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">``&lt;img alt="Review
in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"&gt;``</a>

---------

Co-authored-by: github-actions <github-actions@twenty.com>
This commit is contained in:
Félix Malfait
2026-07-01 18:50:35 +02:00
committed by GitHub
parent efd600c12b
commit 55ed4b7adb
44 changed files with 1149 additions and 111 deletions
@@ -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`.
+6
View File
@@ -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"
}
}
},
@@ -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,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,
});
});
@@ -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,
});
@@ -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,
};
};
@@ -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();
});
@@ -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' },
]);
});
});
@@ -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);
};
@@ -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);
}
}
}
@@ -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;
}
@@ -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,
};
};
@@ -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,
);
};
@@ -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}',
);
});
});
@@ -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');
});
});
@@ -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;
};
@@ -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);
@@ -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';
@@ -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,
);
};
@@ -0,0 +1,4 @@
export type MessageDescriptor = {
message: string;
context?: string;
};
@@ -0,0 +1,6 @@
import { type MessageDescriptor } from './message-descriptor.type';
export const normalizeMessageDescriptor = (
descriptor: string | MessageDescriptor,
): MessageDescriptor =>
typeof descriptor === 'string' ? { message: descriptor } : descriptor;
@@ -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();
@@ -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),
};
};
@@ -0,0 +1,5 @@
import { type AppLocale } from 'twenty-shared/translations';
export type TranslationCatalogsByLocale = Partial<
Record<AppLocale, Record<string, string>>
>;
@@ -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',