Files
twenty/packages/twenty-sdk/src/cli/operations/build.ts
T
Félix Malfait 55ed4b7adb 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>
2026-07-01 18:50:35 +02:00

133 lines
3.7 KiB
TypeScript

import { execSync } from 'child_process';
import path from 'path';
import { applyGeneratedCover } from '@/cli/utilities/build/cover/apply-generated-cover';
import { buildApplication } from '@/cli/utilities/build/common/build-application';
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/translations/compile-application-translations';
import { runSafe } from '@/cli/utilities/run-safe';
import { APP_ERROR_CODES, type CommandResult } from '@/cli/types';
export type AppBuildOptions = {
appPath: string;
tarball?: boolean;
onProgress?: (message: string) => void;
};
export type AppBuildResult = {
outputDir: string;
fileCount: number;
tarballPath?: string;
};
const innerAppBuild = async (
options: AppBuildOptions,
): Promise<CommandResult<AppBuildResult>> => {
const { appPath, onProgress } = options;
onProgress?.('Building manifest...');
const manifestResult = await buildAndValidateManifest(appPath);
if (!manifestResult.success) {
return {
success: false,
error: {
code: APP_ERROR_CODES.MANIFEST_BUILD_FAILED,
message: manifestResult.errors.join('\n'),
},
};
}
const { filePaths } = manifestResult;
for (const warning of manifestResult.warnings) {
onProgress?.(`${warning}`);
}
const { manifest, generatedAssets } = await applyGeneratedCover({
appPath,
manifest: manifestResult.manifest,
}).catch((error) => {
onProgress?.(
`⚠ Skipped cover image generation: ${error instanceof Error ? error.message : String(error)}`,
);
return { manifest: manifestResult.manifest, generatedAssets: [] };
});
if (generatedAssets.length > 0) {
onProgress?.('Generated cover image from logo');
}
const translations = await compileApplicationTranslations(appPath);
onProgress?.('Building application files...');
const buildResult = await buildApplication({
appPath,
manifest,
filePaths,
generatedAssets,
});
onProgress?.('Running typecheck...');
const typecheckErrors = await runTypecheck(appPath);
if (typecheckErrors.length > 0) {
const errorMessages = typecheckErrors.map(
(error) =>
`${error.file}(${error.line},${error.column + 1}): ${error.text}`,
);
return {
success: false,
error: {
code: APP_ERROR_CODES.TYPECHECK_FAILED,
message: `Typecheck failed:\n${errorMessages.join('\n')}`,
},
};
}
const updatedManifest = manifestUpdateChecksums({
manifest,
builtFileInfos: buildResult.builtFileInfos,
});
await writeManifestToOutput(
appPath,
translations ? { ...updatedManifest, translations } : updatedManifest,
);
const outputDir = path.join(appPath, '.twenty', 'output');
const result: AppBuildResult = {
outputDir,
fileCount: buildResult.builtFileInfos.size,
};
if (options.tarball) {
onProgress?.('Packing tarball...');
const packOutput = execSync('npm pack --pack-destination .', {
cwd: outputDir,
encoding: 'utf-8',
}).trim();
const tarballName = packOutput.split('\n').pop()!;
result.tarballPath = path.join(outputDir, tarballName);
}
return { success: true, data: result };
};
export const appBuild = (
options: AppBuildOptions,
): Promise<CommandResult<AppBuildResult>> =>
runSafe(() => innerAppBuild(options), APP_ERROR_CODES.BUILD_FAILED);