From 55ed4b7adb3cc90772ef46ea4d5c22d54ccb3d96 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=A9lix=20Malfait?= Date: Wed, 1 Jul 2026 18:50:35 +0200 Subject: [PATCH] feat(sdk): translate front-component strings with t()/Trans/useTranslate (#22301) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## What Lets app **front components** localize the strings they render, extending the existing application-translation pipeline (which today only covers manifest labels) to component source. App authors mark strings with a small, familiar API; the build extracts and bakes them; the runtime resolves them for the user's locale. ```tsx import { Trans, t, msg, useTranslate } from 'twenty-sdk/front-component'; Loading postcard… Untitled // disambiguation const empty = t('No content yet…'); // works outside JSX

{t('Saved {count} cards', { count })}

// interpolation const STATUSES = [{ id: 'draft', label: msg('Draft') }]; // lazy descriptor ``` ## How - **Runtime** (`twenty-sdk/front-component`): `t()` (eager, usable anywhere — event handlers, helpers, module scope), `msg()` (lazy descriptor), `` (reactive JSX), `useTranslate()` / `useLocale()`. Source-string fallback, `{name}` interpolation, and `context` disambiguation. No build-time macro — these are plain runtime functions. - **Extraction**: a `ts-morph` scan collects `t()`/`msg()`/`` strings from component source into the same `locales/*.json` catalogs the manifest pipeline already writes (`twenty dev:translations-extract`). - **Delivery**: `twenty dev:build` bakes the compiled per-locale catalog into each front-component bundle via an esbuild banner, so the runtime resolves with **no server or renderer changes**. Locale comes from the execution context that already flows to the worker. The catalog key and `generateMessageId` hashing are shared between the node extractor and the browser runtime; `` text whitespace is normalized identically on both sides so multi-line elements resolve. ## Design notes - Reuses the existing `extract → compile → manifest.translations` contract and `generateMessageId`, so component strings flow through the same machinery as manifest labels. - Self-contained in `twenty-sdk` + a shared pure helper; the server is untouched. ## Scope / follow-ups - `twenty dev` (watch) does not bake catalogs yet — preview shows source strings; use `twenty dev:build` (documented). Wiring the watcher is a follow-up. - Usage is documented in twenty-docs under **Apps → Translations** (`developers/extend/apps/translations`). ## Tests Unit tests for the catalog-key/interpolation helpers, the runtime resolver (hit/miss/context/fallback/interpolation), and the ts-morph extractor (static `t`/`msg`/``, dynamic-skip, dedup, multi-line whitespace), plus a compile test for context→messageId. Verified with an adversarial review pass. https://claude.ai/code/session_01NiE7o3cd3zCLZarVkJa6UA --- _Generated by [Claude Code](https://claude.ai/code/session_01NiE7o3cd3zCLZarVkJa6UA)_ ``<img alt="Review in cubic" src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg">`` --------- Co-authored-by: github-actions --- .../extend/apps/translations/overview.mdx | 125 ++++++++++ packages/twenty-docs/docs.json | 6 + .../navigation/base-structure.json | 7 + .../navigation/navigation.template.json | 3 + .../types/FrontComponentExecutionContext.ts | 4 +- ...useFrontComponentExecutionContext.test.tsx | 5 +- .../useFrontComponentExecutionContext.ts | 5 +- .../twenty-sdk/src/cli/commands/dev/index.ts | 8 +- ...18n-extract.ts => translations-extract.ts} | 19 +- .../twenty-sdk/src/cli/operations/build.ts | 2 +- .../build/common/build-application.ts | 22 ++ .../i18n/extract-application-translations.ts | 75 ------ .../application-translations.spec.ts | 32 ++- .../collect-front-component-strings.spec.ts | 118 +++++++++ .../collect-front-component-strings.ts | 233 ++++++++++++++++++ .../collect-translatable-strings.ts | 24 +- .../compile-application-translations.ts | 22 +- .../{i18n => translations}/constants.ts | 0 .../extract-application-translations.ts | 123 +++++++++ .../generate-message-id.ts | 0 ...ad-front-component-translation-catalogs.ts | 58 +++++ .../sdk/front-component/components/Trans.tsx | 53 ++++ .../front-component-translations-key.ts | 4 + .../sdk/front-component/hooks/useLocale.ts | 13 + .../sdk/front-component/hooks/useTranslate.ts | 29 +++ .../src/sdk/front-component/index.ts | 11 + .../translations/__tests__/message.spec.ts | 60 +++++ .../__tests__/resolveTranslation.spec.ts | 63 +++++ .../front-component-translations.ts | 14 ++ .../message/context-separator.constant.ts | 2 + .../message/get-translation-catalog-key.ts | 9 + .../translations/message/index.ts | 8 + .../message/interpolate-message.ts | 16 ++ .../message/message-descriptor.type.ts | 4 + .../message/normalize-message-descriptor.ts | 6 + .../message/normalize-message-whitespace.ts | 3 + .../message/parse-translation-catalog-key.ts | 15 ++ .../translation-catalogs-by-locale.type.ts | 5 + .../message/translation-values.type.ts | 1 + .../sdk/front-component/translations/msg.ts | 5 + .../translations/resolveTranslation.ts | 23 ++ .../src/sdk/front-component/translations/t.ts | 19 ++ .../types/FrontComponentExecutionContext.ts | 4 +- .../src/constants/DocumentationPaths.ts | 2 + 44 files changed, 1149 insertions(+), 111 deletions(-) create mode 100644 packages/twenty-docs/developers/extend/apps/translations/overview.mdx rename packages/twenty-sdk/src/cli/commands/dev/{i18n-extract.ts => translations-extract.ts} (82%) delete mode 100644 packages/twenty-sdk/src/cli/utilities/i18n/extract-application-translations.ts rename packages/twenty-sdk/src/cli/utilities/{i18n => translations}/__tests__/application-translations.spec.ts (69%) create mode 100644 packages/twenty-sdk/src/cli/utilities/translations/__tests__/collect-front-component-strings.spec.ts create mode 100644 packages/twenty-sdk/src/cli/utilities/translations/collect-front-component-strings.ts rename packages/twenty-sdk/src/cli/utilities/{i18n => translations}/collect-translatable-strings.ts (60%) rename packages/twenty-sdk/src/cli/utilities/{i18n => translations}/compile-application-translations.ts (68%) rename packages/twenty-sdk/src/cli/utilities/{i18n => translations}/constants.ts (100%) create mode 100644 packages/twenty-sdk/src/cli/utilities/translations/extract-application-translations.ts rename packages/twenty-sdk/src/cli/utilities/{i18n => translations}/generate-message-id.ts (100%) create mode 100644 packages/twenty-sdk/src/cli/utilities/translations/load-front-component-translation-catalogs.ts create mode 100644 packages/twenty-sdk/src/sdk/front-component/components/Trans.tsx create mode 100644 packages/twenty-sdk/src/sdk/front-component/constants/front-component-translations-key.ts create mode 100644 packages/twenty-sdk/src/sdk/front-component/hooks/useLocale.ts create mode 100644 packages/twenty-sdk/src/sdk/front-component/hooks/useTranslate.ts create mode 100644 packages/twenty-sdk/src/sdk/front-component/translations/__tests__/message.spec.ts create mode 100644 packages/twenty-sdk/src/sdk/front-component/translations/__tests__/resolveTranslation.spec.ts create mode 100644 packages/twenty-sdk/src/sdk/front-component/translations/front-component-translations.ts create mode 100644 packages/twenty-sdk/src/sdk/front-component/translations/message/context-separator.constant.ts create mode 100644 packages/twenty-sdk/src/sdk/front-component/translations/message/get-translation-catalog-key.ts create mode 100644 packages/twenty-sdk/src/sdk/front-component/translations/message/index.ts create mode 100644 packages/twenty-sdk/src/sdk/front-component/translations/message/interpolate-message.ts create mode 100644 packages/twenty-sdk/src/sdk/front-component/translations/message/message-descriptor.type.ts create mode 100644 packages/twenty-sdk/src/sdk/front-component/translations/message/normalize-message-descriptor.ts create mode 100644 packages/twenty-sdk/src/sdk/front-component/translations/message/normalize-message-whitespace.ts create mode 100644 packages/twenty-sdk/src/sdk/front-component/translations/message/parse-translation-catalog-key.ts create mode 100644 packages/twenty-sdk/src/sdk/front-component/translations/message/translation-catalogs-by-locale.type.ts create mode 100644 packages/twenty-sdk/src/sdk/front-component/translations/message/translation-values.type.ts create mode 100644 packages/twenty-sdk/src/sdk/front-component/translations/msg.ts create mode 100644 packages/twenty-sdk/src/sdk/front-component/translations/resolveTranslation.ts create mode 100644 packages/twenty-sdk/src/sdk/front-component/translations/t.ts diff --git a/packages/twenty-docs/developers/extend/apps/translations/overview.mdx b/packages/twenty-docs/developers/extend/apps/translations/overview.mdx new file mode 100644 index 0000000000..0981690c14 --- /dev/null +++ b/packages/twenty-docs/developers/extend/apps/translations/overview.mdx @@ -0,0 +1,125 @@ +--- +title: Translations +description: Ship your app in multiple languages — translate manifest labels and UI strings through one locales catalog. +icon: "language" +--- + +Twenty apps are authored in **English**: the strings in your source and manifest +are the English source text, `en` is the source locale you translate *from*, and +any locale left untranslated falls back to it. + +Your app has two kinds of translatable text, and both flow through the same +`locales/` catalog: + +- **Manifest labels** — object and field names, view titles, menu items, and + other strings declared in your app's metadata. +- **Front-component strings** — the UI text your React front components render. + +You mark the translatable strings, extract them into per-locale catalogs, +translate those catalogs, and the build serves the right language for the +current user — no extra wiring. + +## Marking front-component strings + +Import the translation helpers from `twenty-sdk/front-component`: + +```tsx +import { Trans, t, msg, useTranslate } from 'twenty-sdk/front-component'; + +const STATUSES = [ + { id: 'draft', label: msg('Draft') }, + { id: 'sent', label: msg('Sent') }, +]; + +const Card = ({ count, name }: { count: number; name: string }) => { + const { t } = useTranslate(); + + return ( +
+ {/* Static text — reactive to the user's locale */} + Loading postcard… + + {/* Disambiguate identical sources with a context */} + Untitled + + {/* Interpolation: pass values explicitly */} +

{t('Hi {name}', { name })}

+

{t('Saved {count} cards', { count })}

+ + {/* Resolve a lazily-declared descriptor */} +
    {STATUSES.map((s) =>
  • {t(s.label)}
  • )}
+
+ ); +}; +``` + +### When to use which + +- **``** — static text in JSX. Use the `message` and `values` + props for interpolation (``); + interpolating directly in the children is not statically extractable. +- **`useTranslate().t`** — dynamic strings inside a component. Re-renders when the + user switches language. Prefer this inside render. +- **`t(...)`** (imported directly) — eager translation usable **anywhere**, + including event handlers, helpers, and module scope — not only inside render. +- **`msg(...)`** — a lazy descriptor for strings declared as data (constants, + config). Resolve it later with `t(descriptor)`. + +### Context + +Pass `context` to disambiguate identical source strings that translate +differently: + +```tsx +t({ message: 'Open', context: 'door' }); +t({ message: 'Open', context: 'window' }); +Untitled +``` + +## Extracting and translating + +Run the extract command from your app directory: + +```bash +twenty dev:translations-extract # collect strings into locales/en.json +twenty dev:translations-extract --locale fr-FR # also scaffold a target locale +``` + +Extraction collects both your manifest labels and the `t()`/`msg()`/`` +strings from your front-component source into `locales/.json`, keyed by +source string. Fill in the translations: + +```json +// locales/fr-FR.json +{ + "Loading postcard…": "Chargement de la carte…", + "Hi {name}": "Bonjour {name}", + "Saved {count} cards": "{count} cartes enregistrées" +} +``` + +Placeholders like `{name}` are substituted at runtime — keep them in the +translation. Any string left empty falls back to the source text. + +## How it runs + +`twenty dev:build` compiles the catalogs and serves the right language for the +current user: manifest labels are resolved server-side, and front-component +catalogs are baked into each component bundle. At runtime a component reads the +locale from its execution context (the host's current language) and resolves +each string against its catalog, falling back to the source when a translation +is missing. Switching language in the host re-renders `` and +`useTranslate().t` strings live. + +Because catalogs are compiled at build time, updating a translation means +re-running `twenty dev:build` (and redeploying), the same as any other change. + + +Translations are compiled by `twenty dev:build` (and `twenty apply`). The +continuous `twenty dev` watch shows source strings, so test localized output +with a one-off build. + + +`` text children may span multiple lines — whitespace is collapsed the +same way JSX collapses it, so `Welcome\n back` and the extracted +key both become `Welcome back`. diff --git a/packages/twenty-docs/docs.json b/packages/twenty-docs/docs.json index 729cd974ba..21351e52e6 100644 --- a/packages/twenty-docs/docs.json +++ b/packages/twenty-docs/docs.json @@ -434,6 +434,12 @@ "developers/extend/apps/operations/testing", "developers/extend/apps/operations/publishing" ] + }, + { + "group": "Translations", + "pages": [ + "developers/extend/apps/translations/overview" + ] } ] }, diff --git a/packages/twenty-docs/navigation/base-structure.json b/packages/twenty-docs/navigation/base-structure.json index b22261b39a..0da7267316 100644 --- a/packages/twenty-docs/navigation/base-structure.json +++ b/packages/twenty-docs/navigation/base-structure.json @@ -436,6 +436,13 @@ "developers/extend/apps/operations/testing", "developers/extend/apps/operations/publishing" ] + }, + { + "key": "appsTranslations", + "label": "Translations", + "pages": [ + "developers/extend/apps/translations/overview" + ] } ] }, diff --git a/packages/twenty-docs/navigation/navigation.template.json b/packages/twenty-docs/navigation/navigation.template.json index 9ac96e330f..99f5ef4df9 100644 --- a/packages/twenty-docs/navigation/navigation.template.json +++ b/packages/twenty-docs/navigation/navigation.template.json @@ -174,6 +174,9 @@ }, "appsOperations": { "label": "Operations" + }, + "appsTranslations": { + "label": "Translations" } } }, diff --git a/packages/twenty-front-component-renderer/src/types/FrontComponentExecutionContext.ts b/packages/twenty-front-component-renderer/src/types/FrontComponentExecutionContext.ts index 67771ad1e5..660956c839 100644 --- a/packages/twenty-front-component-renderer/src/types/FrontComponentExecutionContext.ts +++ b/packages/twenty-front-component-renderer/src/types/FrontComponentExecutionContext.ts @@ -1,3 +1,5 @@ +import { type AppLocale } from 'twenty-shared/translations'; + export type FrontComponentExecutionContext = { frontComponentId: string; userId: string | null; @@ -9,5 +11,5 @@ export type FrontComponentExecutionContext = { selectedRecordIds: string[]; /** Resolved color scheme of the host UI ('System' is already resolved) */ colorScheme: 'light' | 'dark'; - locale?: string; + locale?: AppLocale; }; 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 cc072aacdb..f0fceeb823 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 @@ -3,6 +3,7 @@ import { I18nProvider } from '@lingui/react'; import { act, renderHook } from '@testing-library/react'; import { getDefaultStore } from 'jotai'; import { AppPath, SidePanelPages } from 'twenty-shared/types'; +import { type AppLocale } from 'twenty-shared/translations'; import { MAIN_CONTEXT_STORE_INSTANCE_ID } from '@/context-store/constants/MainContextStoreInstanceId'; import { contextStoreRecordShowParentViewComponentState } from '@/context-store/states/contextStoreRecordShowParentViewComponentState'; @@ -180,7 +181,7 @@ describe('useFrontComponentExecutionContext', () => { recordId: 'record-456', selectedRecordIds: ['record-456'], colorScheme: 'light', - locale: i18n.locale, + locale: i18n.locale as AppLocale, }); }); @@ -196,7 +197,7 @@ describe('useFrontComponentExecutionContext', () => { recordId: null, selectedRecordIds: ['record-1', 'record-2', 'record-3'], colorScheme: 'light', - locale: i18n.locale, + locale: i18n.locale as AppLocale, }); }); 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 79dfddba1c..24151d2b6c 100644 --- a/packages/twenty-front/src/modules/front-components/hooks/useFrontComponentExecutionContext.ts +++ b/packages/twenty-front/src/modules/front-components/hooks/useFrontComponentExecutionContext.ts @@ -11,6 +11,7 @@ import { SidePanelPages, type EnqueueSnackbarParams, } from 'twenty-shared/types'; +import { type AppLocale } from 'twenty-shared/translations'; import { currentUserState } from '@/auth/states/currentUserState'; import { useCommandMenuConfirmationModal } from '@/command-menu-item/confirmation-modal/hooks/useCommandMenuConfirmationModal'; @@ -252,7 +253,9 @@ export const useFrontComponentExecutionContext = ({ recordId: selectedRecordIds?.length === 1 ? selectedRecordIds[0] : null, selectedRecordIds: selectedRecordIds ?? [], colorScheme, - locale: i18n.locale, + // i18n.locale is a Lingui string; the host is always configured with the + // APP_LOCALES set, so it is a valid AppLocale. + locale: i18n.locale as AppLocale, }; const unmountFrontComponent: FrontComponentHostCommunicationApi['unmountFrontComponent'] = diff --git a/packages/twenty-sdk/src/cli/commands/dev/index.ts b/packages/twenty-sdk/src/cli/commands/dev/index.ts index 4067b61863..1813640144 100644 --- a/packages/twenty-sdk/src/cli/commands/dev/index.ts +++ b/packages/twenty-sdk/src/cli/commands/dev/index.ts @@ -8,7 +8,7 @@ import { AppDevCommand } from './dev'; import { AppDevOnceCommand } from './dev-once'; import { registerDevFunctionCommands } from './function'; import { AppGenerateClientCommand } from './generate-client'; -import { AppI18nExtractCommand } from './i18n-extract'; +import { AppTranslationsExtractCommand } from './translations-extract'; import { AppTypecheckCommand } from './typecheck'; export const registerDevCommands = (program: Command): void => { @@ -18,7 +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 translationsExtractCommand = new AppTranslationsExtractCommand(); const devAction = async ( appPath: string | undefined, @@ -178,14 +178,14 @@ export const registerDevCommands = (program: Command): void => { }); program - .command('dev:i18n-extract [appPath]') + .command('dev:translations-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({ + await translationsExtractCommand.execute({ appPath: formatPath(appPath), locale: options.locale, }); diff --git a/packages/twenty-sdk/src/cli/commands/dev/i18n-extract.ts b/packages/twenty-sdk/src/cli/commands/dev/translations-extract.ts similarity index 82% rename from packages/twenty-sdk/src/cli/commands/dev/i18n-extract.ts rename to packages/twenty-sdk/src/cli/commands/dev/translations-extract.ts index 4ce066e7d7..3bf5e9f1d7 100644 --- a/packages/twenty-sdk/src/cli/commands/dev/i18n-extract.ts +++ b/packages/twenty-sdk/src/cli/commands/dev/translations-extract.ts @@ -1,14 +1,17 @@ +import path from 'path'; + +import chalk from 'chalk'; + 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 { extractApplicationTranslations } from '@/cli/utilities/translations/extract-application-translations'; import { APP_LOCALES, SOURCE_LOCALE, type AppLocale, } from 'twenty-shared/translations'; -export type AppI18nExtractOptions = { +type AppTranslationsExtractOptions = { appPath?: string; locale?: string; }; @@ -16,8 +19,8 @@ export type AppI18nExtractOptions = { const isSupportedLocale = (locale: string): locale is AppLocale => Object.prototype.hasOwnProperty.call(APP_LOCALES, locale); -export class AppI18nExtractCommand { - async execute(options: AppI18nExtractOptions): Promise { +export class AppTranslationsExtractCommand { + async execute(options: AppTranslationsExtractOptions): Promise { const appPath = options.appPath ?? CURRENT_EXECUTION_DIRECTORY; let scaffoldLocale: AppLocale | undefined; @@ -55,10 +58,16 @@ export class AppI18nExtractCommand { console.warn(chalk.yellow(manifestResult.warnings.join('\n'))); } + const frontComponentSourcePaths = + manifestResult.filePaths.frontComponents.map((relativePath) => + path.join(appPath, relativePath), + ); + const { sourceCount, updatedLocaleFiles } = await extractApplicationTranslations({ appPath, manifest: manifestResult.manifest, + frontComponentSourcePaths, scaffoldLocale, }); diff --git a/packages/twenty-sdk/src/cli/operations/build.ts b/packages/twenty-sdk/src/cli/operations/build.ts index 3691d83660..cbab2227b4 100644 --- a/packages/twenty-sdk/src/cli/operations/build.ts +++ b/packages/twenty-sdk/src/cli/operations/build.ts @@ -7,7 +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 { compileApplicationTranslations } from '@/cli/utilities/translations/compile-application-translations'; import { runSafe } from '@/cli/utilities/run-safe'; import { APP_ERROR_CODES, type CommandResult } from '@/cli/types'; diff --git a/packages/twenty-sdk/src/cli/utilities/build/common/build-application.ts b/packages/twenty-sdk/src/cli/utilities/build/common/build-application.ts index 4ae37d6b83..e30b972e82 100644 --- a/packages/twenty-sdk/src/cli/utilities/build/common/build-application.ts +++ b/packages/twenty-sdk/src/cli/utilities/build/common/build-application.ts @@ -16,6 +16,7 @@ import { getFrontComponentBuildPlugins } from '@/cli/utilities/build/common/fron import { createStubTwentySdkDefinePlugin } from '@/cli/utilities/build/common/plugins/stub-twenty-sdk-define.plugin'; import { type OnFileBuiltCallback } from '@/cli/utilities/build/common/restartable-watcher-interface'; import { type EntityFilePaths } from '@/cli/utilities/build/manifest/manifest-extract-config'; +import { loadFrontComponentTranslationCatalogs } from '@/cli/utilities/translations/load-front-component-translation-catalogs'; import { copy, emptyDir, @@ -23,6 +24,7 @@ import { pathExists, pathExistsSync, } from '@/cli/utilities/file/fs-utils'; +import { FRONT_COMPONENT_TRANSLATIONS_KEY } from '@/sdk/front-component/constants/front-component-translations-key'; export type AppBuildOptions = { appPath: string; @@ -65,6 +67,23 @@ export const buildApplication = async ( const { logicFunctions, frontComponents } = options.filePaths; + // Bake the app's compiled translation catalogs into every front-component + // bundle so the runtime t()/ resolves them in the sandboxed worker + // without a server round-trip. Omitted entirely when the app has no + // translations, leaving the runtime to fall back to source strings. + const frontComponentTranslationCatalogs = + await loadFrontComponentTranslationCatalogs(options.appPath); + + const frontComponentTranslationsBanner = Object.keys( + frontComponentTranslationCatalogs, + ).length + ? { + js: `globalThis[${JSON.stringify(FRONT_COMPONENT_TRANSLATIONS_KEY)}]=${JSON.stringify( + frontComponentTranslationCatalogs, + )};`, + } + : undefined; + await esbuildOneShotBuild({ appPath: options.appPath, sourcePaths: logicFunctions, @@ -99,6 +118,9 @@ export const buildApplication = async ( sourcemap: true, metafile: true, logLevel: 'silent', + ...(frontComponentTranslationsBanner !== undefined + ? { banner: frontComponentTranslationsBanner } + : {}), plugins: [ ...getFrontComponentBuildPlugins(), createStubTwentySdkDefinePlugin(), 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 deleted file mode 100644 index 66a0091d9e..0000000000 --- a/packages/twenty-sdk/src/cli/utilities/i18n/extract-application-translations.ts +++ /dev/null @@ -1,75 +0,0 @@ -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/__tests__/application-translations.spec.ts b/packages/twenty-sdk/src/cli/utilities/translations/__tests__/application-translations.spec.ts similarity index 69% rename from packages/twenty-sdk/src/cli/utilities/i18n/__tests__/application-translations.spec.ts rename to packages/twenty-sdk/src/cli/utilities/translations/__tests__/application-translations.spec.ts index 5cca097574..9c609e38b4 100644 --- a/packages/twenty-sdk/src/cli/utilities/i18n/__tests__/application-translations.spec.ts +++ b/packages/twenty-sdk/src/cli/utilities/translations/__tests__/application-translations.spec.ts @@ -4,9 +4,10 @@ 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 { getTranslationCatalogKey } from '@/sdk/front-component/translations/message'; +import { collectTranslatableStrings } from '@/cli/utilities/translations/collect-translatable-strings'; +import { compileApplicationTranslations } from '@/cli/utilities/translations/compile-application-translations'; +import { generateMessageId } from '@/cli/utilities/translations/generate-message-id'; import { type Manifest } from 'twenty-shared/application'; const buildManifest = (overrides: Record): Manifest => @@ -64,7 +65,7 @@ describe('collectTranslatableStrings', () => { 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 appPath = await mkdtemp(join(tmpdir(), 'twenty-translations-')); const localesDir = join(appPath, 'locales'); await mkdir(localesDir, { recursive: true }); @@ -84,8 +85,29 @@ describe('compileApplicationTranslations', () => { }); }); + it('hashes a context-qualified key with its context so it matches the server lookup', async () => { + const appPath = await mkdtemp( + join(tmpdir(), 'twenty-translations-context-'), + ); + const localesDir = join(appPath, 'locales'); + + await mkdir(localesDir, { recursive: true }); + await writeFile( + join(localesDir, 'fr-FR.json'), + JSON.stringify({ + [getTranslationCatalogKey('Open', 'door')]: 'Ouvrir', + }), + ); + + const result = await compileApplicationTranslations(appPath); + + expect(result).toEqual({ + 'fr-FR': { [generateMessageId('Open', 'door')]: 'Ouvrir' }, + }); + }); + it('returns undefined when there is no locales directory', async () => { - const appPath = await mkdtemp(join(tmpdir(), 'twenty-i18n-empty-')); + const appPath = await mkdtemp(join(tmpdir(), 'twenty-translations-empty-')); expect(await compileApplicationTranslations(appPath)).toBeUndefined(); }); diff --git a/packages/twenty-sdk/src/cli/utilities/translations/__tests__/collect-front-component-strings.spec.ts b/packages/twenty-sdk/src/cli/utilities/translations/__tests__/collect-front-component-strings.spec.ts new file mode 100644 index 0000000000..0f9df59f4e --- /dev/null +++ b/packages/twenty-sdk/src/cli/utilities/translations/__tests__/collect-front-component-strings.spec.ts @@ -0,0 +1,118 @@ +import { mkdtemp, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { describe, expect, it } from 'vitest'; + +import { collectFrontComponentStrings } from '@/cli/utilities/translations/collect-front-component-strings'; + +const writeFrontComponent = async (source: string): Promise => { + const dir = await mkdtemp(join(tmpdir(), 'twenty-fc-translations-')); + const filePath = join(dir, 'my.front-component.tsx'); + + await writeFile(filePath, source); + + return filePath; +}; + +describe('collectFrontComponentStrings', () => { + it('returns nothing when there are no source files', async () => { + expect(await collectFrontComponentStrings([])).toEqual([]); + }); + + it('extracts t(), msg() and static strings with context', async () => { + const filePath = await writeFrontComponent(` + import { t, msg, Trans, useTranslate } from 'twenty-sdk/front-component'; + + const STATUS = msg('Draft'); + + const Component = () => { + const { t: translate } = useTranslate(); + + const label = t('No content yet'); + const verb = t({ message: 'Open', context: 'door' }); + + return ( +
+ Welcome back + Untitled + + {translate(STATUS)} +
+ ); + }; + + export default defineFrontComponent({ component: Component }); + `); + + const result = await collectFrontComponentStrings([filePath]); + + expect(result).toEqual( + expect.arrayContaining([ + { message: 'Draft' }, + { message: 'No content yet' }, + { message: 'Open', context: 'door' }, + { message: 'Welcome back' }, + { message: 'Untitled', context: 'card' }, + { message: 'Hi {name}' }, + ]), + ); + expect(result).toHaveLength(6); + }); + + it('collapses whitespace in multi-line static text', async () => { + const filePath = await writeFrontComponent(` + import { Trans } from 'twenty-sdk/front-component'; + + const Component = () => ( +

+ + Welcome + back + +

+ ); + + export default defineFrontComponent({ component: Component }); + `); + + expect(await collectFrontComponentStrings([filePath])).toEqual([ + { message: 'Welcome back' }, + ]); + }); + + it('skips dynamic arguments and interpolated children that cannot be statically extracted', async () => { + const filePath = await writeFrontComponent(` + import { t, Trans } from 'twenty-sdk/front-component'; + + const Component = ({ name }: { name: string }) => { + const dynamic = t(name); + + return ( +
+ Hello {name} +
+ ); + }; + + export default defineFrontComponent({ component: Component }); + `); + + expect(await collectFrontComponentStrings([filePath])).toEqual([]); + }); + + it('dedupes identical message/context pairs across files', async () => { + const first = await writeFrontComponent(` + import { t } from 'twenty-sdk/front-component'; + export const a = () => t('Save'); + `); + const second = await writeFrontComponent(` + import { t } from 'twenty-sdk/front-component'; + export const b = () => t('Save'); + `); + + expect(await collectFrontComponentStrings([first, second])).toEqual([ + { message: 'Save' }, + ]); + }); +}); diff --git a/packages/twenty-sdk/src/cli/utilities/translations/collect-front-component-strings.ts b/packages/twenty-sdk/src/cli/utilities/translations/collect-front-component-strings.ts new file mode 100644 index 0000000000..5b502823e7 --- /dev/null +++ b/packages/twenty-sdk/src/cli/utilities/translations/collect-front-component-strings.ts @@ -0,0 +1,233 @@ +import { readFile } from 'node:fs/promises'; + +import { + Node, + Project, + type JsxElement, + type JsxOpeningElement, + type JsxSelfClosingElement, +} from 'ts-morph'; + +import { + getTranslationCatalogKey, + normalizeMessageWhitespace, + type MessageDescriptor, +} from '@/sdk/front-component/translations/message'; + +const TRANSLATION_FUNCTION_NAMES = new Set(['t', 'msg']); +const TRANS_COMPONENT_NAME = 'Trans'; + +const getStringLiteralValue = (node: Node | undefined): string | undefined => { + if (node === undefined) { + return undefined; + } + + if ( + Node.isStringLiteral(node) || + Node.isNoSubstitutionTemplateLiteral(node) + ) { + return node.getLiteralText(); + } + + return undefined; +}; + +// Only static string literals are extractable; dynamic arguments are skipped. +const extractFromCallArgument = ( + argument: Node | undefined, +): MessageDescriptor | undefined => { + if (argument === undefined) { + return undefined; + } + + const literalMessage = getStringLiteralValue(argument); + + if (literalMessage !== undefined) { + return { message: literalMessage }; + } + + if (!Node.isObjectLiteralExpression(argument)) { + return undefined; + } + + const messageProperty = argument.getProperty('message'); + + if ( + messageProperty === undefined || + !Node.isPropertyAssignment(messageProperty) + ) { + return undefined; + } + + const message = getStringLiteralValue(messageProperty.getInitializer()); + + if (message === undefined) { + return undefined; + } + + const contextProperty = argument.getProperty('context'); + const context = + contextProperty !== undefined && Node.isPropertyAssignment(contextProperty) + ? getStringLiteralValue(contextProperty.getInitializer()) + : undefined; + + return context !== undefined ? { message, context } : { message }; +}; + +const getJsxAttributeStringValue = ( + element: JsxOpeningElement | JsxSelfClosingElement, + name: string, +): string | undefined => { + const attribute = element.getAttribute(name); + + if (attribute === undefined || !Node.isJsxAttribute(attribute)) { + return undefined; + } + + const initializer = attribute.getInitializer(); + + if (initializer === undefined) { + return undefined; + } + + if (Node.isStringLiteral(initializer)) { + return initializer.getLiteralText(); + } + + if (Node.isJsxExpression(initializer)) { + return getStringLiteralValue(initializer.getExpression()); + } + + return undefined; +}; + +const getTransChildrenText = (element: JsxElement): string | undefined => { + const children = element.getJsxChildren(); + + const hasDynamicChild = children.some( + (child) => + Node.isJsxExpression(child) || + Node.isJsxElement(child) || + Node.isJsxSelfClosingElement(child), + ); + + if (hasDynamicChild) { + return undefined; + } + + const text = normalizeMessageWhitespace( + children + .filter((child) => Node.isJsxText(child)) + .map((child) => child.getText()) + .join(''), + ); + + return text.length > 0 ? text : undefined; +}; + +const dedupeByCatalogKey = ( + descriptors: MessageDescriptor[], +): MessageDescriptor[] => { + const descriptorByKey = new Map(); + + for (const descriptor of descriptors) { + descriptorByKey.set( + getTranslationCatalogKey(descriptor.message, descriptor.context), + descriptor, + ); + } + + return [...descriptorByKey.values()]; +}; + +export const collectFrontComponentStrings = async ( + sourceFilePaths: string[], +): Promise => { + if (sourceFilePaths.length === 0) { + return []; + } + + const project = new Project({ + useInMemoryFileSystem: true, + skipFileDependencyResolution: true, + }); + + const descriptors: MessageDescriptor[] = []; + + for (let index = 0; index < sourceFilePaths.length; index++) { + let content: string; + + try { + content = await readFile(sourceFilePaths[index], 'utf8'); + } catch { + continue; + } + + const sourceFile = project.createSourceFile( + `front-component-${index}.tsx`, + content, + { overwrite: true }, + ); + + sourceFile.forEachDescendant((node) => { + if (Node.isCallExpression(node)) { + const expression = node.getExpression(); + + if ( + Node.isIdentifier(expression) && + TRANSLATION_FUNCTION_NAMES.has(expression.getText()) + ) { + const descriptor = extractFromCallArgument(node.getArguments()[0]); + + if (descriptor !== undefined) { + descriptors.push(descriptor); + } + } + + return; + } + + if (Node.isJsxSelfClosingElement(node)) { + if (node.getTagNameNode().getText() !== TRANS_COMPONENT_NAME) { + return; + } + + const message = getJsxAttributeStringValue(node, 'message'); + + if (message !== undefined && message.length > 0) { + const context = getJsxAttributeStringValue(node, 'context'); + + descriptors.push( + context !== undefined ? { message, context } : { message }, + ); + } + + return; + } + + if (Node.isJsxElement(node)) { + const openingElement = node.getOpeningElement(); + + if ( + openingElement.getTagNameNode().getText() !== TRANS_COMPONENT_NAME + ) { + return; + } + + const message = + getJsxAttributeStringValue(openingElement, 'message') ?? + getTransChildrenText(node); + + if (message !== undefined && message.length > 0) { + const context = getJsxAttributeStringValue(openingElement, 'context'); + + descriptors.push( + context !== undefined ? { message, context } : { message }, + ); + } + } + }); + } + + return dedupeByCatalogKey(descriptors); +}; diff --git a/packages/twenty-sdk/src/cli/utilities/i18n/collect-translatable-strings.ts b/packages/twenty-sdk/src/cli/utilities/translations/collect-translatable-strings.ts similarity index 60% rename from packages/twenty-sdk/src/cli/utilities/i18n/collect-translatable-strings.ts rename to packages/twenty-sdk/src/cli/utilities/translations/collect-translatable-strings.ts index d5a49608b4..8476cfdad4 100644 --- a/packages/twenty-sdk/src/cli/utilities/i18n/collect-translatable-strings.ts +++ b/packages/twenty-sdk/src/cli/utilities/translations/collect-translatable-strings.ts @@ -6,12 +6,18 @@ const TRANSLATABLE_KEYS_BY_MANIFEST_KEY: Record = { views: ['name'], pageLayoutTabs: ['title'], commandMenuItems: ['label', 'shortLabel'], - navigationMenuItems: ['label'], + navigationMenuItems: ['name'], }; export const collectTranslatableStrings = (manifest: Manifest): string[] => { const strings = new Set(); + const addString = (value: unknown) => { + if (typeof value === 'string' && value.length > 0) { + strings.add(value); + } + }; + for (const [manifestKey, fieldKeys] of Object.entries( TRANSLATABLE_KEYS_BY_MANIFEST_KEY, )) { @@ -29,11 +35,19 @@ export const collectTranslatableStrings = (manifest: Manifest): string[] => { } for (const fieldKey of fieldKeys) { - const value = (entity as Record)[fieldKey]; + addString((entity as Record)[fieldKey]); + } + } + } - if (typeof value === 'string' && value.length > 0) { - strings.add(value); - } + // Tab and widget titles live nested under pageLayouts[].tabs[], not in the + // flat pageLayoutTabs array, so walk the tree to reach them. + for (const pageLayout of manifest.pageLayouts ?? []) { + for (const tab of pageLayout.tabs ?? []) { + addString(tab.title); + + for (const widget of tab.widgets ?? []) { + addString(widget.title); } } } diff --git a/packages/twenty-sdk/src/cli/utilities/i18n/compile-application-translations.ts b/packages/twenty-sdk/src/cli/utilities/translations/compile-application-translations.ts similarity index 68% rename from packages/twenty-sdk/src/cli/utilities/i18n/compile-application-translations.ts rename to packages/twenty-sdk/src/cli/utilities/translations/compile-application-translations.ts index 5f26b8399d..52b7f144a1 100644 --- a/packages/twenty-sdk/src/cli/utilities/i18n/compile-application-translations.ts +++ b/packages/twenty-sdk/src/cli/utilities/translations/compile-application-translations.ts @@ -1,9 +1,10 @@ import { readdir } from 'node:fs/promises'; import path from 'path'; +import { parseTranslationCatalogKey } from '@/sdk/front-component/translations/message'; 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 { LOCALES_DIR } from '@/cli/utilities/translations/constants'; +import { generateMessageId } from '@/cli/utilities/translations/generate-message-id'; import { type TranslationsManifest } from 'twenty-shared/application'; import { APP_LOCALES, @@ -49,25 +50,24 @@ export const compileApplicationTranslations = async ( )) ?? {}; 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(); + const keyByMessageId = new Map(); - for (const [source, translation] of Object.entries(sourceToTranslation)) { + for (const [key, translation] of Object.entries(sourceToTranslation)) { if (typeof translation !== 'string' || translation.length === 0) { continue; } - const messageId = generateMessageId(source); - const collidingSource = sourceByMessageId.get(messageId); + const { message, context } = parseTranslationCatalogKey(key); + const messageId = generateMessageId(message, context); + const collidingKey = keyByMessageId.get(messageId); - if (collidingSource !== undefined && collidingSource !== source) { + if (collidingKey !== undefined && collidingKey !== key) { console.warn( - `Message id collision in "${localeFile}": "${source}" and "${collidingSource}" share id "${messageId}". Keeping "${source}".`, + `Message id collision in "${localeFile}": "${key}" and "${collidingKey}" share id "${messageId}". Keeping "${key}".`, ); } - sourceByMessageId.set(messageId, source); + keyByMessageId.set(messageId, key); compiled[messageId] = translation; } diff --git a/packages/twenty-sdk/src/cli/utilities/i18n/constants.ts b/packages/twenty-sdk/src/cli/utilities/translations/constants.ts similarity index 100% rename from packages/twenty-sdk/src/cli/utilities/i18n/constants.ts rename to packages/twenty-sdk/src/cli/utilities/translations/constants.ts diff --git a/packages/twenty-sdk/src/cli/utilities/translations/extract-application-translations.ts b/packages/twenty-sdk/src/cli/utilities/translations/extract-application-translations.ts new file mode 100644 index 0000000000..09de61d1ca --- /dev/null +++ b/packages/twenty-sdk/src/cli/utilities/translations/extract-application-translations.ts @@ -0,0 +1,123 @@ +import { readdir } from 'node:fs/promises'; +import path from 'path'; + +import { type Manifest } from 'twenty-shared/application'; +import { SOURCE_LOCALE, type AppLocale } from 'twenty-shared/translations'; +import { isDefined } from 'twenty-shared/utils'; + +import { + getTranslationCatalogKey, + type MessageDescriptor, +} from '@/sdk/front-component/translations/message'; +import { + ensureDir, + pathExists, + readJson, + writeJson, +} from '@/cli/utilities/file/fs-utils'; +import { collectFrontComponentStrings } from '@/cli/utilities/translations/collect-front-component-strings'; +import { collectTranslatableStrings } from '@/cli/utilities/translations/collect-translatable-strings'; +import { LOCALES_DIR } from '@/cli/utilities/translations/constants'; + +type ExtractApplicationTranslationsResult = { + sourceCount: number; + updatedLocaleFiles: string[]; +}; + +const collectSourceEntries = async ({ + manifest, + frontComponentSourcePaths, +}: { + manifest: Manifest; + frontComponentSourcePaths: string[]; +}): Promise> => { + const manifestDescriptors: MessageDescriptor[] = collectTranslatableStrings( + manifest, + ).map((message) => ({ message })); + + const frontComponentDescriptors = await collectFrontComponentStrings( + frontComponentSourcePaths, + ); + + const descriptorByKey = new Map(); + + for (const descriptor of [ + ...manifestDescriptors, + ...frontComponentDescriptors, + ]) { + descriptorByKey.set( + getTranslationCatalogKey(descriptor.message, descriptor.context), + descriptor, + ); + } + + return descriptorByKey; +}; + +export const extractApplicationTranslations = async ({ + appPath, + manifest, + frontComponentSourcePaths = [], + scaffoldLocale, +}: { + appPath: string; + manifest: Manifest; + frontComponentSourcePaths?: string[]; + scaffoldLocale?: AppLocale; +}): Promise => { + const descriptorByKey = await collectSourceEntries({ + manifest, + frontComponentSourcePaths, + }); + + const sortedKeys = [...descriptorByKey.keys()].sort(); + const localesDir = path.join(appPath, LOCALES_DIR); + + await ensureDir(localesDir); + + const sourceCatalog: Record = {}; + + for (const key of sortedKeys) { + const descriptor = descriptorByKey.get(key); + + if (isDefined(descriptor)) { + sourceCatalog[key] = descriptor.message; + } + } + + await writeJson( + path.join(localesDir, `${SOURCE_LOCALE}.json`), + sourceCatalog, + ); + + 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 key of sortedKeys) { + const existingValue = existing[key]; + + merged[key] = typeof existingValue === 'string' ? existingValue : ''; + } + + await writeJson(filePath, merged); + } + + return { + sourceCount: sortedKeys.length, + updatedLocaleFiles: existingLocaleFiles, + }; +}; diff --git a/packages/twenty-sdk/src/cli/utilities/i18n/generate-message-id.ts b/packages/twenty-sdk/src/cli/utilities/translations/generate-message-id.ts similarity index 100% rename from packages/twenty-sdk/src/cli/utilities/i18n/generate-message-id.ts rename to packages/twenty-sdk/src/cli/utilities/translations/generate-message-id.ts diff --git a/packages/twenty-sdk/src/cli/utilities/translations/load-front-component-translation-catalogs.ts b/packages/twenty-sdk/src/cli/utilities/translations/load-front-component-translation-catalogs.ts new file mode 100644 index 0000000000..3fdefb8ca2 --- /dev/null +++ b/packages/twenty-sdk/src/cli/utilities/translations/load-front-component-translation-catalogs.ts @@ -0,0 +1,58 @@ +import { readdir } from 'node:fs/promises'; +import path from 'path'; + +import { + APP_LOCALES, + SOURCE_LOCALE, + type AppLocale, +} from 'twenty-shared/translations'; + +import { type TranslationCatalogsByLocale } from '@/sdk/front-component/translations/message'; +import { pathExists, readJson } from '@/cli/utilities/file/fs-utils'; +import { LOCALES_DIR } from '@/cli/utilities/translations/constants'; + +const isSupportedLocale = (locale: string): locale is AppLocale => + Object.prototype.hasOwnProperty.call(APP_LOCALES, locale); + +export const loadFrontComponentTranslationCatalogs = async ( + appPath: string, +): Promise => { + const localesDir = path.join(appPath, LOCALES_DIR); + + if (!(await pathExists(localesDir))) { + return {}; + } + + const localeFiles = (await readdir(localesDir)).filter((entry) => + entry.endsWith('.json'), + ); + + const catalogs: TranslationCatalogsByLocale = {}; + + for (const localeFile of localeFiles) { + const locale = path.basename(localeFile, '.json'); + + if (locale === SOURCE_LOCALE || !isSupportedLocale(locale)) { + continue; + } + + const catalog = + (await readJson>( + path.join(localesDir, localeFile), + )) ?? {}; + + const nonEmptyEntries: Record = {}; + + for (const [key, value] of Object.entries(catalog)) { + if (typeof value === 'string' && value.length > 0) { + nonEmptyEntries[key] = value; + } + } + + if (Object.keys(nonEmptyEntries).length > 0) { + catalogs[locale] = nonEmptyEntries; + } + } + + return catalogs; +}; diff --git a/packages/twenty-sdk/src/sdk/front-component/components/Trans.tsx b/packages/twenty-sdk/src/sdk/front-component/components/Trans.tsx new file mode 100644 index 0000000000..c894ab4fab --- /dev/null +++ b/packages/twenty-sdk/src/sdk/front-component/components/Trans.tsx @@ -0,0 +1,53 @@ +import { type ReactNode } from 'react'; + +import { useLocale } from '../hooks/useLocale'; +import { + normalizeMessageWhitespace, + type TranslationValues, +} from '../translations/message'; +import { resolveTranslation } from '../translations/resolveTranslation'; + +export type TransProps = { + children?: ReactNode; + // Use this (with `values`) for interpolation; inlined child expressions + // cannot be statically extracted. + message?: string; + context?: string; + values?: TranslationValues; +}; + +const getSourceMessage = ( + message: string | undefined, + children: ReactNode, +): string | undefined => { + if (message !== undefined) { + return message; + } + + if (typeof children === 'string') { + return normalizeMessageWhitespace(children); + } + + return undefined; +}; + +export const Trans = ({ + children, + message, + context, + values, +}: TransProps): ReactNode => { + const locale = useLocale(); + + const sourceMessage = getSourceMessage(message, children); + + if (sourceMessage === undefined) { + return children; + } + + return resolveTranslation( + { message: sourceMessage, context }, + values, + locale, + ); +}; diff --git a/packages/twenty-sdk/src/sdk/front-component/constants/front-component-translations-key.ts b/packages/twenty-sdk/src/sdk/front-component/constants/front-component-translations-key.ts new file mode 100644 index 0000000000..6cef3bf8e5 --- /dev/null +++ b/packages/twenty-sdk/src/sdk/front-component/constants/front-component-translations-key.ts @@ -0,0 +1,4 @@ +// globalThis key under which the build bakes the app's compiled translation +// catalogs so the runtime resolver can read them inside the sandboxed worker. +export const FRONT_COMPONENT_TRANSLATIONS_KEY = + '__twentySdkFrontComponentTranslations__'; diff --git a/packages/twenty-sdk/src/sdk/front-component/hooks/useLocale.ts b/packages/twenty-sdk/src/sdk/front-component/hooks/useLocale.ts new file mode 100644 index 0000000000..af3c51ab2b --- /dev/null +++ b/packages/twenty-sdk/src/sdk/front-component/hooks/useLocale.ts @@ -0,0 +1,13 @@ +import { SOURCE_LOCALE, type AppLocale } from 'twenty-shared/translations'; + +import { type FrontComponentExecutionContext } from '../types/FrontComponentExecutionContext'; +import { useFrontComponentExecutionContext } from './useFrontComponentExecutionContext'; + +const selectLocale = (context: FrontComponentExecutionContext): AppLocale => + context.locale ?? SOURCE_LOCALE; + +// Reactive locale of the host UI. Re-renders the component when the user +// switches language, mirroring useColorScheme. +export const useLocale = (): AppLocale => { + return useFrontComponentExecutionContext(selectLocale); +}; diff --git a/packages/twenty-sdk/src/sdk/front-component/hooks/useTranslate.ts b/packages/twenty-sdk/src/sdk/front-component/hooks/useTranslate.ts new file mode 100644 index 0000000000..d209e407c0 --- /dev/null +++ b/packages/twenty-sdk/src/sdk/front-component/hooks/useTranslate.ts @@ -0,0 +1,29 @@ +import { useCallback } from 'react'; +import { type AppLocale } from 'twenty-shared/translations'; + +import { + type MessageDescriptor, + type TranslationValues, +} from '../translations/message'; +import { resolveTranslation } from '../translations/resolveTranslation'; +import { useLocale } from './useLocale'; + +export type UseTranslateResult = { + locale: AppLocale; + t: ( + descriptor: string | MessageDescriptor, + values?: TranslationValues, + ) => string; +}; + +export const useTranslate = (): UseTranslateResult => { + const locale = useLocale(); + + const t = useCallback( + (descriptor: string | MessageDescriptor, values?: TranslationValues) => + resolveTranslation(descriptor, values, locale), + [locale], + ); + + return { locale, t }; +}; diff --git a/packages/twenty-sdk/src/sdk/front-component/index.ts b/packages/twenty-sdk/src/sdk/front-component/index.ts index 3e708f1887..7ba51702a0 100644 --- a/packages/twenty-sdk/src/sdk/front-component/index.ts +++ b/packages/twenty-sdk/src/sdk/front-component/index.ts @@ -1,3 +1,5 @@ +export { Trans } from './components/Trans'; +export type { TransProps } from './components/Trans'; export { closeSidePanel } from './functions/closeSidePanel'; export { copyToClipboard } from './functions/copyToClipboard'; export { getApplicationVariable } from './functions/getApplicationVariable'; @@ -10,9 +12,18 @@ export { updateProgress } from './functions/updateProgress'; export { useColorScheme } from './hooks/useColorScheme'; export { useFrontComponentExecutionContext } from './hooks/useFrontComponentExecutionContext'; export { useFrontComponentId } from './hooks/useFrontComponentId'; +export { useTranslate } from './hooks/useTranslate'; +export type { UseTranslateResult } from './hooks/useTranslate'; +export { useLocale } from './hooks/useLocale'; export { useRecordId } from './hooks/useRecordId'; export { useSelectedRecordIds } from './hooks/useSelectedRecordIds'; export { useUserId } from './hooks/useUserId'; +export { msg } from './translations/msg'; +export { t } from './translations/t'; +export type { + MessageDescriptor, + TranslationValues, +} from './translations/message'; export type { FrontComponentExecutionContext } from './types/FrontComponentExecutionContext'; export { getFrontComponentCommandErrorDedupeKey } from './utils/getFrontComponentCommandErrorDedupeKey'; export type { diff --git a/packages/twenty-sdk/src/sdk/front-component/translations/__tests__/message.spec.ts b/packages/twenty-sdk/src/sdk/front-component/translations/__tests__/message.spec.ts new file mode 100644 index 0000000000..fd6f35dd1c --- /dev/null +++ b/packages/twenty-sdk/src/sdk/front-component/translations/__tests__/message.spec.ts @@ -0,0 +1,60 @@ +import { describe, expect, it } from 'vitest'; + +import { + getTranslationCatalogKey, + interpolateMessage, + normalizeMessageDescriptor, + parseTranslationCatalogKey, +} from '@/sdk/front-component/translations/message'; + +describe('normalizeMessageDescriptor', () => { + it('wraps a bare string into a descriptor', () => { + expect(normalizeMessageDescriptor('Save')).toEqual({ message: 'Save' }); + }); + + it('passes a descriptor through unchanged', () => { + expect( + normalizeMessageDescriptor({ message: 'Open', context: 'door' }), + ).toEqual({ message: 'Open', context: 'door' }); + }); +}); + +describe('translation catalog key', () => { + it('uses the raw message when there is no context', () => { + expect(getTranslationCatalogKey('Save')).toBe('Save'); + expect(getTranslationCatalogKey('Save', '')).toBe('Save'); + }); + + it('disambiguates identical messages with different contexts', () => { + expect(getTranslationCatalogKey('Open', 'door')).not.toBe( + getTranslationCatalogKey('Open', 'window'), + ); + }); + + it('round-trips through parseTranslationCatalogKey', () => { + expect( + parseTranslationCatalogKey(getTranslationCatalogKey('Save')), + ).toEqual({ message: 'Save' }); + expect( + parseTranslationCatalogKey(getTranslationCatalogKey('Open', 'door')), + ).toEqual({ message: 'Open', context: 'door' }); + }); +}); + +describe('interpolateMessage', () => { + it('returns the template unchanged when no values are given', () => { + expect(interpolateMessage('Hello {name}')).toBe('Hello {name}'); + }); + + it('substitutes named placeholders', () => { + expect(interpolateMessage('Saved {count} cards', { count: 3 })).toBe( + 'Saved 3 cards', + ); + }); + + it('leaves unknown placeholders intact', () => { + expect(interpolateMessage('Hi {name} from {city}', { name: 'Ada' })).toBe( + 'Hi Ada from {city}', + ); + }); +}); diff --git a/packages/twenty-sdk/src/sdk/front-component/translations/__tests__/resolveTranslation.spec.ts b/packages/twenty-sdk/src/sdk/front-component/translations/__tests__/resolveTranslation.spec.ts new file mode 100644 index 0000000000..bc3a3ec902 --- /dev/null +++ b/packages/twenty-sdk/src/sdk/front-component/translations/__tests__/resolveTranslation.spec.ts @@ -0,0 +1,63 @@ +import { afterEach, describe, expect, it } from 'vitest'; + +import { setFrontComponentTranslations } from '@/sdk/front-component/translations/front-component-translations'; +import { getTranslationCatalogKey } from '@/sdk/front-component/translations/message'; +import { resolveTranslation } from '@/sdk/front-component/translations/resolveTranslation'; + +afterEach(() => { + setFrontComponentTranslations({}); +}); + +describe('resolveTranslation', () => { + it('returns the translation for the active locale', () => { + setFrontComponentTranslations({ 'fr-FR': { Save: 'Enregistrer' } }); + + expect(resolveTranslation('Save', undefined, 'fr-FR')).toBe('Enregistrer'); + }); + + it('falls back to the source message when the locale is missing', () => { + setFrontComponentTranslations({ 'fr-FR': { Save: 'Enregistrer' } }); + + expect(resolveTranslation('Save', undefined, 'de-DE')).toBe('Save'); + }); + + it('falls back to the source message when the key is untranslated', () => { + setFrontComponentTranslations({ 'fr-FR': {} }); + + expect(resolveTranslation('Cancel', undefined, 'fr-FR')).toBe('Cancel'); + }); + + it('resolves context-disambiguated messages independently', () => { + setFrontComponentTranslations({ + 'fr-FR': { + [getTranslationCatalogKey('Open', 'door')]: 'Ouvrir', + [getTranslationCatalogKey('Open', 'window')]: 'Lever', + }, + }); + + expect( + resolveTranslation( + { message: 'Open', context: 'door' }, + undefined, + 'fr-FR', + ), + ).toBe('Ouvrir'); + expect( + resolveTranslation( + { message: 'Open', context: 'window' }, + undefined, + 'fr-FR', + ), + ).toBe('Lever'); + }); + + it('interpolates values into the resolved translation', () => { + setFrontComponentTranslations({ + 'fr-FR': { 'Saved {count} cards': 'Cartes enregistrées : {count}' }, + }); + + expect( + resolveTranslation('Saved {count} cards', { count: 5 }, 'fr-FR'), + ).toBe('Cartes enregistrées : 5'); + }); +}); diff --git a/packages/twenty-sdk/src/sdk/front-component/translations/front-component-translations.ts b/packages/twenty-sdk/src/sdk/front-component/translations/front-component-translations.ts new file mode 100644 index 0000000000..fc4e729ee4 --- /dev/null +++ b/packages/twenty-sdk/src/sdk/front-component/translations/front-component-translations.ts @@ -0,0 +1,14 @@ +import { FRONT_COMPONENT_TRANSLATIONS_KEY } from '../constants/front-component-translations-key'; +import { type TranslationCatalogsByLocale } from './message'; + +export const getFrontComponentTranslations = (): TranslationCatalogsByLocale => + ((globalThis as Record)[FRONT_COMPONENT_TRANSLATIONS_KEY] as + | TranslationCatalogsByLocale + | undefined) ?? {}; + +export const setFrontComponentTranslations = ( + catalogs: TranslationCatalogsByLocale, +): void => { + (globalThis as Record)[FRONT_COMPONENT_TRANSLATIONS_KEY] = + catalogs; +}; diff --git a/packages/twenty-sdk/src/sdk/front-component/translations/message/context-separator.constant.ts b/packages/twenty-sdk/src/sdk/front-component/translations/message/context-separator.constant.ts new file mode 100644 index 0000000000..bfad59ad98 --- /dev/null +++ b/packages/twenty-sdk/src/sdk/front-component/translations/message/context-separator.constant.ts @@ -0,0 +1,2 @@ +// A control char, so context-less keys stay equal to the raw source message. +export const CONTEXT_SEPARATOR = String.fromCharCode(0x04); diff --git a/packages/twenty-sdk/src/sdk/front-component/translations/message/get-translation-catalog-key.ts b/packages/twenty-sdk/src/sdk/front-component/translations/message/get-translation-catalog-key.ts new file mode 100644 index 0000000000..ca6db005ff --- /dev/null +++ b/packages/twenty-sdk/src/sdk/front-component/translations/message/get-translation-catalog-key.ts @@ -0,0 +1,9 @@ +import { CONTEXT_SEPARATOR } from './context-separator.constant'; + +export const getTranslationCatalogKey = ( + message: string, + context?: string, +): string => + context !== undefined && context.length > 0 + ? `${context}${CONTEXT_SEPARATOR}${message}` + : message; diff --git a/packages/twenty-sdk/src/sdk/front-component/translations/message/index.ts b/packages/twenty-sdk/src/sdk/front-component/translations/message/index.ts new file mode 100644 index 0000000000..4391f26039 --- /dev/null +++ b/packages/twenty-sdk/src/sdk/front-component/translations/message/index.ts @@ -0,0 +1,8 @@ +export { getTranslationCatalogKey } from './get-translation-catalog-key'; +export { interpolateMessage } from './interpolate-message'; +export { normalizeMessageDescriptor } from './normalize-message-descriptor'; +export { normalizeMessageWhitespace } from './normalize-message-whitespace'; +export { parseTranslationCatalogKey } from './parse-translation-catalog-key'; +export type { MessageDescriptor } from './message-descriptor.type'; +export type { TranslationCatalogsByLocale } from './translation-catalogs-by-locale.type'; +export type { TranslationValues } from './translation-values.type'; diff --git a/packages/twenty-sdk/src/sdk/front-component/translations/message/interpolate-message.ts b/packages/twenty-sdk/src/sdk/front-component/translations/message/interpolate-message.ts new file mode 100644 index 0000000000..2bfaeed6c4 --- /dev/null +++ b/packages/twenty-sdk/src/sdk/front-component/translations/message/interpolate-message.ts @@ -0,0 +1,16 @@ +import { type TranslationValues } from './translation-values.type'; + +export const interpolateMessage = ( + template: string, + values?: TranslationValues, +): string => { + if (values === undefined) { + return template; + } + + return template.replace(/\{(\w+)\}/g, (placeholder, name: string) => + Object.prototype.hasOwnProperty.call(values, name) + ? String(values[name]) + : placeholder, + ); +}; diff --git a/packages/twenty-sdk/src/sdk/front-component/translations/message/message-descriptor.type.ts b/packages/twenty-sdk/src/sdk/front-component/translations/message/message-descriptor.type.ts new file mode 100644 index 0000000000..b0767ed272 --- /dev/null +++ b/packages/twenty-sdk/src/sdk/front-component/translations/message/message-descriptor.type.ts @@ -0,0 +1,4 @@ +export type MessageDescriptor = { + message: string; + context?: string; +}; diff --git a/packages/twenty-sdk/src/sdk/front-component/translations/message/normalize-message-descriptor.ts b/packages/twenty-sdk/src/sdk/front-component/translations/message/normalize-message-descriptor.ts new file mode 100644 index 0000000000..3a313da8ef --- /dev/null +++ b/packages/twenty-sdk/src/sdk/front-component/translations/message/normalize-message-descriptor.ts @@ -0,0 +1,6 @@ +import { type MessageDescriptor } from './message-descriptor.type'; + +export const normalizeMessageDescriptor = ( + descriptor: string | MessageDescriptor, +): MessageDescriptor => + typeof descriptor === 'string' ? { message: descriptor } : descriptor; diff --git a/packages/twenty-sdk/src/sdk/front-component/translations/message/normalize-message-whitespace.ts b/packages/twenty-sdk/src/sdk/front-component/translations/message/normalize-message-whitespace.ts new file mode 100644 index 0000000000..77b861b502 --- /dev/null +++ b/packages/twenty-sdk/src/sdk/front-component/translations/message/normalize-message-whitespace.ts @@ -0,0 +1,3 @@ +// Must match how JSX collapses whitespace, so build and runtime agree. +export const normalizeMessageWhitespace = (text: string): string => + text.replace(/\s+/g, ' ').trim(); diff --git a/packages/twenty-sdk/src/sdk/front-component/translations/message/parse-translation-catalog-key.ts b/packages/twenty-sdk/src/sdk/front-component/translations/message/parse-translation-catalog-key.ts new file mode 100644 index 0000000000..9e68ad38a4 --- /dev/null +++ b/packages/twenty-sdk/src/sdk/front-component/translations/message/parse-translation-catalog-key.ts @@ -0,0 +1,15 @@ +import { CONTEXT_SEPARATOR } from './context-separator.constant'; +import { type MessageDescriptor } from './message-descriptor.type'; + +export const parseTranslationCatalogKey = (key: string): MessageDescriptor => { + const separatorIndex = key.indexOf(CONTEXT_SEPARATOR); + + if (separatorIndex === -1) { + return { message: key }; + } + + return { + context: key.slice(0, separatorIndex), + message: key.slice(separatorIndex + 1), + }; +}; diff --git a/packages/twenty-sdk/src/sdk/front-component/translations/message/translation-catalogs-by-locale.type.ts b/packages/twenty-sdk/src/sdk/front-component/translations/message/translation-catalogs-by-locale.type.ts new file mode 100644 index 0000000000..89213b8d30 --- /dev/null +++ b/packages/twenty-sdk/src/sdk/front-component/translations/message/translation-catalogs-by-locale.type.ts @@ -0,0 +1,5 @@ +import { type AppLocale } from 'twenty-shared/translations'; + +export type TranslationCatalogsByLocale = Partial< + Record> +>; diff --git a/packages/twenty-sdk/src/sdk/front-component/translations/message/translation-values.type.ts b/packages/twenty-sdk/src/sdk/front-component/translations/message/translation-values.type.ts new file mode 100644 index 0000000000..3cc7edbd0a --- /dev/null +++ b/packages/twenty-sdk/src/sdk/front-component/translations/message/translation-values.type.ts @@ -0,0 +1 @@ +export type TranslationValues = Record; diff --git a/packages/twenty-sdk/src/sdk/front-component/translations/msg.ts b/packages/twenty-sdk/src/sdk/front-component/translations/msg.ts new file mode 100644 index 0000000000..864e305cca --- /dev/null +++ b/packages/twenty-sdk/src/sdk/front-component/translations/msg.ts @@ -0,0 +1,5 @@ +import { normalizeMessageDescriptor, type MessageDescriptor } from './message'; + +export const msg = ( + descriptor: string | MessageDescriptor, +): MessageDescriptor => normalizeMessageDescriptor(descriptor); diff --git a/packages/twenty-sdk/src/sdk/front-component/translations/resolveTranslation.ts b/packages/twenty-sdk/src/sdk/front-component/translations/resolveTranslation.ts new file mode 100644 index 0000000000..f5b979adab --- /dev/null +++ b/packages/twenty-sdk/src/sdk/front-component/translations/resolveTranslation.ts @@ -0,0 +1,23 @@ +import { type AppLocale } from 'twenty-shared/translations'; + +import { getFrontComponentTranslations } from './front-component-translations'; +import { + getTranslationCatalogKey, + interpolateMessage, + normalizeMessageDescriptor, + type MessageDescriptor, + type TranslationValues, +} from './message'; + +export const resolveTranslation = ( + descriptor: string | MessageDescriptor, + values: TranslationValues | undefined, + locale: AppLocale, +): string => { + const { message, context } = normalizeMessageDescriptor(descriptor); + + const catalog = getFrontComponentTranslations()[locale]; + const translation = catalog?.[getTranslationCatalogKey(message, context)]; + + return interpolateMessage(translation ?? message, values); +}; diff --git a/packages/twenty-sdk/src/sdk/front-component/translations/t.ts b/packages/twenty-sdk/src/sdk/front-component/translations/t.ts new file mode 100644 index 0000000000..fa494da187 --- /dev/null +++ b/packages/twenty-sdk/src/sdk/front-component/translations/t.ts @@ -0,0 +1,19 @@ +import { SOURCE_LOCALE, type AppLocale } from 'twenty-shared/translations'; + +import { getFrontComponentExecutionContext } from '../context/frontComponentContext'; +import { type FrontComponentExecutionContext } from '../types/FrontComponentExecutionContext'; +import { type MessageDescriptor, type TranslationValues } from './message'; +import { resolveTranslation } from './resolveTranslation'; + +const getCurrentLocale = (): AppLocale => { + // Unset at module scope before the host pushes it. + const context: FrontComponentExecutionContext | undefined = + getFrontComponentExecutionContext(); + + return context?.locale ?? SOURCE_LOCALE; +}; + +export const t = ( + descriptor: string | MessageDescriptor, + values?: TranslationValues, +): string => resolveTranslation(descriptor, values, getCurrentLocale()); 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 67771ad1e5..660956c839 100644 --- a/packages/twenty-sdk/src/sdk/front-component/types/FrontComponentExecutionContext.ts +++ b/packages/twenty-sdk/src/sdk/front-component/types/FrontComponentExecutionContext.ts @@ -1,3 +1,5 @@ +import { type AppLocale } from 'twenty-shared/translations'; + export type FrontComponentExecutionContext = { frontComponentId: string; userId: string | null; @@ -9,5 +11,5 @@ export type FrontComponentExecutionContext = { selectedRecordIds: string[]; /** Resolved color scheme of the host UI ('System' is already resolved) */ colorScheme: 'light' | 'dark'; - locale?: string; + locale?: AppLocale; }; diff --git a/packages/twenty-shared/src/constants/DocumentationPaths.ts b/packages/twenty-shared/src/constants/DocumentationPaths.ts index a0d46fbc75..4104068ce2 100644 --- a/packages/twenty-shared/src/constants/DocumentationPaths.ts +++ b/packages/twenty-shared/src/constants/DocumentationPaths.ts @@ -75,6 +75,8 @@ export const DOCUMENTATION_PATHS = { '/developers/extend/apps/operations/sync-and-recovery', DEVELOPERS_EXTEND_APPS_OPERATIONS_TESTING: '/developers/extend/apps/operations/testing', + DEVELOPERS_EXTEND_APPS_TRANSLATIONS_OVERVIEW: + '/developers/extend/apps/translations/overview', DEVELOPERS_EXTEND_OAUTH: '/developers/extend/oauth', DEVELOPERS_EXTEND_WEBHOOKS: '/developers/extend/webhooks', DEVELOPERS_INTRODUCTION: '/developers/introduction',