feat(twenty-sdk): extract & compile app translations into the manifest (#22236)

## Summary

**PR 2/4** of the app-metadata-translations stack. Gives app developers
the authoring side, as part of the normal manifest build — and it stays
out of the way of developers who don't translate.

- `twenty-sdk` CLI i18n pipeline: collect translatable strings from the
manifest, generate value-as-key message ids (`sha256(value)` truncated,
byte-identical to the server's `generateMessageId`), a `dev
i18n-extract` command to scaffold per-locale catalog files, and a
compile step folded into `build` that emits `manifest.translations`.
- Opt-in: no `locales/` dir → `compileApplicationTranslations` returns
`undefined` → manifest is unchanged.
- Adds an optional `locale` to the front-component execution context so
components can translate against the host locale.

## Stack
Stacks on #22235 (PR 1/4). Base branch:
`claude/app-translation-1-runtime-resolution`.

## Tests
Unit (vitest): extract/compile round-trip + message-id determinism.

## Verification note
`yarn install` could not complete in the remote dev environment, so
typecheck/lint/tests were not run locally — **CI is the source of
truth**.

https://claude.ai/code/session_01NiE7o3cd3zCLZarVkJa6UA

---
_Generated by [Claude
Code](https://claude.ai/code/session_01NiE7o3cd3zCLZarVkJa6UA)_

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22236?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->
This commit is contained in:
Félix Malfait
2026-06-28 07:36:48 +02:00
committed by GitHub
parent 41c10b9ee7
commit d81b3c3fa3
13 changed files with 407 additions and 2 deletions
@@ -9,4 +9,5 @@ export type FrontComponentExecutionContext = {
selectedRecordIds: string[];
/** Resolved color scheme of the host UI ('System' is already resolved) */
colorScheme: 'light' | 'dark';
locale?: string;
};
@@ -180,6 +180,7 @@ describe('useFrontComponentExecutionContext', () => {
recordId: 'record-456',
selectedRecordIds: ['record-456'],
colorScheme: 'light',
locale: i18n.locale,
});
});
@@ -195,6 +196,7 @@ describe('useFrontComponentExecutionContext', () => {
recordId: null,
selectedRecordIds: ['record-1', 'record-2', 'record-3'],
colorScheme: 'light',
locale: i18n.locale,
});
});
@@ -80,7 +80,7 @@ export const useFrontComponentExecutionContext = ({
} = useSnackBar();
const { closeSidePanelMenu } = useSidePanelMenu();
const { copyToClipboard: copyToClipboardWithSnackbar } = useCopyToClipboard();
const { t } = useLingui();
const { t, i18n } = useLingui();
// oxlint-disable-next-line twenty/no-state-useref
const lastCopyToClipboardCallAtRef = useRef<number>(Number.NEGATIVE_INFINITY);
const setCommandMenuItemProgress = useSetAtomFamilyState(
@@ -252,6 +252,7 @@ export const useFrontComponentExecutionContext = ({
recordId: selectedRecordIds?.length === 1 ? selectedRecordIds[0] : null,
selectedRecordIds: selectedRecordIds ?? [],
colorScheme,
locale: i18n.locale,
};
const unmountFrontComponent: FrontComponentHostCommunicationApi['unmountFrontComponent'] =
@@ -0,0 +1,75 @@
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 {
APP_LOCALES,
SOURCE_LOCALE,
type AppLocale,
} from 'twenty-shared/translations';
export type AppI18nExtractOptions = {
appPath?: string;
locale?: string;
};
const isSupportedLocale = (locale: string): locale is AppLocale =>
Object.prototype.hasOwnProperty.call(APP_LOCALES, locale);
export class AppI18nExtractCommand {
async execute(options: AppI18nExtractOptions): Promise<void> {
const appPath = options.appPath ?? CURRENT_EXECUTION_DIRECTORY;
let scaffoldLocale: AppLocale | undefined;
if (options.locale !== undefined) {
if (
options.locale === SOURCE_LOCALE ||
!isSupportedLocale(options.locale)
) {
console.error(
chalk.red(
`"${options.locale}" is not a supported target locale. Choose one of: ${Object.keys(
APP_LOCALES,
)
.filter((locale) => locale !== SOURCE_LOCALE)
.join(', ')}`,
),
);
process.exit(1);
}
scaffoldLocale = options.locale;
}
console.log(chalk.blue('Extracting translatable strings...'));
const manifestResult = await buildAndValidateManifest(appPath);
if (!manifestResult.success) {
console.error(chalk.red(manifestResult.errors.join('\n')));
process.exit(1);
}
if (manifestResult.warnings.length > 0) {
console.warn(chalk.yellow(manifestResult.warnings.join('\n')));
}
const { sourceCount, updatedLocaleFiles } =
await extractApplicationTranslations({
appPath,
manifest: manifestResult.manifest,
scaffoldLocale,
});
console.log(
chalk.green(
`✓ Extracted ${sourceCount} string${sourceCount === 1 ? '' : 's'} to locales/`,
),
);
if (updatedLocaleFiles.length > 0) {
console.log(chalk.gray(`Updated: ${updatedLocaleFiles.join(', ')}`));
}
}
}
@@ -7,6 +7,7 @@ import { AppBuildCommand } from './build';
import { AppDevCommand } from './dev';
import { AppDevOnceCommand } from './dev-once';
import { AppGenerateClientCommand } from './generate-client';
import { AppI18nExtractCommand } from './i18n-extract';
import { AppTypecheckCommand } from './typecheck';
import { registerDevFunctionCommands } from './function';
@@ -17,6 +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 devAction = async (
appPath: string | undefined,
@@ -123,5 +125,19 @@ export const registerDevCommands = (program: Command): void => {
});
});
program
.command('dev:i18n-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({
appPath: formatPath(appPath),
locale: options.locale,
});
});
registerDevFunctionCommands(program);
};
@@ -7,6 +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 { runSafe } from '@/cli/utilities/run-safe';
import { APP_ERROR_CODES, type CommandResult } from '@/cli/types';
@@ -62,6 +63,8 @@ const innerAppBuild = async (
onProgress?.('Generated cover image from logo');
}
const translations = await compileApplicationTranslations(appPath);
onProgress?.('Building application files...');
const buildResult = await buildApplication({
@@ -95,7 +98,10 @@ const innerAppBuild = async (
builtFileInfos: buildResult.builtFileInfos,
});
await writeManifestToOutput(appPath, updatedManifest);
await writeManifestToOutput(
appPath,
translations ? { ...updatedManifest, translations } : updatedManifest,
);
const outputDir = path.join(appPath, '.twenty', 'output');
@@ -0,0 +1,92 @@
import { mkdir, mkdtemp, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
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 { type Manifest } from 'twenty-shared/application';
const buildManifest = (overrides: Record<string, unknown>): Manifest =>
({
application: {},
objects: [],
fields: [],
views: [],
pageLayoutTabs: [],
commandMenuItems: [],
navigationMenuItems: [],
...overrides,
}) as unknown as Manifest;
describe('generateMessageId', () => {
it('is deterministic and six characters long', () => {
expect(generateMessageId('Company')).toHaveLength(6);
expect(generateMessageId('Company')).toBe(generateMessageId('Company'));
});
it('differs for different sources and contexts', () => {
expect(generateMessageId('Company')).not.toBe(
generateMessageId('Companies'),
);
expect(generateMessageId('Cancel', 'subscription')).not.toBe(
generateMessageId('Cancel'),
);
});
});
describe('collectTranslatableStrings', () => {
it('collects and dedupes object and field strings', () => {
const manifest = buildManifest({
objects: [
{
labelSingular: 'Company',
labelPlural: 'Companies',
description: 'A company',
},
],
fields: [
{ label: 'Name', description: 'Name' },
{ label: 'Company', description: '' },
],
});
expect(collectTranslatableStrings(manifest)).toEqual([
'A company',
'Companies',
'Company',
'Name',
]);
});
});
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 localesDir = join(appPath, 'locales');
await mkdir(localesDir, { recursive: true });
await writeFile(
join(localesDir, 'fr-FR.json'),
JSON.stringify({ Company: 'Entreprise', Untranslated: '' }),
);
await writeFile(
join(localesDir, 'en.json'),
JSON.stringify({ Company: 'Company' }),
);
const result = await compileApplicationTranslations(appPath);
expect(result).toEqual({
'fr-FR': { [generateMessageId('Company')]: 'Entreprise' },
});
});
it('returns undefined when there is no locales directory', async () => {
const appPath = await mkdtemp(join(tmpdir(), 'twenty-i18n-empty-'));
expect(await compileApplicationTranslations(appPath)).toBeUndefined();
});
});
@@ -0,0 +1,42 @@
import { type Manifest } from 'twenty-shared/application';
const TRANSLATABLE_KEYS_BY_MANIFEST_KEY: Record<string, readonly string[]> = {
objects: ['labelSingular', 'labelPlural', 'description'],
fields: ['label', 'description'],
views: ['name'],
pageLayoutTabs: ['title'],
commandMenuItems: ['label', 'shortLabel'],
navigationMenuItems: ['label'],
};
export const collectTranslatableStrings = (manifest: Manifest): string[] => {
const strings = new Set<string>();
for (const [manifestKey, fieldKeys] of Object.entries(
TRANSLATABLE_KEYS_BY_MANIFEST_KEY,
)) {
const entities = (manifest as unknown as Record<string, unknown>)[
manifestKey
];
if (!Array.isArray(entities)) {
continue;
}
for (const entity of entities) {
if (entity === null || typeof entity !== 'object') {
continue;
}
for (const fieldKey of fieldKeys) {
const value = (entity as Record<string, unknown>)[fieldKey];
if (typeof value === 'string' && value.length > 0) {
strings.add(value);
}
}
}
}
return [...strings].sort();
};
@@ -0,0 +1,84 @@
import { readdir } from 'node:fs/promises';
import path from 'path';
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 { type TranslationsManifest } from 'twenty-shared/application';
import {
APP_LOCALES,
SOURCE_LOCALE,
type AppLocale,
} from 'twenty-shared/translations';
const isSupportedLocale = (locale: string): locale is AppLocale =>
Object.prototype.hasOwnProperty.call(APP_LOCALES, locale);
export const compileApplicationTranslations = async (
appPath: string,
): Promise<TranslationsManifest | undefined> => {
const localesDir = path.join(appPath, LOCALES_DIR);
if (!(await pathExists(localesDir))) {
return undefined;
}
const localeFiles = (await readdir(localesDir)).filter((entry) =>
entry.endsWith('.json'),
);
const translations: Record<string, Record<string, string>> = {};
for (const localeFile of localeFiles) {
const locale = path.basename(localeFile, '.json');
if (locale === SOURCE_LOCALE) {
continue;
}
if (!isSupportedLocale(locale)) {
console.warn(
`Skipping translation file "${localeFile}": "${locale}" is not a supported locale.`,
);
continue;
}
const sourceToTranslation =
(await readJson<Record<string, string>>(
path.join(localesDir, localeFile),
)) ?? {};
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>();
for (const [source, translation] of Object.entries(sourceToTranslation)) {
if (typeof translation !== 'string' || translation.length === 0) {
continue;
}
const messageId = generateMessageId(source);
const collidingSource = sourceByMessageId.get(messageId);
if (collidingSource !== undefined && collidingSource !== source) {
console.warn(
`Message id collision in "${localeFile}": "${source}" and "${collidingSource}" share id "${messageId}". Keeping "${source}".`,
);
}
sourceByMessageId.set(messageId, source);
compiled[messageId] = translation;
}
if (Object.keys(compiled).length > 0) {
translations[locale] = compiled;
}
}
if (Object.keys(translations).length === 0) {
return undefined;
}
return translations as TranslationsManifest;
};
@@ -0,0 +1 @@
export const LOCALES_DIR = 'locales';
@@ -0,0 +1,75 @@
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,
};
};
@@ -0,0 +1,9 @@
import { createHash } from 'node:crypto';
const UNIT_SEPARATOR = '\u001F';
export const generateMessageId = (message: string, context = ''): string =>
createHash('sha256')
.update(message + UNIT_SEPARATOR + (context || ''))
.digest('base64')
.slice(0, 6);
@@ -9,4 +9,5 @@ export type FrontComponentExecutionContext = {
selectedRecordIds: string[];
/** Resolved color scheme of the host UI ('System' is already resolved) */
colorScheme: 'light' | 'dark';
locale?: string;
};