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:
@@ -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;
|
||||
};
|
||||
|
||||
+2
@@ -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,
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
+2
-1
@@ -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;
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user