feat(sdk): translate front-component strings with t()/Trans/useTranslate (#22301)
## 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';
<Trans>Loading postcard…</Trans>
<Trans context="card-title">Untitled</Trans> // disambiguation
const empty = t('No content yet…'); // works outside JSX
<p>{t('Saved {count} cards', { count })}</p> // 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),
`<Trans>`
(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()`/`<Trans>`
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; `<Trans>` 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`/`<Trans>`, 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)_
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22301?utm_source=github"
rel="nofollow noreferrer noopener" target="_blank">``<img alt="Review
in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg">``</a>
---------
Co-authored-by: github-actions <github-actions@twenty.com>
This commit is contained in:
@@ -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 (
|
||||
<section>
|
||||
{/* Static text — reactive to the user's locale */}
|
||||
<Trans>Loading postcard…</Trans>
|
||||
|
||||
{/* Disambiguate identical sources with a context */}
|
||||
<Trans context="card-title">Untitled</Trans>
|
||||
|
||||
{/* Interpolation: pass values explicitly */}
|
||||
<p>{t('Hi {name}', { name })}</p>
|
||||
<p>{t('Saved {count} cards', { count })}</p>
|
||||
|
||||
{/* Resolve a lazily-declared descriptor */}
|
||||
<ul>{STATUSES.map((s) => <li key={s.id}>{t(s.label)}</li>)}</ul>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
```
|
||||
|
||||
### When to use which
|
||||
|
||||
- **`<Trans>…</Trans>`** — static text in JSX. Use the `message` and `values`
|
||||
props for interpolation (`<Trans message="Hi {name}" values={{ name }} />`);
|
||||
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' });
|
||||
<Trans context="card-title">Untitled</Trans>
|
||||
```
|
||||
|
||||
## 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()`/`<Trans>`
|
||||
strings from your front-component source into `locales/<locale>.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 `<Trans>` 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.
|
||||
|
||||
<Note>
|
||||
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.
|
||||
</Note>
|
||||
|
||||
`<Trans>` text children may span multiple lines — whitespace is collapsed the
|
||||
same way JSX collapses it, so `<Trans>Welcome\n back</Trans>` and the extracted
|
||||
key both become `Welcome back`.
|
||||
@@ -434,6 +434,12 @@
|
||||
"developers/extend/apps/operations/testing",
|
||||
"developers/extend/apps/operations/publishing"
|
||||
]
|
||||
},
|
||||
{
|
||||
"group": "Translations",
|
||||
"pages": [
|
||||
"developers/extend/apps/translations/overview"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
@@ -436,6 +436,13 @@
|
||||
"developers/extend/apps/operations/testing",
|
||||
"developers/extend/apps/operations/publishing"
|
||||
]
|
||||
},
|
||||
{
|
||||
"key": "appsTranslations",
|
||||
"label": "Translations",
|
||||
"pages": [
|
||||
"developers/extend/apps/translations/overview"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
@@ -174,6 +174,9 @@
|
||||
},
|
||||
"appsOperations": {
|
||||
"label": "Operations"
|
||||
},
|
||||
"appsTranslations": {
|
||||
"label": "Translations"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
+3
-1
@@ -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;
|
||||
};
|
||||
|
||||
+3
-2
@@ -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,
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
+4
-1
@@ -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'] =
|
||||
|
||||
@@ -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 <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,
|
||||
});
|
||||
|
||||
+14
-5
@@ -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<void> {
|
||||
export class AppTranslationsExtractCommand {
|
||||
async execute(options: AppTranslationsExtractOptions): Promise<void> {
|
||||
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,
|
||||
});
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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()/<Trans> 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(),
|
||||
|
||||
@@ -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<ExtractApplicationTranslationsResult> => {
|
||||
const sources = collectTranslatableStrings(manifest);
|
||||
const localesDir = path.join(appPath, LOCALES_DIR);
|
||||
|
||||
await ensureDir(localesDir);
|
||||
|
||||
const sourceCatalog: Record<string, string> = {};
|
||||
|
||||
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<Record<string, string>>(filePath)) ?? {};
|
||||
const merged: Record<string, string> = {};
|
||||
|
||||
for (const source of sources) {
|
||||
merged[source] = existing[source] ?? '';
|
||||
}
|
||||
|
||||
await writeJson(filePath, merged);
|
||||
}
|
||||
|
||||
return {
|
||||
sourceCount: sources.length,
|
||||
updatedLocaleFiles: existingLocaleFiles,
|
||||
};
|
||||
};
|
||||
+27
-5
@@ -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<string, unknown>): 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();
|
||||
});
|
||||
+118
@@ -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<string> => {
|
||||
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 <Trans> 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 (
|
||||
<div title={label}>
|
||||
<Trans>Welcome back</Trans>
|
||||
<Trans context="card">Untitled</Trans>
|
||||
<Trans message="Hi {name}" values={{ name: 'Ada' }} />
|
||||
<span>{translate(STATUS)}</span>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
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 <Trans> static text', async () => {
|
||||
const filePath = await writeFrontComponent(`
|
||||
import { Trans } from 'twenty-sdk/front-component';
|
||||
|
||||
const Component = () => (
|
||||
<p>
|
||||
<Trans>
|
||||
Welcome
|
||||
back
|
||||
</Trans>
|
||||
</p>
|
||||
);
|
||||
|
||||
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 (
|
||||
<div title={dynamic}>
|
||||
<Trans>Hello {name}</Trans>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
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' },
|
||||
]);
|
||||
});
|
||||
});
|
||||
+233
@@ -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<string, MessageDescriptor>();
|
||||
|
||||
for (const descriptor of descriptors) {
|
||||
descriptorByKey.set(
|
||||
getTranslationCatalogKey(descriptor.message, descriptor.context),
|
||||
descriptor,
|
||||
);
|
||||
}
|
||||
|
||||
return [...descriptorByKey.values()];
|
||||
};
|
||||
|
||||
export const collectFrontComponentStrings = async (
|
||||
sourceFilePaths: string[],
|
||||
): Promise<MessageDescriptor[]> => {
|
||||
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);
|
||||
};
|
||||
+19
-5
@@ -6,12 +6,18 @@ const TRANSLATABLE_KEYS_BY_MANIFEST_KEY: Record<string, readonly string[]> = {
|
||||
views: ['name'],
|
||||
pageLayoutTabs: ['title'],
|
||||
commandMenuItems: ['label', 'shortLabel'],
|
||||
navigationMenuItems: ['label'],
|
||||
navigationMenuItems: ['name'],
|
||||
};
|
||||
|
||||
export const collectTranslatableStrings = (manifest: Manifest): string[] => {
|
||||
const strings = new Set<string>();
|
||||
|
||||
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<string, unknown>)[fieldKey];
|
||||
addString((entity as Record<string, unknown>)[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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+11
-11
@@ -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<string, string> = {};
|
||||
// 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<string, string>();
|
||||
const keyByMessageId = new Map<string, string>();
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
+123
@@ -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<Map<string, MessageDescriptor>> => {
|
||||
const manifestDescriptors: MessageDescriptor[] = collectTranslatableStrings(
|
||||
manifest,
|
||||
).map((message) => ({ message }));
|
||||
|
||||
const frontComponentDescriptors = await collectFrontComponentStrings(
|
||||
frontComponentSourcePaths,
|
||||
);
|
||||
|
||||
const descriptorByKey = new Map<string, MessageDescriptor>();
|
||||
|
||||
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<ExtractApplicationTranslationsResult> => {
|
||||
const descriptorByKey = await collectSourceEntries({
|
||||
manifest,
|
||||
frontComponentSourcePaths,
|
||||
});
|
||||
|
||||
const sortedKeys = [...descriptorByKey.keys()].sort();
|
||||
const localesDir = path.join(appPath, LOCALES_DIR);
|
||||
|
||||
await ensureDir(localesDir);
|
||||
|
||||
const sourceCatalog: Record<string, string> = {};
|
||||
|
||||
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<Record<string, unknown>>(filePath)) ?? {};
|
||||
const merged: Record<string, string> = {};
|
||||
|
||||
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,
|
||||
};
|
||||
};
|
||||
+58
@@ -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<TranslationCatalogsByLocale> => {
|
||||
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<Record<string, string>>(
|
||||
path.join(localesDir, localeFile),
|
||||
)) ?? {};
|
||||
|
||||
const nonEmptyEntries: Record<string, string> = {};
|
||||
|
||||
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;
|
||||
};
|
||||
@@ -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,
|
||||
);
|
||||
};
|
||||
+4
@@ -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__';
|
||||
@@ -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);
|
||||
};
|
||||
@@ -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 };
|
||||
};
|
||||
@@ -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 {
|
||||
|
||||
@@ -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}',
|
||||
);
|
||||
});
|
||||
});
|
||||
+63
@@ -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');
|
||||
});
|
||||
});
|
||||
+14
@@ -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<string, unknown>)[FRONT_COMPONENT_TRANSLATIONS_KEY] as
|
||||
| TranslationCatalogsByLocale
|
||||
| undefined) ?? {};
|
||||
|
||||
export const setFrontComponentTranslations = (
|
||||
catalogs: TranslationCatalogsByLocale,
|
||||
): void => {
|
||||
(globalThis as Record<string, unknown>)[FRONT_COMPONENT_TRANSLATIONS_KEY] =
|
||||
catalogs;
|
||||
};
|
||||
+2
@@ -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);
|
||||
+9
@@ -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;
|
||||
@@ -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';
|
||||
+16
@@ -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,
|
||||
);
|
||||
};
|
||||
+4
@@ -0,0 +1,4 @@
|
||||
export type MessageDescriptor = {
|
||||
message: string;
|
||||
context?: string;
|
||||
};
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
import { type MessageDescriptor } from './message-descriptor.type';
|
||||
|
||||
export const normalizeMessageDescriptor = (
|
||||
descriptor: string | MessageDescriptor,
|
||||
): MessageDescriptor =>
|
||||
typeof descriptor === 'string' ? { message: descriptor } : descriptor;
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
// Must match how JSX collapses <Trans> whitespace, so build and runtime agree.
|
||||
export const normalizeMessageWhitespace = (text: string): string =>
|
||||
text.replace(/\s+/g, ' ').trim();
|
||||
+15
@@ -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),
|
||||
};
|
||||
};
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
import { type AppLocale } from 'twenty-shared/translations';
|
||||
|
||||
export type TranslationCatalogsByLocale = Partial<
|
||||
Record<AppLocale, Record<string, string>>
|
||||
>;
|
||||
+1
@@ -0,0 +1 @@
|
||||
export type TranslationValues = Record<string, string | number>;
|
||||
@@ -0,0 +1,5 @@
|
||||
import { normalizeMessageDescriptor, type MessageDescriptor } from './message';
|
||||
|
||||
export const msg = (
|
||||
descriptor: string | MessageDescriptor,
|
||||
): MessageDescriptor => normalizeMessageDescriptor(descriptor);
|
||||
@@ -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);
|
||||
};
|
||||
@@ -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());
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user