Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 54 additions & 0 deletions .changeset/i18n-gate-declared-labels.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
---
"@objectstack/cli": minor
"@objectstack/spec": patch
---

feat(cli,spec): gate the whole declared surface for i18n, and translate inline object actions server-side (#3370)

In a zh-CN workspace the platform chrome was localized while author-declared
labels leaked English — the approval drawer rendered **Approve / Reject /
Reassign** right beside the inbox's own 通过 / 拒绝. Two independent holes, both
closed here.

**The lint gate could not see them.** `os lint`'s i18n coverage kept its own
walk of the metadata, separate from the one `os i18n extract` uses to scaffold
bundles, and the two had drifted: coverage only ever walked the *top-level*
`actions` array, while `sys_approval_request` declares its decision actions
**inline on the object**. Those labels were extractable but ungated, so an
untranslated one could ship and no lint run would notice. Coverage now derives
its expected keys from `collectExpectedEntries()` — the extractor's walker — so
the gated surface and the scaffolded surface cannot disagree again. Newly gated
as a result: inline object actions, action `params` and `resultDialog` copy,
object-nested `listViews` (label / description / `emptyState`), object
`description`, field `help` / `placeholder`, and the `apps` / `dashboards` /
`pages` surfaces. Extract output is byte-identical — verified against the
committed plugin bundles.

**It stays silent for projects that do not translate.** Which locales get
checked is the project's declaration, never an assumption: `os lint`,
`os i18n check` and `os i18n extract` now read the stack's own
`i18n.defaultLocale` / `i18n.supportedLocales`, falling back to the locales a
bundle already exists for, and finally to `en`. A project with neither is
checked against its default locale alone — which its inline labels already
satisfy — so it reports zero i18n issues. That also fixes a monolingual
*non-English* project being told it owed `en` translations it never claimed to
speak. Locked by regression tests; the three bundled examples stay at 0 errors.

**The server sent English regardless of locale.** `translateObject` walked an
object's `label` / `pluralLabel` / `description` / `fields` but never its inline
`actions`, so `GET /api/v1/meta/object/:name` returned the authored English
literals even though `@objectstack/plugin-approvals` ships `_actions`
translations for all eight decision actions in zh-CN / ja-JP / es-ES. The
Console compensated by re-resolving labels client-side against a separately
fetched bundle; every other consumer — mobile, plain HTTP, SDUI — rendered the
source language. It now runs inline actions through `translateAction`, without
stamping a synthetic `objectName` onto the response.

Adds `os i18n extract --no-metadata-forms`. Whether the companion
`<locale>.metadata-forms.generated.ts` file is written was previously implicit:
every run emitted it, so `--check` demanded that file in packages that
deliberately do not commit one. The Studio metadata-form baseline is
registry-driven and identical for every stack, so exactly one package owns it
(`platform-objects`); a plugin translating only its own objects now opts out,
and its `--check` stops failing on a tree that is in sync. Defaults to emitting,
so `pnpm check:i18n` keeps covering all 8 platform bundles.
27 changes: 26 additions & 1 deletion content/docs/ui/translations.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -78,7 +78,11 @@ export default defineStack({

The metadata types resolved per request are **object, view, action, app,
dashboard, and page** — a field's labels are translated as part of its object
document.
document, and so are the labels of any actions the object declares inline
(#3370). Before that, an object document went out with its authored action
labels untouched: `sys_approval_request`'s Approve / Reject rendered in English
in a zh-CN workspace for every consumer except the Console, which happened to
re-resolve them client-side against its own copy of the bundle.

<Callout type="info">
**Page headers are keyed by page name (#3589).** A page's `page:header`
Expand DownExpand Up@@ -151,6 +155,27 @@ A missing string in the **default** locale is an error; missing strings in
other locales are warnings until you set `--strict` / `--threshold`. The Todo
example ships a completeness test alongside its bundles — worth copying.

### Which locales get checked

Your project decides, and the tooling never assumes. `os lint`, `os i18n check`
and `os i18n extract` read the `i18n` block above — `supportedLocales` is the
set they gate, `defaultLocale` the one that must be complete. Without that
block they fall back to whatever locales your bundles already cover, and
finally to `en`.

The consequence worth stating plainly: **a project that does not do i18n
reports nothing.** No `i18n` block and no bundles means one active locale, the
default one, and your inline `label:` is already that locale's text — so there
is no gap to report and no need to reach for `--skip-i18n`. The same holds if
your source language isn't English: declare `defaultLocale: 'zh-CN'` and the
tooling stops asking for English you never promised.

Translating is therefore opt-in, but once you opt in it covers the **whole**
declared surface — every row of the table above, including action labels
declared inline on an object. That surface used to be narrower than what
`os i18n extract` would scaffold, which is how untranslated approval buttons
shipped without any lint noticing (#3370).

The two gates answer different questions, and you want both. `os i18n check`
asks *are the strings translated?* — a coverage number about human work.
`os i18n extract --check` asks *are the generated bundles still what the schema
Expand Down
7 changes: 4 additions & 3 deletions packages/cli/src/commands/i18n/check.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -34,11 +34,12 @@ export default class I18nCheck extends Command {
static override flags = {
json: Flags.boolean({ description: 'Output as JSON' }),
'default-locale': Flags.string({
description: 'Locale that must be 100% translated (errors raised against it)',
default: 'en',
description:
"Locale that must be 100% translated (errors raised against it). Defaults to the config's i18n.defaultLocale, else 'en'.",
}),
locales: Flags.string({
description: 'Comma-separated list of locales to check (default: every locale found)',
description:
'Comma-separated list of locales to check (default: the config\'s i18n.supportedLocales, else every locale found)',
}),
strict: Flags.boolean({
description: 'Treat missing keys in non-default locales as errors (CI parity gate)',
Expand Down
45 changes: 37 additions & 8 deletions packages/cli/src/commands/i18n/extract.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -55,11 +55,11 @@ export default class I18nExtract extends Command {
static override flags = {
json: Flags.boolean({ description: 'Output JSON instead of writing files' }),
'default-locale': Flags.string({
description: 'Locale filled from schema labels',
default: 'en',
description: "Locale filled from schema labels. Defaults to the config's i18n.defaultLocale, else 'en'.",
}),
locales: Flags.string({
description: 'Comma-separated list of locales to emit (always includes default-locale)',
description:
"Comma-separated list of locales to emit (always includes default-locale). Defaults to the config's i18n.supportedLocales.",
}),
fill: Flags.string({
description: 'How non-default locales are filled: empty | default | todo',
Expand All@@ -81,6 +81,12 @@ export default class I18nExtract extends Command {
default: true,
allowNo: true,
}),
'metadata-forms': Flags.boolean({
description:
'Also write <locale>.metadata-forms.generated.ts for the Studio metadata-form baseline (default). Pass --no-metadata-forms in a package that owns only its own objects — that baseline belongs to one package, not every plugin.',
default: true,
allowNo: true,
}),
'dry-run': Flags.boolean({
description: 'Print to stdout instead of writing to --out',
default: false,
Expand All@@ -106,12 +112,25 @@ export default class I18nExtract extends Command {

const normalized = normalizeStackInput(config as Record<string, unknown>);
const filter = flags.filter ? new RegExp(flags.filter) : undefined;
// The stack's own `i18n` block already names the languages it ships, so
// scaffolding those by default saves repeating them on every invocation.
const declared = (normalized as { i18n?: { defaultLocale?: unknown; supportedLocales?: unknown } }).i18n;
const declaredLocales = Array.isArray(declared?.supportedLocales)
? declared.supportedLocales.filter((l): l is string => typeof l === 'string' && l.length > 0)
: [];
const locales = flags.locales
? flags.locales.split(',').map((s) => s.trim()).filter(Boolean)
: undefined;
: declaredLocales.length > 0
? declaredLocales
: undefined;
const defaultLocale =
flags['default-locale'] ??
(typeof declared?.defaultLocale === 'string' && declared.defaultLocale.length > 0
? declared.defaultLocale
: 'en');

const result = extractTranslations(normalized, {
defaultLocale: flags['default-locale'],
defaultLocale,
locales,
fill: flags.fill as FillStrategy,
filter,
Expand All@@ -128,6 +147,16 @@ export default class I18nExtract extends Command {
metadataFormsCounts[locale] = countLeaves(result.bundles[locale]?.metadataForms);
}
const anyMetadataForms = Object.values(metadataFormsCounts).some((n) => n > 0);
// Whether the companion `<locale>.metadata-forms.generated.ts` file is
// written is its own question, orthogonal to `--objects-only` (which only
// picks the sub-tree of the *objects* module). The Studio metadata-form
// baseline is registry-driven and identical for every stack, so exactly
// one package should own it — `platform-objects` does. A plugin that owns
// only its own objects passes `--no-metadata-forms`; without it, `--check`
// demands a baseline copy the package deliberately does not commit and
// fails on a tree that is in fact in sync.
const emitsMetadataForms = (locale: string): boolean =>
flags['metadata-forms'] && (metadataFormsCounts[locale] ?? 0) > 0;

if (flags.json) {
console.log(JSON.stringify({
Expand DownExpand Up@@ -169,7 +198,7 @@ export default class I18nExtract extends Command {
locale,
objectsOnly,
}));
if (metadataFormsCounts[locale] > 0) {
if (emitsMetadataForms(locale)) {
console.log(chalk.dim(`── ${locale} (metadataForms) ──`));
console.log(renderTranslationModule(result.bundles[locale], {
locale,
Expand All@@ -195,7 +224,7 @@ export default class I18nExtract extends Command {
keys: result.counts[locale],
});
}
if (metadataFormsCounts[locale] > 0) {
if (emitsMetadataForms(locale)) {
emitted.push({
file: path.join(outDir, `${locale}.metadata-forms.generated.ts`),
content: renderTranslationModule(result.bundles[locale], { locale, kind: 'metadataForms' }),
Expand All@@ -222,7 +251,7 @@ export default class I18nExtract extends Command {
console.log('');
printError(
'Translation bundles have drifted from the schema. Regenerate and commit:\n' +
` os i18n extract ${args.config ?? ''} --locales=${localesEmitted.filter((l) => l !== flags['default-locale']).join(',')} ` +
` os i18n extract ${args.config ?? ''} --locales=${localesEmitted.filter((l) => l !== defaultLocale).join(',')} ` +
`--fill=${flags.fill} --out=${flags.out}`.replace(/\s+/g, ' '),
);
process.exit(1);
Expand Down
9 changes: 7 additions & 2 deletions packages/cli/src/commands/lint.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -604,8 +604,8 @@ export default class Lint extends Command {
description: 'Treat missing translations in non-default locales as errors',
}),
'default-locale': Flags.string({
description: 'Default locale for i18n coverage (must be 100% translated)',
default: 'en',
description:
"Default locale for i18n coverage (must be 100% translated). Defaults to the config's i18n.defaultLocale, else 'en'.",
}),
};

Expand DownExpand Up@@ -645,6 +645,11 @@ export default class Lint extends Command {
}

// ── Translation coverage ──
// No locale is forced here: `computeI18nCoverage` falls back to the
// stack's own `i18n` block and, failing that, to the locales its bundles
// already cover. A project that ships neither is checked against its
// default locale alone, which its inline labels already satisfy — so this
// stays silent for projects that do not translate.
let hiddenPlatform = 0;
if (!flags['skip-i18n']) {
const coverage = computeI18nCoverage(normalized, {
Expand Down
Loading
Loading