55ed4b7adb
## 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>
133 lines
3.7 KiB
TypeScript
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);
|