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