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:
@@ -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;
|
||||
};
|
||||
Reference in New Issue
Block a user