feat(richtext-lexical): i18n support (#6524)

This commit is contained in:
Alessio Gravili
2024-05-28 09:10:39 -04:00
committed by GitHub
parent 8a91a7adbb
commit c383f391e3
17 changed files with 322 additions and 53 deletions

View File

@@ -40,7 +40,7 @@ export const Account: React.FC<AdminViewProps> = async ({
const collectionConfig = config.collections.find((collection) => collection.slug === userSlug)
if (collectionConfig) {
if (collectionConfig && user?.id) {
const { docPermissions, hasPublishPermission, hasSavePermission } =
await getDocumentPermissions({
id: user.id,

View File

@@ -1,4 +1,4 @@
import type { I18nClient } from '@payloadcms/translations'
import type { GenericLanguages, I18n, I18nClient } from '@payloadcms/translations'
import type { JSONSchema4 } from 'json-schema'
import type React from 'react'
@@ -28,11 +28,12 @@ type RichTextAdapterBase<
}) => Map<string, React.ReactNode>
generateSchemaMap?: (args: {
config: SanitizedConfig
i18n: I18nClient
i18n: I18n<any, any>
schemaMap: Map<string, Field[]>
schemaPath: string
}) => Map<string, Field[]>
hooks?: FieldBase['hooks']
i18n?: Partial<GenericLanguages>
outputSchema?: ({
collectionIDFieldTypes,
config,

View File

@@ -17,6 +17,7 @@ import { InvalidConfiguration } from '../errors/index.js'
import { sanitizeGlobals } from '../globals/config/sanitize.js'
import getPreferencesCollection from '../preferences/preferencesCollection.js'
import checkDuplicateCollections from '../utilities/checkDuplicateCollections.js'
import { deepMerge } from '../utilities/deepMerge.js'
import { isPlainObject } from '../utilities/isPlainObject.js'
import { defaults } from './defaults.js'
@@ -154,6 +155,9 @@ export const sanitizeConfig = async (incomingConfig: Config): Promise<SanitizedC
config: config as SanitizedConfig,
isRoot: true,
})
if (config.editor.i18n && Object.keys(config.editor.i18n).length >= 0) {
config.i18n.translations = deepMerge(config.i18n.translations, config.editor.i18n)
}
}
const promises: Promise<void>[] = []

View File

@@ -8,6 +8,7 @@ import {
InvalidFieldRelationship,
MissingFieldType,
} from '../../errors/index.js'
import { deepMerge } from '../../utilities/deepMerge.js'
import { formatLabels, toWords } from '../../utilities/formatLabels.js'
import { baseBlockFields } from '../baseFields/baseBlockFields.js'
import { baseIDField } from '../baseFields/baseIDField.js'
@@ -168,6 +169,10 @@ export const sanitizeFields = async ({
})
}
if (field.editor.i18n && Object.keys(field.editor.i18n).length >= 0) {
config.i18n.translations = deepMerge(config.i18n.translations, field.editor.i18n)
}
// Add editor adapter hooks to field hooks
if (!field.hooks) field.hooks = {}

View File

@@ -37,7 +37,8 @@
"build:types": "tsc --emitDeclarationOnly --outDir dist",
"clean": "rimraf {dist,*.tsbuildinfo}",
"copyfiles": "copyfiles -u 1 \"src/**/*.{html,css,scss,ttf,woff,woff2,eot,svg,jpg,png,json}\" dist/",
"prepublishOnly": "pnpm clean && pnpm turbo build"
"prepublishOnly": "pnpm clean && pnpm turbo build",
"translateNewKeys": "tsx scripts/translateNewKeys.ts"
},
"dependencies": {
"@faceless-ui/modal": "2.0.2",

View File

@@ -0,0 +1,68 @@
import type {
AcceptedLanguages,
GenericLanguages,
GenericTranslationsObject,
} from '@payloadcms/translations'
import * as fs from 'node:fs'
import path from 'path'
import { fileURLToPath } from 'url'
import { translateObject } from '../../translations/scripts/translateNewKeys/index.js'
const filename = fileURLToPath(import.meta.url)
const dirname = path.dirname(filename)
// Function to get all files with a specific name recursively in all subdirectories
function findFilesRecursively(startPath, filter) {
let results = []
const entries = fs.readdirSync(startPath, { withFileTypes: true })
entries.forEach((dirent) => {
const fullPath = path.join(startPath, dirent.name)
if (dirent.isDirectory()) {
results = results.concat(findFilesRecursively(fullPath, filter))
} else {
if (dirent.name === filter) {
results.push(fullPath)
}
}
})
return results
}
const i18nFilePaths = findFilesRecursively(path.resolve(dirname, '../src'), 'i18n.ts')
async function translate() {
for (const i18nFilePath of i18nFilePaths) {
const imported = await import(i18nFilePath)
const translationsObject = imported.i18n as Partial<GenericLanguages>
const allTranslations: {
[key in AcceptedLanguages]?: {
dateFNSKey: string
translations: GenericTranslationsObject
}
} = {}
for (const lang in translationsObject) {
allTranslations[lang] = {
dateFNSKey: 'en',
translations: translationsObject[lang],
}
}
console.log('Translating', i18nFilePath)
await translateObject({
allTranslationsObject: allTranslations,
fromTranslationsObject: translationsObject.en,
inlineFile: i18nFilePath,
tsFilePrefix: `import { GenericLanguages } from '@payloadcms/translations'
export const i18n: Partial<GenericLanguages> = `,
tsFileSuffix: ``,
})
}
}
void translate()

View File

@@ -1,5 +1,7 @@
'use client'
import type { ClientTranslationKeys } from '@payloadcms/translations'
import { $isNodeSelection } from 'lexical'
import type { FeatureProviderProviderClient } from '../types.js'
@@ -36,7 +38,10 @@ const HorizontalRuleFeatureClient: FeatureProviderProviderClient<undefined> = (p
Icon: HorizontalRuleIcon,
key: 'horizontalRule',
keywords: ['hr', 'horizontal rule', 'line', 'separator'],
label: `Horizontal Rule`,
label: ({ i18n }) => {
return i18n.t('lexical:horizontalRule:label')
},
onSelect: ({ editor }) => {
editor.dispatchCommand(INSERT_HORIZONTAL_RULE_COMMAND, undefined)
},
@@ -47,6 +52,7 @@ const HorizontalRuleFeatureClient: FeatureProviderProviderClient<undefined> = (p
},
],
},
toolbarFixed: {
groups: [
toolbarAddDropdownGroupWithItems([
@@ -61,7 +67,9 @@ const HorizontalRuleFeatureClient: FeatureProviderProviderClient<undefined> = (p
return $isHorizontalRuleNode(firstNode)
},
key: 'horizontalRule',
label: `Horizontal Rule`,
label: ({ i18n }) => {
return i18n.t('lexical:horizontalRule:label')
},
onSelect: ({ editor }) => {
editor.dispatchCommand(INSERT_HORIZONTAL_RULE_COMMAND, undefined)
},

View File

@@ -2,6 +2,7 @@ import type { FeatureProviderProviderServer } from '../types.js'
import { createNode } from '../typeUtilities.js'
import { HorizontalRuleFeatureClientComponent } from './feature.client.js'
import { i18n } from './i18n.js'
import { MarkdownTransformer } from './markdownTransformer.js'
import { HorizontalRuleNode } from './nodes/HorizontalRuleNode.js'
@@ -12,6 +13,7 @@ export const HorizontalRuleFeature: FeatureProviderProviderServer<undefined, und
feature: () => {
return {
ClientComponent: HorizontalRuleFeatureClientComponent,
i18n,
markdownTransformers: [MarkdownTransformer],
nodes: [
createNode({

View File

@@ -0,0 +1,100 @@
import type { GenericLanguages } from '@payloadcms/translations'
export const i18n: Partial<GenericLanguages> = {
ar: {
label: 'القاعدة الأفقية',
},
az: {
label: 'Üfüqi Xətt',
},
bg: {
label: 'Хоризонтална линия',
},
cs: {
label: 'Vodorovný pravítko',
},
de: {
label: 'Trennlinie',
},
en: {
label: 'Horizontal Rule',
},
es: {
label: 'Regla Horizontal',
},
fa: {
label: 'قاعده افقی',
},
fr: {
label: 'Règle horizontale',
},
he: {
label: 'קו אופקי',
},
hr: {
label: 'Vodoravna linija',
},
hu: {
label: 'Vízszintes vonal',
},
it: {
label: 'Regola Orizzontale',
},
ja: {
label: '水平線',
},
ko: {
label: '수평 규칙',
},
my: {
label: 'Peraturan Mendatar',
},
nb: {
label: 'Horisontal Regel',
},
nl: {
label: 'Horizontale Regel',
},
pl: {
label: 'Pozioma Linia',
},
pt: {
label: 'Regra Horizontal',
},
ro: {
label: 'Linie orizontală',
},
rs: {
label: 'Horizontalna linija',
},
'rs-latin': {
label: 'Horizontalna linija',
},
ru: {
label: 'Горизонтальная линия',
},
sk: {
label: 'Vodorovná čiara',
},
sv: {
label: 'Horisontell linje',
},
th: {
label: 'กฎขีดตรง',
},
tr: {
label: 'Yatay Çizgi',
},
uk: {
label: 'Горизонтальна лінія',
},
vi: {
label: 'Quy tắc ngang',
},
zh: {
label: '水平线',
},
'zh-TW': {
label: '水平線',
},
}

View File

@@ -49,7 +49,7 @@ export type ToolbarGroupItem = {
}) => boolean
key: string
/** The label is displayed as text if the item is part of a dropdown group */
label?: (({ i18n }: { i18n: I18nClient }) => string) | string
label?: (({ i18n }: { i18n: I18nClient<{}, string> }) => string) | string
onSelect?: ({ editor, isActive }: { editor: LexicalEditor; isActive: boolean }) => void
order?: number
}

View File

@@ -1,5 +1,5 @@
import type { Transformer } from '@lexical/markdown'
import type { I18n, I18nClient } from '@payloadcms/translations'
import type { GenericLanguages, I18n, I18nClient } from '@payloadcms/translations'
import type { JSONSchema4 } from 'json-schema'
import type { Klass, LexicalEditor, LexicalNode, SerializedEditorState } from 'lexical'
import type { SerializedLexicalNode } from 'lexical'
@@ -319,6 +319,25 @@ export type ServerFeature<ServerProps, ClientFeatureProps> = {
isRequired: boolean
}) => JSONSchema4
}
/**
* Here you can provide i18n translations for your feature. These will only be available on the server and client.
*
* Translations here are automatically scoped to `lexical.featureKey.yourKey`
*
* @Example
* ```ts
* i18n: {
* en: {
* label: 'Horizontal Rule',
* },
* de: {
* label: 'Trennlinie',
* },
* }
* ```
* In order to access these translations, you would use `i18n.t('lexical:horizontalRule:label')`.
*/
i18n?: Partial<GenericLanguages>
markdownTransformers?: Transformer[]
nodes?: Array<NodeWithHooks>
@@ -395,8 +414,9 @@ export type SanitizedPlugin =
key: string
position: 'belowContainer'
}
export type SanitizedServerFeatures = Required<
Pick<ResolvedServerFeature<unknown, unknown>, 'markdownTransformers' | 'nodes'>
Pick<ResolvedServerFeature<unknown, unknown>, 'i18n' | 'markdownTransformers' | 'nodes'>
> & {
/** The node types mapped to their converters */
converters: {

View File

@@ -13,10 +13,10 @@ export const sanitizeServerFeatures = (
html: [],
},
enabledFeatures: [],
generatedTypes: {
modifyOutputSchemas: [],
},
hooks: {
afterChange: new Map(),
afterRead: new Map(),
@@ -24,6 +24,7 @@ export const sanitizeServerFeatures = (
beforeDuplicate: new Map(),
beforeValidate: new Map(),
},
i18n: {},
markdownTransformers: [],
nodes: [],
populationPromises: new Map(),
@@ -40,6 +41,17 @@ export const sanitizeServerFeatures = (
sanitized.generatedTypes.modifyOutputSchemas.push(feature.generatedTypes.modifyOutputSchema)
}
if (feature?.i18n) {
for (const lang in feature.i18n) {
if (!sanitized.i18n[lang]) {
sanitized.i18n[lang] = {
lexical: {},
}
}
sanitized.i18n[lang].lexical[feature.key] = feature.i18n[lang]
}
}
if (feature.nodes?.length) {
sanitized.nodes = sanitized.nodes.concat(feature.nodes)
feature.nodes.forEach((node) => {

View File

@@ -13,7 +13,7 @@ export type SlashMenuItem = {
keyboardShortcut?: string
// For extra searching.
keywords?: Array<string>
label?: (({ i18n }: { i18n: I18nClient }) => string) | string
label?: (({ i18n }: { i18n: I18nClient<{}, string> }) => string) | string
// What happens when you select this item?
onSelect: ({ editor, queryString }: { editor: LexicalEditor; queryString: string }) => void
}
@@ -22,7 +22,7 @@ export type SlashMenuGroup = {
items: Array<SlashMenuItem>
key: string
// Used for class names and, if label is not provided, for display.
label?: (({ i18n }: { i18n: I18nClient }) => string) | string
label?: (({ i18n }: { i18n: I18nClient<{}, string> }) => string) | string
}
export type SlashMenuItemInternal = SlashMenuItem & {

View File

@@ -109,6 +109,7 @@ export function lexicalEditor(props?: LexicalEditorProps): LexicalRichTextAdapte
generateSchemaMap: getGenerateSchemaMap({
resolvedFeatureMap,
}),
i18n: finalSanitizedEditorConfig.features.i18n,
/* hooks: {
afterChange: finalSanitizedEditorConfig.features.hooks.afterChange,
afterRead: finalSanitizedEditorConfig.features.hooks.afterRead,

View File

@@ -32,7 +32,7 @@
"build:types": "tsc --outDir dist",
"clean": "rimraf {dist,*.tsbuildinfo}",
"prepublishOnly": "pnpm clean && pnpm turbo build",
"translateNewKeys": "tsx scripts/translateNewKeys/index.ts"
"translateNewKeys": "tsx scripts/translateNewKeys/run.ts"
},
"devDependencies": {
"@payloadcms/eslint-config": "workspace:*",

View File

@@ -3,7 +3,6 @@
import fs from 'fs'
import path from 'path'
import { format } from 'prettier'
import { fileURLToPath } from 'url'
import type {
AcceptedLanguages,
@@ -11,8 +10,6 @@ import type {
GenericTranslationsObject,
} from '../../src/types.js'
import { translations } from '../../src/exports/all.js'
import { enTranslations } from '../../src/languages/en.js'
import { cloneDeep } from '../../src/utilities/cloneDeep.js'
import { deepMerge } from '../../src/utilities/deepMerge.js'
import { acceptedLanguages } from '../../src/utilities/languages.js'
@@ -22,9 +19,6 @@ import { generateTsObjectLiteral } from './generateTsObjectLiteral.js'
import { sortKeys } from './sortKeys.js'
import { translateText } from './translateText.js'
const filename = fileURLToPath(import.meta.url)
const dirname = path.dirname(filename)
/**
*
* props.allTranslationsObject:
@@ -59,6 +53,13 @@ export async function translateObject(props: {
}
}
fromTranslationsObject: GenericTranslationsObject
/**
*
* If set, will output the entire translations object (incl. all locales) to this file.
*
* @default false
*/
inlineFile?: string
languages?: AcceptedLanguages[]
targetFolder?: string
tsFilePrefix?: string
@@ -67,6 +68,7 @@ export async function translateObject(props: {
const {
allTranslationsObject,
fromTranslationsObject,
inlineFile,
languages = acceptedLanguages.filter((lang) => lang !== 'en'),
targetFolder = '',
tsFilePrefix = `import type { DefaultTranslationsObject, Language } from '../types.js'\n\nexport const {{locale}}Translations: DefaultTranslationsObject = `,
@@ -87,6 +89,12 @@ export async function translateObject(props: {
const translationPromises: Promise<void>[] = []
for (const targetLang of languages) {
if (!allTranslatedTranslationsObject?.[targetLang]) {
allTranslatedTranslationsObject[targetLang] = {
dateFNSKey: targetLang,
translations: {},
}
}
const keysWhichDoNotExistInFromlang = findMissingKeys(
allTranslatedTranslationsObject?.[targetLang].translations,
fromTranslationsObject,
@@ -172,22 +180,18 @@ export async function translateObject(props: {
console.log('New translations:', allOnlyNewTranslatedTranslationsObject)
// save
if (inlineFile?.length) {
const simpleTranslationsObject = {}
for (const lang in allTranslatedTranslationsObject) {
simpleTranslationsObject[lang] = allTranslatedTranslationsObject[lang].translations
}
for (const key of languages) {
// e.g. sanitize rs-latin to rsLatin
const sanitizedKey = key.replace(
/-(\w)(\w*)/g,
(_, firstLetter, remainingLetters) =>
firstLetter.toUpperCase() + remainingLetters.toLowerCase(),
)
const filePath = path.resolve(dirname, targetFolder, `${sanitizedKey}.ts`)
// prefix & translations
let fileContent: string = `${tsFilePrefix.replace('{{locale}}', sanitizedKey)}${generateTsObjectLiteral(allTranslatedTranslationsObject[key].translations)}\n`
// write allTranslatedTranslationsObject
const filePath = path.resolve(inlineFile)
let fileContent: string = `${tsFilePrefix}${generateTsObjectLiteral(simpleTranslationsObject)}\n`
// suffix
fileContent += `${tsFileSuffix.replaceAll('{{locale}}', sanitizedKey).replaceAll('{{dateFNSKey}}', `'${allTranslatedTranslationsObject[key].dateFNSKey}'`)}\n`
fileContent += `${tsFileSuffix}\n`
// eslint
fileContent = await applyEslintFixes(fileContent, filePath)
@@ -202,28 +206,39 @@ export async function translateObject(props: {
})
fs.writeFileSync(filePath, fileContent, 'utf8')
} else {
// save
for (const key of languages) {
// e.g. sanitize rs-latin to rsLatin
const sanitizedKey = key.replace(
/-(\w)(\w*)/g,
(_, firstLetter, remainingLetters) =>
firstLetter.toUpperCase() + remainingLetters.toLowerCase(),
)
const filePath = path.resolve(targetFolder, `${sanitizedKey}.ts`)
// prefix & translations
let fileContent: string = `${tsFilePrefix.replace('{{locale}}', sanitizedKey)}${generateTsObjectLiteral(allTranslatedTranslationsObject[key].translations)}\n`
// suffix
fileContent += `${tsFileSuffix.replaceAll('{{locale}}', sanitizedKey).replaceAll('{{dateFNSKey}}', `'${allTranslatedTranslationsObject[key].dateFNSKey}'`)}\n`
// eslint
fileContent = await applyEslintFixes(fileContent, filePath)
// prettier
fileContent = await format(fileContent, {
parser: 'typescript',
printWidth: 100,
semi: false,
singleQuote: true,
trailingComma: 'all',
})
fs.writeFileSync(filePath, fileContent, 'utf8')
}
}
return allTranslatedTranslationsObject
}
const allTranslations: {
[key in AcceptedLanguages]?: {
dateFNSKey: string
translations: GenericTranslationsObject
}
} = {}
for (const key of Object.keys(translations)) {
allTranslations[key] = {
dateFNSKey: translations[key].dateFNSKey,
translations: translations[key].translations,
}
}
void translateObject({
allTranslationsObject: allTranslations,
fromTranslationsObject: enTranslations,
//languages: ['de'],
targetFolder: '../../src/languages',
})

View File

@@ -0,0 +1,32 @@
import path from 'path'
import { fileURLToPath } from 'url'
import type { AcceptedLanguages, GenericTranslationsObject } from '../../src/types.js'
import { translations } from '../../src/exports/all.js'
import { enTranslations } from '../../src/languages/en.js'
import { translateObject } from './index.js'
const filename = fileURLToPath(import.meta.url)
const dirname = path.dirname(filename)
const allTranslations: {
[key in AcceptedLanguages]?: {
dateFNSKey: string
translations: GenericTranslationsObject
}
} = {}
for (const key of Object.keys(translations)) {
allTranslations[key] = {
dateFNSKey: translations[key].dateFNSKey,
translations: translations[key].translations,
}
}
void translateObject({
allTranslationsObject: allTranslations,
fromTranslationsObject: enTranslations,
//languages: ['de'],
targetFolder: path.resolve(dirname, '../../src/languages'),
})