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
@@ -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;
};