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,
});