From d81b3c3fa3d6bbecfaf17fc7e3dbc23d73d94fbe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=A9lix=20Malfait?= Date: Sun, 28 Jun 2026 07:36:48 +0200 Subject: [PATCH] feat(twenty-sdk): extract & compile app translations into the manifest (#22236) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary **PR 2/4** of the app-metadata-translations stack. Gives app developers the authoring side, as part of the normal manifest build — and it stays out of the way of developers who don't translate. - `twenty-sdk` CLI i18n pipeline: collect translatable strings from the manifest, generate value-as-key message ids (`sha256(value)` truncated, byte-identical to the server's `generateMessageId`), a `dev i18n-extract` command to scaffold per-locale catalog files, and a compile step folded into `build` that emits `manifest.translations`. - Opt-in: no `locales/` dir → `compileApplicationTranslations` returns `undefined` → manifest is unchanged. - Adds an optional `locale` to the front-component execution context so components can translate against the host locale. ## Stack Stacks on #22235 (PR 1/4). Base branch: `claude/app-translation-1-runtime-resolution`. ## Tests Unit (vitest): extract/compile round-trip + message-id determinism. ## Verification note `yarn install` could not complete in the remote dev environment, so typecheck/lint/tests were not run locally — **CI is the source of truth**. https://claude.ai/code/session_01NiE7o3cd3zCLZarVkJa6UA --- _Generated by [Claude Code](https://claude.ai/code/session_01NiE7o3cd3zCLZarVkJa6UA)_ Review in cubic --- .../types/FrontComponentExecutionContext.ts | 1 + ...useFrontComponentExecutionContext.test.tsx | 2 + .../useFrontComponentExecutionContext.ts | 3 +- .../src/cli/commands/dev/i18n-extract.ts | 75 +++++++++++++++ .../twenty-sdk/src/cli/commands/dev/index.ts | 16 ++++ .../twenty-sdk/src/cli/operations/build.ts | 8 +- .../application-translations.spec.ts | 92 +++++++++++++++++++ .../i18n/collect-translatable-strings.ts | 42 +++++++++ .../i18n/compile-application-translations.ts | 84 +++++++++++++++++ .../src/cli/utilities/i18n/constants.ts | 1 + .../i18n/extract-application-translations.ts | 75 +++++++++++++++ .../cli/utilities/i18n/generate-message-id.ts | 9 ++ .../types/FrontComponentExecutionContext.ts | 1 + 13 files changed, 407 insertions(+), 2 deletions(-) create mode 100644 packages/twenty-sdk/src/cli/commands/dev/i18n-extract.ts create mode 100644 packages/twenty-sdk/src/cli/utilities/i18n/__tests__/application-translations.spec.ts create mode 100644 packages/twenty-sdk/src/cli/utilities/i18n/collect-translatable-strings.ts create mode 100644 packages/twenty-sdk/src/cli/utilities/i18n/compile-application-translations.ts create mode 100644 packages/twenty-sdk/src/cli/utilities/i18n/constants.ts create mode 100644 packages/twenty-sdk/src/cli/utilities/i18n/extract-application-translations.ts create mode 100644 packages/twenty-sdk/src/cli/utilities/i18n/generate-message-id.ts diff --git a/packages/twenty-front-component-renderer/src/types/FrontComponentExecutionContext.ts b/packages/twenty-front-component-renderer/src/types/FrontComponentExecutionContext.ts index ab6282a834..67771ad1e5 100644 --- a/packages/twenty-front-component-renderer/src/types/FrontComponentExecutionContext.ts +++ b/packages/twenty-front-component-renderer/src/types/FrontComponentExecutionContext.ts @@ -9,4 +9,5 @@ export type FrontComponentExecutionContext = { selectedRecordIds: string[]; /** Resolved color scheme of the host UI ('System' is already resolved) */ colorScheme: 'light' | 'dark'; + locale?: string; }; diff --git a/packages/twenty-front/src/modules/front-components/hooks/__tests__/useFrontComponentExecutionContext.test.tsx b/packages/twenty-front/src/modules/front-components/hooks/__tests__/useFrontComponentExecutionContext.test.tsx index bad5168ef3..cc072aacdb 100644 --- a/packages/twenty-front/src/modules/front-components/hooks/__tests__/useFrontComponentExecutionContext.test.tsx +++ b/packages/twenty-front/src/modules/front-components/hooks/__tests__/useFrontComponentExecutionContext.test.tsx @@ -180,6 +180,7 @@ describe('useFrontComponentExecutionContext', () => { recordId: 'record-456', selectedRecordIds: ['record-456'], colorScheme: 'light', + locale: i18n.locale, }); }); @@ -195,6 +196,7 @@ describe('useFrontComponentExecutionContext', () => { recordId: null, selectedRecordIds: ['record-1', 'record-2', 'record-3'], colorScheme: 'light', + locale: i18n.locale, }); }); diff --git a/packages/twenty-front/src/modules/front-components/hooks/useFrontComponentExecutionContext.ts b/packages/twenty-front/src/modules/front-components/hooks/useFrontComponentExecutionContext.ts index 236c60beda..79dfddba1c 100644 --- a/packages/twenty-front/src/modules/front-components/hooks/useFrontComponentExecutionContext.ts +++ b/packages/twenty-front/src/modules/front-components/hooks/useFrontComponentExecutionContext.ts @@ -80,7 +80,7 @@ export const useFrontComponentExecutionContext = ({ } = useSnackBar(); const { closeSidePanelMenu } = useSidePanelMenu(); const { copyToClipboard: copyToClipboardWithSnackbar } = useCopyToClipboard(); - const { t } = useLingui(); + const { t, i18n } = useLingui(); // oxlint-disable-next-line twenty/no-state-useref const lastCopyToClipboardCallAtRef = useRef(Number.NEGATIVE_INFINITY); const setCommandMenuItemProgress = useSetAtomFamilyState( @@ -252,6 +252,7 @@ export const useFrontComponentExecutionContext = ({ recordId: selectedRecordIds?.length === 1 ? selectedRecordIds[0] : null, selectedRecordIds: selectedRecordIds ?? [], colorScheme, + locale: i18n.locale, }; const unmountFrontComponent: FrontComponentHostCommunicationApi['unmountFrontComponent'] = diff --git a/packages/twenty-sdk/src/cli/commands/dev/i18n-extract.ts b/packages/twenty-sdk/src/cli/commands/dev/i18n-extract.ts new file mode 100644 index 0000000000..4ce066e7d7 --- /dev/null +++ b/packages/twenty-sdk/src/cli/commands/dev/i18n-extract.ts @@ -0,0 +1,75 @@ +import { buildAndValidateManifest } from '@/cli/utilities/build/manifest/build-and-validate-manifest'; +import { CURRENT_EXECUTION_DIRECTORY } from '@/cli/utilities/config/current-execution-directory'; +import { extractApplicationTranslations } from '@/cli/utilities/i18n/extract-application-translations'; +import chalk from 'chalk'; +import { + APP_LOCALES, + SOURCE_LOCALE, + type AppLocale, +} from 'twenty-shared/translations'; + +export type AppI18nExtractOptions = { + appPath?: string; + locale?: string; +}; + +const isSupportedLocale = (locale: string): locale is AppLocale => + Object.prototype.hasOwnProperty.call(APP_LOCALES, locale); + +export class AppI18nExtractCommand { + async execute(options: AppI18nExtractOptions): Promise { + const appPath = options.appPath ?? CURRENT_EXECUTION_DIRECTORY; + + let scaffoldLocale: AppLocale | undefined; + + if (options.locale !== undefined) { + if ( + options.locale === SOURCE_LOCALE || + !isSupportedLocale(options.locale) + ) { + console.error( + chalk.red( + `"${options.locale}" is not a supported target locale. Choose one of: ${Object.keys( + APP_LOCALES, + ) + .filter((locale) => locale !== SOURCE_LOCALE) + .join(', ')}`, + ), + ); + process.exit(1); + } + + scaffoldLocale = options.locale; + } + + console.log(chalk.blue('Extracting translatable strings...')); + + const manifestResult = await buildAndValidateManifest(appPath); + + if (!manifestResult.success) { + console.error(chalk.red(manifestResult.errors.join('\n'))); + process.exit(1); + } + + if (manifestResult.warnings.length > 0) { + console.warn(chalk.yellow(manifestResult.warnings.join('\n'))); + } + + const { sourceCount, updatedLocaleFiles } = + await extractApplicationTranslations({ + appPath, + manifest: manifestResult.manifest, + scaffoldLocale, + }); + + console.log( + chalk.green( + `✓ Extracted ${sourceCount} string${sourceCount === 1 ? '' : 's'} to locales/`, + ), + ); + + if (updatedLocaleFiles.length > 0) { + console.log(chalk.gray(`Updated: ${updatedLocaleFiles.join(', ')}`)); + } + } +} diff --git a/packages/twenty-sdk/src/cli/commands/dev/index.ts b/packages/twenty-sdk/src/cli/commands/dev/index.ts index 1754e07050..55557abb54 100644 --- a/packages/twenty-sdk/src/cli/commands/dev/index.ts +++ b/packages/twenty-sdk/src/cli/commands/dev/index.ts @@ -7,6 +7,7 @@ import { AppBuildCommand } from './build'; import { AppDevCommand } from './dev'; import { AppDevOnceCommand } from './dev-once'; import { AppGenerateClientCommand } from './generate-client'; +import { AppI18nExtractCommand } from './i18n-extract'; import { AppTypecheckCommand } from './typecheck'; import { registerDevFunctionCommands } from './function'; @@ -17,6 +18,7 @@ export const registerDevCommands = (program: Command): void => { const typecheckCommand = new AppTypecheckCommand(); const addCommand = new EntityAddCommand(); const generateClientCommand = new AppGenerateClientCommand(); + const i18nExtractCommand = new AppI18nExtractCommand(); const devAction = async ( appPath: string | undefined, @@ -123,5 +125,19 @@ export const registerDevCommands = (program: Command): void => { }); }); + program + .command('dev:i18n-extract [appPath]') + .description('Extract translatable strings into locales/ catalogs') + .option( + '--locale ', + 'Scaffold an empty catalog for a target locale (e.g. fr-FR)', + ) + .action(async (appPath, options) => { + await i18nExtractCommand.execute({ + appPath: formatPath(appPath), + locale: options.locale, + }); + }); + registerDevFunctionCommands(program); }; diff --git a/packages/twenty-sdk/src/cli/operations/build.ts b/packages/twenty-sdk/src/cli/operations/build.ts index b585bf18b5..3691d83660 100644 --- a/packages/twenty-sdk/src/cli/operations/build.ts +++ b/packages/twenty-sdk/src/cli/operations/build.ts @@ -7,6 +7,7 @@ import { runTypecheck } from '@/cli/utilities/build/common/typecheck-plugin'; import { buildAndValidateManifest } from '@/cli/utilities/build/manifest/build-and-validate-manifest'; import { manifestUpdateChecksums } from '@/cli/utilities/build/manifest/manifest-update-checksums'; import { writeManifestToOutput } from '@/cli/utilities/build/manifest/manifest-writer'; +import { compileApplicationTranslations } from '@/cli/utilities/i18n/compile-application-translations'; import { runSafe } from '@/cli/utilities/run-safe'; import { APP_ERROR_CODES, type CommandResult } from '@/cli/types'; @@ -62,6 +63,8 @@ const innerAppBuild = async ( onProgress?.('Generated cover image from logo'); } + const translations = await compileApplicationTranslations(appPath); + onProgress?.('Building application files...'); const buildResult = await buildApplication({ @@ -95,7 +98,10 @@ const innerAppBuild = async ( builtFileInfos: buildResult.builtFileInfos, }); - await writeManifestToOutput(appPath, updatedManifest); + await writeManifestToOutput( + appPath, + translations ? { ...updatedManifest, translations } : updatedManifest, + ); const outputDir = path.join(appPath, '.twenty', 'output'); diff --git a/packages/twenty-sdk/src/cli/utilities/i18n/__tests__/application-translations.spec.ts b/packages/twenty-sdk/src/cli/utilities/i18n/__tests__/application-translations.spec.ts new file mode 100644 index 0000000000..5cca097574 --- /dev/null +++ b/packages/twenty-sdk/src/cli/utilities/i18n/__tests__/application-translations.spec.ts @@ -0,0 +1,92 @@ +import { mkdir, mkdtemp, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { describe, expect, it } from 'vitest'; + +import { collectTranslatableStrings } from '@/cli/utilities/i18n/collect-translatable-strings'; +import { compileApplicationTranslations } from '@/cli/utilities/i18n/compile-application-translations'; +import { generateMessageId } from '@/cli/utilities/i18n/generate-message-id'; +import { type Manifest } from 'twenty-shared/application'; + +const buildManifest = (overrides: Record): Manifest => + ({ + application: {}, + objects: [], + fields: [], + views: [], + pageLayoutTabs: [], + commandMenuItems: [], + navigationMenuItems: [], + ...overrides, + }) as unknown as Manifest; + +describe('generateMessageId', () => { + it('is deterministic and six characters long', () => { + expect(generateMessageId('Company')).toHaveLength(6); + expect(generateMessageId('Company')).toBe(generateMessageId('Company')); + }); + + it('differs for different sources and contexts', () => { + expect(generateMessageId('Company')).not.toBe( + generateMessageId('Companies'), + ); + expect(generateMessageId('Cancel', 'subscription')).not.toBe( + generateMessageId('Cancel'), + ); + }); +}); + +describe('collectTranslatableStrings', () => { + it('collects and dedupes object and field strings', () => { + const manifest = buildManifest({ + objects: [ + { + labelSingular: 'Company', + labelPlural: 'Companies', + description: 'A company', + }, + ], + fields: [ + { label: 'Name', description: 'Name' }, + { label: 'Company', description: '' }, + ], + }); + + expect(collectTranslatableStrings(manifest)).toEqual([ + 'A company', + 'Companies', + 'Company', + 'Name', + ]); + }); +}); + +describe('compileApplicationTranslations', () => { + it('compiles catalogs keyed by message id, skipping source locale and empty values', async () => { + const appPath = await mkdtemp(join(tmpdir(), 'twenty-i18n-')); + const localesDir = join(appPath, 'locales'); + + await mkdir(localesDir, { recursive: true }); + await writeFile( + join(localesDir, 'fr-FR.json'), + JSON.stringify({ Company: 'Entreprise', Untranslated: '' }), + ); + await writeFile( + join(localesDir, 'en.json'), + JSON.stringify({ Company: 'Company' }), + ); + + const result = await compileApplicationTranslations(appPath); + + expect(result).toEqual({ + 'fr-FR': { [generateMessageId('Company')]: 'Entreprise' }, + }); + }); + + it('returns undefined when there is no locales directory', async () => { + const appPath = await mkdtemp(join(tmpdir(), 'twenty-i18n-empty-')); + + expect(await compileApplicationTranslations(appPath)).toBeUndefined(); + }); +}); diff --git a/packages/twenty-sdk/src/cli/utilities/i18n/collect-translatable-strings.ts b/packages/twenty-sdk/src/cli/utilities/i18n/collect-translatable-strings.ts new file mode 100644 index 0000000000..d5a49608b4 --- /dev/null +++ b/packages/twenty-sdk/src/cli/utilities/i18n/collect-translatable-strings.ts @@ -0,0 +1,42 @@ +import { type Manifest } from 'twenty-shared/application'; + +const TRANSLATABLE_KEYS_BY_MANIFEST_KEY: Record = { + objects: ['labelSingular', 'labelPlural', 'description'], + fields: ['label', 'description'], + views: ['name'], + pageLayoutTabs: ['title'], + commandMenuItems: ['label', 'shortLabel'], + navigationMenuItems: ['label'], +}; + +export const collectTranslatableStrings = (manifest: Manifest): string[] => { + const strings = new Set(); + + for (const [manifestKey, fieldKeys] of Object.entries( + TRANSLATABLE_KEYS_BY_MANIFEST_KEY, + )) { + const entities = (manifest as unknown as Record)[ + manifestKey + ]; + + if (!Array.isArray(entities)) { + continue; + } + + for (const entity of entities) { + if (entity === null || typeof entity !== 'object') { + continue; + } + + for (const fieldKey of fieldKeys) { + const value = (entity as Record)[fieldKey]; + + if (typeof value === 'string' && value.length > 0) { + strings.add(value); + } + } + } + } + + return [...strings].sort(); +}; diff --git a/packages/twenty-sdk/src/cli/utilities/i18n/compile-application-translations.ts b/packages/twenty-sdk/src/cli/utilities/i18n/compile-application-translations.ts new file mode 100644 index 0000000000..5f26b8399d --- /dev/null +++ b/packages/twenty-sdk/src/cli/utilities/i18n/compile-application-translations.ts @@ -0,0 +1,84 @@ +import { readdir } from 'node:fs/promises'; +import path from 'path'; + +import { pathExists, readJson } from '@/cli/utilities/file/fs-utils'; +import { LOCALES_DIR } from '@/cli/utilities/i18n/constants'; +import { generateMessageId } from '@/cli/utilities/i18n/generate-message-id'; +import { type TranslationsManifest } from 'twenty-shared/application'; +import { + APP_LOCALES, + SOURCE_LOCALE, + type AppLocale, +} from 'twenty-shared/translations'; + +const isSupportedLocale = (locale: string): locale is AppLocale => + Object.prototype.hasOwnProperty.call(APP_LOCALES, locale); + +export const compileApplicationTranslations = async ( + appPath: string, +): Promise => { + const localesDir = path.join(appPath, LOCALES_DIR); + + if (!(await pathExists(localesDir))) { + return undefined; + } + + const localeFiles = (await readdir(localesDir)).filter((entry) => + entry.endsWith('.json'), + ); + + const translations: Record> = {}; + + for (const localeFile of localeFiles) { + const locale = path.basename(localeFile, '.json'); + + if (locale === SOURCE_LOCALE) { + continue; + } + + if (!isSupportedLocale(locale)) { + console.warn( + `Skipping translation file "${localeFile}": "${locale}" is not a supported locale.`, + ); + continue; + } + + const sourceToTranslation = + (await readJson>( + path.join(localesDir, localeFile), + )) ?? {}; + + const compiled: Record = {}; + // Detect when two distinct source strings hash to the same message id so the + // collision is reported instead of silently overwriting the earlier value. + const sourceByMessageId = new Map(); + + for (const [source, translation] of Object.entries(sourceToTranslation)) { + if (typeof translation !== 'string' || translation.length === 0) { + continue; + } + + const messageId = generateMessageId(source); + const collidingSource = sourceByMessageId.get(messageId); + + if (collidingSource !== undefined && collidingSource !== source) { + console.warn( + `Message id collision in "${localeFile}": "${source}" and "${collidingSource}" share id "${messageId}". Keeping "${source}".`, + ); + } + + sourceByMessageId.set(messageId, source); + compiled[messageId] = translation; + } + + if (Object.keys(compiled).length > 0) { + translations[locale] = compiled; + } + } + + if (Object.keys(translations).length === 0) { + return undefined; + } + + return translations as TranslationsManifest; +}; diff --git a/packages/twenty-sdk/src/cli/utilities/i18n/constants.ts b/packages/twenty-sdk/src/cli/utilities/i18n/constants.ts new file mode 100644 index 0000000000..fa6a338bd2 --- /dev/null +++ b/packages/twenty-sdk/src/cli/utilities/i18n/constants.ts @@ -0,0 +1 @@ +export const LOCALES_DIR = 'locales'; diff --git a/packages/twenty-sdk/src/cli/utilities/i18n/extract-application-translations.ts b/packages/twenty-sdk/src/cli/utilities/i18n/extract-application-translations.ts new file mode 100644 index 0000000000..66a0091d9e --- /dev/null +++ b/packages/twenty-sdk/src/cli/utilities/i18n/extract-application-translations.ts @@ -0,0 +1,75 @@ +import { readdir } from 'node:fs/promises'; +import path from 'path'; + +import { + ensureDir, + pathExists, + readJson, + writeJson, +} from '@/cli/utilities/file/fs-utils'; +import { collectTranslatableStrings } from '@/cli/utilities/i18n/collect-translatable-strings'; +import { LOCALES_DIR } from '@/cli/utilities/i18n/constants'; +import { type Manifest } from 'twenty-shared/application'; +import { SOURCE_LOCALE, type AppLocale } from 'twenty-shared/translations'; + +export type ExtractApplicationTranslationsResult = { + sourceCount: number; + updatedLocaleFiles: string[]; +}; + +export const extractApplicationTranslations = async ({ + appPath, + manifest, + scaffoldLocale, +}: { + appPath: string; + manifest: Manifest; + scaffoldLocale?: AppLocale; +}): Promise => { + const sources = collectTranslatableStrings(manifest); + const localesDir = path.join(appPath, LOCALES_DIR); + + await ensureDir(localesDir); + + const sourceCatalog: Record = {}; + + for (const source of sources) { + sourceCatalog[source] = source; + } + + await writeJson( + path.join(localesDir, `${SOURCE_LOCALE}.json`), + sourceCatalog, + ); + + // Create an empty catalog for a brand-new locale so the merge step below + // populates it with the current source keys. + if (scaffoldLocale !== undefined && scaffoldLocale !== SOURCE_LOCALE) { + const scaffoldPath = path.join(localesDir, `${scaffoldLocale}.json`); + + if (!(await pathExists(scaffoldPath))) { + await writeJson(scaffoldPath, {}); + } + } + + const existingLocaleFiles = (await readdir(localesDir)).filter( + (entry) => entry.endsWith('.json') && entry !== `${SOURCE_LOCALE}.json`, + ); + + for (const localeFile of existingLocaleFiles) { + const filePath = path.join(localesDir, localeFile); + const existing = (await readJson>(filePath)) ?? {}; + const merged: Record = {}; + + for (const source of sources) { + merged[source] = existing[source] ?? ''; + } + + await writeJson(filePath, merged); + } + + return { + sourceCount: sources.length, + updatedLocaleFiles: existingLocaleFiles, + }; +}; diff --git a/packages/twenty-sdk/src/cli/utilities/i18n/generate-message-id.ts b/packages/twenty-sdk/src/cli/utilities/i18n/generate-message-id.ts new file mode 100644 index 0000000000..15216bc8c5 --- /dev/null +++ b/packages/twenty-sdk/src/cli/utilities/i18n/generate-message-id.ts @@ -0,0 +1,9 @@ +import { createHash } from 'node:crypto'; + +const UNIT_SEPARATOR = '\u001F'; + +export const generateMessageId = (message: string, context = ''): string => + createHash('sha256') + .update(message + UNIT_SEPARATOR + (context || '')) + .digest('base64') + .slice(0, 6); diff --git a/packages/twenty-sdk/src/sdk/front-component/types/FrontComponentExecutionContext.ts b/packages/twenty-sdk/src/sdk/front-component/types/FrontComponentExecutionContext.ts index ab6282a834..67771ad1e5 100644 --- a/packages/twenty-sdk/src/sdk/front-component/types/FrontComponentExecutionContext.ts +++ b/packages/twenty-sdk/src/sdk/front-component/types/FrontComponentExecutionContext.ts @@ -9,4 +9,5 @@ export type FrontComponentExecutionContext = { selectedRecordIds: string[]; /** Resolved color scheme of the host UI ('System' is already resolved) */ colorScheme: 'light' | 'dark'; + locale?: string; };