Skip to content

Commit a8d1e24

Browse files
authored
feat(cli,spec): gate the whole declared surface for i18n; translate inline object actions (#3370) (#3753)
A zh-CN workspace localized the platform chrome but leaked English from author-declared labels: the approval drawer rendered Approve / Reject / Reassign beside the inbox's own 通过 / 拒绝. Two independent holes. The lint gate could not see them. `os lint`'s coverage detector kept its own walk of the metadata, separate from the walker `os i18n extract` uses to scaffold bundles, and the two had drifted — coverage only walked the top-level `actions` array, while `sys_approval_request` declares its decision actions inline on the object. Those labels were extractable but ungated. Coverage now derives its expected keys from `collectExpectedEntries()`, so the gated surface and the scaffolded surface cannot disagree again. Newly gated: inline object actions, action params and resultDialog copy, object-nested listViews, object description, field help/placeholder, and apps/dashboards/pages. Extract output is byte-identical against the committed plugin bundles. It stays silent for projects that do not translate. `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 covers, then to `en`. A project declaring neither is checked against its default locale alone, which its inline labels already satisfy — zero issues. This also stops a monolingual non-English project being told it owes `en` translations. The server sent English regardless of locale. `translateObject` walked an object's label/pluralLabel/description/fields but never its inline `actions`, so the meta endpoint returned the authored literals even though plugin-approvals ships `_actions` translations for all eight decision actions. The Console compensated client-side; every other consumer rendered the source language. Inline actions now run through `translateAction`. Adds `os i18n extract --no-metadata-forms` so a package that owns only its own objects stops being asked to commit the registry-driven Studio baseline, which exactly one package should carry. Defaults to on: deriving it from `--objects-only` instead took `pnpm check:i18n` from 8 bundles to 4 while still reporting green, so the emit set is now pinned to the flags by a test. Follow-up filed as #3762: platform-objects declares 55 inline actions across 12 objects with no objects translation bundle at all.
1 parent a137bbc commit a8d1e24

12 files changed

Lines changed: 816 additions & 302 deletions

File tree

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
---
2+
"@objectstack/cli": minor
3+
"@objectstack/spec": patch
4+
---
5+
6+
feat(cli,spec): gate the whole declared surface for i18n, and translate inline object actions server-side (#3370)
7+
8+
In a zh-CN workspace the platform chrome was localized while author-declared
9+
labels leaked English — the approval drawer rendered **Approve / Reject /
10+
Reassign** right beside the inbox's own 通过 / 拒绝. Two independent holes, both
11+
closed here.
12+
13+
**The lint gate could not see them.** `os lint`'s i18n coverage kept its own
14+
walk of the metadata, separate from the one `os i18n extract` uses to scaffold
15+
bundles, and the two had drifted: coverage only ever walked the *top-level*
16+
`actions` array, while `sys_approval_request` declares its decision actions
17+
**inline on the object**. Those labels were extractable but ungated, so an
18+
untranslated one could ship and no lint run would notice. Coverage now derives
19+
its expected keys from `collectExpectedEntries()` — the extractor's walker — so
20+
the gated surface and the scaffolded surface cannot disagree again. Newly gated
21+
as a result: inline object actions, action `params` and `resultDialog` copy,
22+
object-nested `listViews` (label / description / `emptyState`), object
23+
`description`, field `help` / `placeholder`, and the `apps` / `dashboards` /
24+
`pages` surfaces. Extract output is byte-identical — verified against the
25+
committed plugin bundles.
26+
27+
**It stays silent for projects that do not translate.** Which locales get
28+
checked is the project's declaration, never an assumption: `os lint`,
29+
`os i18n check` and `os i18n extract` now read the stack's own
30+
`i18n.defaultLocale` / `i18n.supportedLocales`, falling back to the locales a
31+
bundle already exists for, and finally to `en`. A project with neither is
32+
checked against its default locale alone — which its inline labels already
33+
satisfy — so it reports zero i18n issues. That also fixes a monolingual
34+
*non-English* project being told it owed `en` translations it never claimed to
35+
speak. Locked by regression tests; the three bundled examples stay at 0 errors.
36+
37+
**The server sent English regardless of locale.** `translateObject` walked an
38+
object's `label` / `pluralLabel` / `description` / `fields` but never its inline
39+
`actions`, so `GET /api/v1/meta/object/:name` returned the authored English
40+
literals even though `@objectstack/plugin-approvals` ships `_actions`
41+
translations for all eight decision actions in zh-CN / ja-JP / es-ES. The
42+
Console compensated by re-resolving labels client-side against a separately
43+
fetched bundle; every other consumer — mobile, plain HTTP, SDUI — rendered the
44+
source language. It now runs inline actions through `translateAction`, without
45+
stamping a synthetic `objectName` onto the response.
46+
47+
Adds `os i18n extract --no-metadata-forms`. Whether the companion
48+
`<locale>.metadata-forms.generated.ts` file is written was previously implicit:
49+
every run emitted it, so `--check` demanded that file in packages that
50+
deliberately do not commit one. The Studio metadata-form baseline is
51+
registry-driven and identical for every stack, so exactly one package owns it
52+
(`platform-objects`); a plugin translating only its own objects now opts out,
53+
and its `--check` stops failing on a tree that is in sync. Defaults to emitting,
54+
so `pnpm check:i18n` keeps covering all 8 platform bundles.

content/docs/ui/translations.mdx

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -78,7 +78,11 @@ export default defineStack({
7878

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

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

158+
### Which locales get checked
159+
160+
Your project decides, and the tooling never assumes. `os lint`, `os i18n check`
161+
and `os i18n extract` read the `i18n` block above`supportedLocales` is the
162+
set they gate, `defaultLocale` the one that must be complete. Without that
163+
block they fall back to whatever locales your bundles already cover, and
164+
finally to `en`.
165+
166+
The consequence worth stating plainly: **a project that does not do i18n
167+
reports nothing.** No `i18n` block and no bundles means one active locale, the
168+
default one, and your inline `label:` is already that locale's text — so there
169+
is no gap to report and no need to reach for `--skip-i18n`. The same holds if
170+
your source language isn't English: declare `defaultLocale: 'zh-CN'` and the
171+
tooling stops asking for English you never promised.
172+
173+
Translating is therefore opt-in, but once you opt in it covers the **whole**
174+
declared surfaceevery row of the table above, including action labels
175+
declared inline on an object. That surface used to be narrower than what
176+
`os i18n extract` would scaffold, which is how untranslated approval buttons
177+
shipped without any lint noticing (#3370).
178+
154179
The two gates answer different questions, and you want both. `os i18n check`
155180
asks *are the strings translated?*a coverage number about human work.
156181
`os i18n extract --check` asks *are the generated bundles still what the schema

packages/cli/src/commands/i18n/check.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -34,11 +34,12 @@ export default class I18nCheck extends Command {
3434
static override flags = {
3535
json: Flags.boolean({ description: 'Output as JSON' }),
3636
'default-locale': Flags.string({
37-
description: 'Locale that must be 100% translated (errors raised against it)',
38-
default: 'en',
37+
description:
38+
"Locale that must be 100% translated (errors raised against it). Defaults to the config's i18n.defaultLocale, else 'en'.",
3939
}),
4040
locales: Flags.string({
41-
description: 'Comma-separated list of locales to check (default: every locale found)',
41+
description:
42+
'Comma-separated list of locales to check (default: the config\'s i18n.supportedLocales, else every locale found)',
4243
}),
4344
strict: Flags.boolean({
4445
description: 'Treat missing keys in non-default locales as errors (CI parity gate)',

packages/cli/src/commands/i18n/extract.ts

Lines changed: 37 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -55,11 +55,11 @@ export default class I18nExtract extends Command {
5555
static override flags = {
5656
json: Flags.boolean({ description: 'Output JSON instead of writing files' }),
5757
'default-locale': Flags.string({
58-
description: 'Locale filled from schema labels',
59-
default: 'en',
58+
description: "Locale filled from schema labels. Defaults to the config's i18n.defaultLocale, else 'en'.",
6059
}),
6160
locales: Flags.string({
62-
description: 'Comma-separated list of locales to emit (always includes default-locale)',
61+
description:
62+
"Comma-separated list of locales to emit (always includes default-locale). Defaults to the config's i18n.supportedLocales.",
6363
}),
6464
fill: Flags.string({
6565
description: 'How non-default locales are filled: empty | default | todo',
@@ -81,6 +81,12 @@ export default class I18nExtract extends Command {
8181
default: true,
8282
allowNo: true,
8383
}),
84+
'metadata-forms': Flags.boolean({
85+
description:
86+
'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.',
87+
default: true,
88+
allowNo: true,
89+
}),
8490
'dry-run': Flags.boolean({
8591
description: 'Print to stdout instead of writing to --out',
8692
default: false,
@@ -106,12 +112,25 @@ export default class I18nExtract extends Command {
106112

107113
const normalized = normalizeStackInput(config as Record<string, unknown>);
108114
const filter = flags.filter ? new RegExp(flags.filter) : undefined;
115+
// The stack's own `i18n` block already names the languages it ships, so
116+
// scaffolding those by default saves repeating them on every invocation.
117+
const declared = (normalized as { i18n?: { defaultLocale?: unknown; supportedLocales?: unknown } }).i18n;
118+
const declaredLocales = Array.isArray(declared?.supportedLocales)
119+
? declared.supportedLocales.filter((l): l is string => typeof l === 'string' && l.length > 0)
120+
: [];
109121
const locales = flags.locales
110122
? flags.locales.split(',').map((s) => s.trim()).filter(Boolean)
111-
: undefined;
123+
: declaredLocales.length > 0
124+
? declaredLocales
125+
: undefined;
126+
const defaultLocale =
127+
flags['default-locale'] ??
128+
(typeof declared?.defaultLocale === 'string' && declared.defaultLocale.length > 0
129+
? declared.defaultLocale
130+
: 'en');
112131

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

132161
if (flags.json) {
133162
console.log(JSON.stringify({
@@ -169,7 +198,7 @@ export default class I18nExtract extends Command {
169198
locale,
170199
objectsOnly,
171200
}));
172-
if (metadataFormsCounts[locale] > 0) {
201+
if (emitsMetadataForms(locale)) {
173202
console.log(chalk.dim(`── ${locale} (metadataForms) ──`));
174203
console.log(renderTranslationModule(result.bundles[locale], {
175204
locale,
@@ -195,7 +224,7 @@ export default class I18nExtract extends Command {
195224
keys: result.counts[locale],
196225
});
197226
}
198-
if (metadataFormsCounts[locale] > 0) {
227+
if (emitsMetadataForms(locale)) {
199228
emitted.push({
200229
file: path.join(outDir, `${locale}.metadata-forms.generated.ts`),
201230
content: renderTranslationModule(result.bundles[locale], { locale, kind: 'metadataForms' }),
@@ -222,7 +251,7 @@ export default class I18nExtract extends Command {
222251
console.log('');
223252
printError(
224253
'Translation bundles have drifted from the schema. Regenerate and commit:\n' +
225-
` os i18n extract ${args.config ?? ''} --locales=${localesEmitted.filter((l) => l !== flags['default-locale']).join(',')} ` +
254+
` os i18n extract ${args.config ?? ''} --locales=${localesEmitted.filter((l) => l !== defaultLocale).join(',')} ` +
226255
`--fill=${flags.fill} --out=${flags.out}`.replace(/\s+/g, ' '),
227256
);
228257
process.exit(1);

packages/cli/src/commands/lint.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -604,8 +604,8 @@ export default class Lint extends Command {
604604
description: 'Treat missing translations in non-default locales as errors',
605605
}),
606606
'default-locale': Flags.string({
607-
description: 'Default locale for i18n coverage (must be 100% translated)',
608-
default: 'en',
607+
description:
608+
"Default locale for i18n coverage (must be 100% translated). Defaults to the config's i18n.defaultLocale, else 'en'.",
609609
}),
610610
};
611611

@@ -645,6 +645,11 @@ export default class Lint extends Command {
645645
}
646646

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

0 commit comments

Comments
 (0)