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
10 changes: 10 additions & 0 deletions .changeset/25922-studio-i18n-table-retired.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
---
'@object-ui/i18n': patch
---

docs(i18n): the `studio` namespace is the designer strings' only home

Clause-②: no
Metadata-protocol: none

- **Last slice of the move ruled on objectstack#25922** (6431032267, Q2 = A; kept by 6431234244). `@object-ui/app-shell`'s private designer table, empty since the previous slice, is deleted, and its `t()` / `tOptional()` read `@object-ui/i18n/namespaces/studio` alone. Every string reads the same as before in every language. The README and the namespace's doc comments now say so, and point at `src/__tests__/studio-namespace.test.ts` as the namespace's one parity check, since the paired-table gate `check:i18n-designer-parity` is retired with the table. No export or string changes.
3 changes: 0 additions & 3 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -3539,9 +3539,6 @@ jobs:
- name: Verify changed en strings were followed by the nine translations
run: pnpm --dir ui run check:i18n-drift

- name: Verify the metadata-admin designer string tables stay paired
run: pnpm --dir ui run check:i18n-designer-parity

- name: Type-check the ui scripts
run: pnpm --dir ui run type-check:scripts

Expand Down
1 change: 0 additions & 1 deletion ui/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,6 @@
"check:i18n-keys": "node scripts/check-i18n-call-site-keys.mjs",
"check:i18n-drift": "node scripts/check-i18n-en-drift.mjs",
"check:i18n-dead-keys": "node scripts/check-i18n-dead-keys.mjs",
"check:i18n-designer-parity": "node scripts/check-i18n-designer-table-parity.mjs",
"check:skills-paths": "node scripts/check-skills-paths.mjs",
"check:skill-examples": "node scripts/check-skill-examples.mjs",
"check:skill-eval-tokens": "node scripts/check-skill-eval-tokens.mjs",
Expand Down
54 changes: 22 additions & 32 deletions ui/packages/app-shell/src/views/metadata-admin/i18n.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,24 +44,26 @@
* 6431234244) reopened the objectui#4662 carve-out: this table becomes the
* `studio` namespace of `@object-ui/i18n` (`packages/i18n/src/namespaces/studio/`),
* one lazy namespace carrying the existing `en` and `zh` text, every other
* language falling back to `en`. It moves in slices. Until the last one, a key
* lives either here or there, never both, and every lookup below asks this
* table first and the namespace second (`lookupString`), so no call site
* changes. `packages/i18n/README.md`, section "Scope — the Studio designer
* strings", is the conventions-side record; keep the two in step.
* language falling back to `en`. It moved in slices, and the last one (S7)
* deleted the engine-string table from this module: every designer string now
* answers from the namespace alone (`lookupString`), with no call site changed.
* `packages/i18n/README.md`, section "Scope — the Studio designer strings", is
* the conventions-side record; keep the two in step.
*
* What has not changed for the keys still here: only `en` and `zh` exist, so
* the other eight shipped locales render English on these screens, and the
* pack gates do not read this module (`scripts/check-i18n-call-site-keys.mjs`
* skips it by declaration). `check:i18n-designer-parity` reads both homes.
* Only `en` and `zh` exist, so the other eight shipped locales render English
* on these screens, and the pack gates do not read the namespace
* (`scripts/check-i18n-call-site-keys.mjs` skips this module by declaration).
* The namespace's own parity is `packages/i18n/src/__tests__/studio-namespace.test.ts`;
* the type and domain label tables below keep theirs in
* `ui/scripts/__tests__/check-i18n-dead-keys.test.ts`.
*/

import { useObjectTranslation } from '@object-ui/i18n';
// The designer strings' new home. Its `en` catalogue is resident; its other
// catalogues are fetched on demand, and `useMetadataLocale()` re-renders its
// caller when one lands. ⚠️ `ui/scripts/check-i18n-dead-keys.mjs` reads the
// namespace's catalogues as part of this table BECAUSE this module imports it:
// drop the import and the gates stop reading them.
// namespace's catalogues as this module's engine strings BECAUSE this module
// imports it: drop the import and the gates stop reading them.
import { studioString, useStudioCatalogue } from '@object-ui/i18n/namespaces/studio';
// Type-only (erased at build): the overlay-scope and lock-state label tables
// below are keyed by their producer's own union rather than by hand — see
Expand Down Expand Up @@ -191,16 +193,6 @@ const DOMAIN_LABELS_ZH: Record<string, string> = {
other: '其他',
};

const ENGINE_STRINGS_EN: Record<string, string> = {
// Empty since #25922 slice S6b: every row lives in `@object-ui/i18n`'s
// `studio` namespace. Kept until slice S7 retires the table.
};

const ENGINE_STRINGS_ZH: Record<string, string> = {
// Empty since #25922 slice S6b: every row lives in `@object-ui/i18n`'s
// `studio` namespace. Kept until slice S7 retires the table.
};

/** Whether a locale resolves to Chinese (the only non-English catalog). */
export function isZhLocale(locale?: SupportedLocale | string): boolean {
return (locale ?? '').toLowerCase().startsWith('zh');
Expand Down Expand Up @@ -515,27 +507,25 @@ export function flowFieldZh(type: string, id: string): FlowFieldZh | undefined {

function pickTable(
locale: SupportedLocale | string | undefined,
): { types: Record<string, string>; domains: Record<string, string>; strings: Record<string, string> } {
): { types: Record<string, string>; domains: Record<string, string> } {
const lower = (locale ?? '').toLowerCase();
if (lower.startsWith('zh')) {
return { types: TYPE_LABELS_ZH, domains: DOMAIN_LABELS_ZH, strings: ENGINE_STRINGS_ZH };
return { types: TYPE_LABELS_ZH, domains: DOMAIN_LABELS_ZH };
}
return { types: TYPE_LABELS_EN, domains: DOMAIN_LABELS_EN, strings: ENGINE_STRINGS_EN };
return { types: TYPE_LABELS_EN, domains: DOMAIN_LABELS_EN };
}

/**
* The value of one designer string: this module's own table for the locale
* first, then the `studio` namespace in `@object-ui/i18n`, which the table is
* moving into slice by slice (objectstack#25922, ruling 6431032267 Q2 = A).
* A key lives in exactly one of the two homes — `check:i18n-designer-parity`
* fails on a key in both — so the order decides nothing but cost.
* The value of one designer string, from the `studio` namespace in
* `@object-ui/i18n` (objectstack#25922, ruling 6431032267 Q2 = A), which holds
* every row this module's own table used to.
*
* The namespace answers a language it has no catalogue for, and a catalogue
* still being fetched, from its `en` catalogue; this table keeps its own
* behaviour for the keys it still holds (a key its zh half lacks echoes back).
* still being fetched, from its `en` catalogue; a zh-only row (a flow node
* label, an enum value, a package form help text) is `undefined` outside zh.
*/
function lookupString(key: string, locale: SupportedLocale | string | undefined): string | undefined {
return pickTable(locale).strings[key] ?? studioString(key, locale);
return studioString(key, locale);
}

export function translateMetadataType(
Expand Down
Original file line number Diff line number Diff line change
@@ -1,19 +1,20 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* objectstack#25922 slices S2 to S6b — the designer rows that moved into
* objectstack#25922 slices S2 to S7 — the designer rows that moved into
* `@object-ui/i18n`'s `studio` namespace (every `engine.inspector.*` row;
* `engine.packages.*`, `engine.form.*`, `engine.edit.*`; all of
* `engine.studio.*`; the data source pages, the field types, the metadata
* preview panes, the flow designer, the rest of the Studio chrome and, last,
* the translation preview), and
* app-shell's `t()` / `tOptional()` still answer every one of them the way they
* did when the rows sat in its own table.
* did when the rows sat in its own table. Slice S7 deleted that table and the
* table-first lookup, so the namespace is the only home.
*
* What would break it:
* - a row copied back into the app-shell table with a different value — the
* table is asked first, so it would shadow the namespace silently;
* - the namespace fallback dropped from `lookupString` — every moved key
* - a lookup asked in front of the namespace again (a table brought back
* with a row whose value differs) — it would shadow the namespace silently;
* - the namespace dropped from `lookupString` — every moved key
* would echo back as itself;
* - a family emptied out of the namespace, which every per-key assertion
* below would then pass over nothing. The floors are per FAMILY, by name,
Expand All @@ -23,6 +24,9 @@
* `loadStudioCatalogue` (the module's public door), not a copy of their text.
*/
import { describe, expect, it } from 'vitest';
import { readFileSync } from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { loadStudioCatalogue } from '@object-ui/i18n/namespaces/studio';
import { t, tOptional } from '../i18n';

Expand Down Expand Up @@ -134,15 +138,49 @@ const FAMILY_FLOOR: Readonly<Record<string, number>> = Object.freeze({
* carry. The package form's help text reads its English from the producer's own
* `.describe()` sentence; the flow node labels and hints and the flow enum
* values read theirs from the engine descriptor or the value itself. Each is
* read through `tOptional` or a `lookupString` fallback (`ZH_ONLY_FAMILIES` in
* `ui/scripts/check-i18n-designer-table-parity.mjs` carries the reasons).
* read through `tOptional` or a `lookupString` fallback; `ZH_ONLY_REASONS`
* below carries the reasons.
*/
const ZH_ONLY_FLOOR: Readonly<Record<string, number>> = Object.freeze({
'engine.packages.form.help.': 9,
'engine.flowNode.': 40,
'engine.enum.': 4,
});

/**
* Why each zh-only family has no `en` row, and the reader that says so. Moved
* here from the retired `check:i18n-designer-parity` gate with slice S7. A
* citation is `{ file, anchor }`, the anchor is TEXT and never a line number,
* and it must occur exactly once in its file or it stops locating anything.
*/
const ZH_ONLY_REASONS = Object.freeze([
{
prefix: 'engine.flowNode.',
reason:
'zh-only by design: for English the flow palette falls back to the engine descriptor’s own ' +
'server-authoritative name/description, so an `en` row would OVERRIDE the server',
file: '../i18n.ts',
anchor: 'resolved by translateNodeLabel/Hint',
},
{
prefix: 'engine.enum.type.',
reason:
'zh-only by construction: translateEnumOption returns the raw value before it ever looks the ' +
'key up for any non-zh locale, so an `en` row is unreachable',
file: '../i18n.ts',
anchor: 'export function translateEnumOption',
},
{
prefix: 'engine.packages.form.help.',
reason:
'zh-only on purpose: with no `en` row tOptional returns undefined, helpText drops out, and the row ' +
'falls back to ManifestSchema’s .describe() in @objectstack/spec — an `en` row would copy that ' +
'text into a second producer, against AGENTS.md #0.1',
file: '../package-schema.ts',
anchor: 'en-US deliberately has NO entry',
},
]);

const familyKeys = (prefix: string): string[] => Object.keys(EN).filter((key) => key.startsWith(prefix));

describe('moved designer rows answer from the studio namespace (objectstack#25922)', () => {
Expand Down Expand Up @@ -173,3 +211,45 @@ describe('moved designer rows answer from the studio namespace (objectstack#2592
expect(keys.filter((key) => tOptional(key, 'en-US') !== undefined)).toEqual([]);
});
});

describe('the namespace is the only home (objectstack#25922 slice S7)', () => {
const here = path.dirname(fileURLToPath(import.meta.url));
const zhOnly = Object.keys(ZH).filter((key) => !(key in EN));

it('every namespace key answers through t() and tOptional() with its catalogue value', () => {
// Every key, not a family sample: with the table gone nothing else may
// answer, so a lookup in front of the namespace shows up here by key.
expect(Object.keys(EN).length).toBeGreaterThan(3000);
const wrong = Object.keys(EN).flatMap((key) => {
const out: string[] = [];
const zh = ZH[key] ?? EN[key];
for (const locale of ['en-US', 'en', 'de-DE', undefined]) {
if (t(key, locale) !== EN[key]) out.push(`t ${locale} ${key}`);
if (tOptional(key, locale) !== EN[key]) out.push(`tOptional ${locale} ${key}`);
}
for (const locale of ['zh-CN', 'zh', 'zh-TW']) {
if (t(key, locale) !== zh) out.push(`t ${locale} ${key}`);
if (tOptional(key, locale) !== zh) out.push(`tOptional ${locale} ${key}`);
}
return out;
});
expect(wrong).toEqual([]);
});

it('the zh-only rows are the documented families, and answer in zh alone', () => {
// 54 at the move (9 package form help rows, 41 flow node rows, 4 flow enum
// values); all of them belong to a family with a reason below.
expect(zhOnly.length).toBeGreaterThanOrEqual(50);
expect(zhOnly.filter((key) => !ZH_ONLY_REASONS.some((f) => key.startsWith(f.prefix)))).toEqual([]);
const wrong = zhOnly.flatMap((key) => [
...['en-US', 'en', 'de-DE', undefined].filter((locale) => tOptional(key, locale) !== undefined).map((l) => `en-side ${l} ${key}`),
...['zh-CN', 'zh', 'zh-TW'].filter((locale) => tOptional(key, locale) !== ZH[key]).map((l) => `zh-side ${l} ${key}`),
]);
expect(wrong).toEqual([]);
});

it.each(ZH_ONLY_REASONS.map((f) => [f.prefix, f] as const))('%s: its citation anchor occurs exactly once', (_prefix, family) => {
const text = readFileSync(path.join(here, family.file), 'utf8');
expect(text.split(family.anchor).length - 1).toBe(1);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -508,9 +508,9 @@ describe('placeholders that must NOT be translated (#3979)', () => {
* own chrome key list rather than hand-copying it.
*
* Since objectstack#25922 slice S2 the prefix lives in the `studio` namespace
* of `@object-ui/i18n` (`packages/i18n/src/namespaces/studio/{en,zh}.ts`), so
* a locale's key set is read from BOTH homes — the app-shell table and the
* namespace catalogue — and a key is retired only when it has left both.
* of `@object-ui/i18n` (`packages/i18n/src/namespaces/studio/{en,zh}.ts`), and
* since slice S7 the app-shell table is gone, so a locale's key set is read
* from its namespace catalogue alone.
*
* Measured on this checkout: every one of the 171 `engine.inspector.pageBlock.*`
* keys in each table is covered by one of those two, and no key is constructed
Expand Down Expand Up @@ -565,13 +565,10 @@ interface EngineStringTable {
name: string;
/** Every key the table declares, in file order. */
keys: string[];
/** 1-based line range of the object literal, for the disjointness guard. */
from: number;
to: number;
}

/**
* The KEYS of one `ENGINE_STRINGS_*` table, read out of the source by LINE
* The KEYS of one `STUDIO_*` catalogue, read out of the source by LINE
* RANGE rather than by scanning the whole file.
*
* Table membership is the whole question here: a key that MOVED from one locale
Expand All @@ -592,7 +589,7 @@ function engineStringTable(source: string, name: string): EngineStringTable {
const m = /^ {2}'((?:[^'\\]|\\.)*)':/.exec(lines[i]);
if (m) keys.push(m[1]);
}
return { name, keys, from: open + 1, to: close + 1 };
return { name, keys };
}

/**
Expand Down Expand Up @@ -637,17 +634,8 @@ function shippedLiteralKeys(): Map<string, string[]> {
}

const I18N_SOURCE = readFileSync(I18N_SOURCE_PATH, 'utf8');
const EN_TABLE = engineStringTable(I18N_SOURCE, 'ENGINE_STRINGS_EN');
const ZH_TABLE = engineStringTable(I18N_SOURCE, 'ENGINE_STRINGS_ZH');
const EN_HOME = engineStringTable(readFileSync(STUDIO_HOME_PATH.en, 'utf8'), 'STUDIO_EN');
const ZH_HOME = engineStringTable(readFileSync(STUDIO_HOME_PATH.zh, 'utf8'), 'STUDIO_ZH');

/** One locale's keys across both homes: the app-shell table and the namespace. */
function bothHomes(table: EngineStringTable, home: EngineStringTable): EngineStringTable {
return { name: `${table.name} + ${home.name}`, keys: [...table.keys, ...home.keys], from: table.from, to: table.to };
}
const EN_KEYS = bothHomes(EN_TABLE, EN_HOME);
const ZH_KEYS = bothHomes(ZH_TABLE, ZH_HOME);
const EN_KEYS = engineStringTable(readFileSync(STUDIO_HOME_PATH.en, 'utf8'), 'STUDIO_EN');
const ZH_KEYS = engineStringTable(readFileSync(STUDIO_HOME_PATH.zh, 'utf8'), 'STUDIO_ZH');

/** The keys `SITES` implies — the complete BLOCK_CONFIG-derived vocabulary. */
const DERIVED = new Set(SITES.map((s) => s.derived));
Expand All @@ -673,26 +661,15 @@ function pageBlockKeys(table: EngineStringTable): string[] {
}

describe('a retired key leaves BOTH locale tables (objectui#8368)', () => {
it('reads two disjoint tables out of i18n.ts, both emptied into the namespace', () => {
it('reads both locale catalogues out of the studio namespace, the app-shell table retired', () => {
// The extractor is a source parser, so its silent failure mode is finding
// FEWER keys — which would make every assertion below vacuously green over
// an empty corpus. Ranges first: overlapping ones would mean the `};`
// scan ran past a table and both sets became the same set.
// The table drained slice by slice into the namespace (objectstack#25922)
// and is empty since slice S6b: a key back in it would shadow the
// namespace's copy. The 3,000-key floor, held over both homes together
// below, is what keeps an empty table from reading as a parser gone quiet.
expect(EN_TABLE.to).toBeLessThan(ZH_TABLE.from);
expect(EN_TABLE.keys).toEqual([]);
expect(ZH_TABLE.keys).toEqual([]);
// an empty corpus. The table drained slice by slice into the namespace
// (objectstack#25922) and slice S7 deleted it: i18n.ts declaring one again
// would be a second home this census cannot see.
expect(I18N_SOURCE).not.toMatch(/\bconst ENGINE_STRINGS_/);
// No duplicates: a key declared twice would be a silent overwrite, and the
// set arithmetic below would hide it.
expect(EN_TABLE.keys.length).toBe(new Set(EN_TABLE.keys).size);
expect(ZH_TABLE.keys.length).toBe(new Set(ZH_TABLE.keys).size);
// The namespace homes are read the same way, and a key in both homes of
// one locale would be counted twice below.
expect(EN_HOME.keys.length).toBeGreaterThan(150);
expect(ZH_HOME.keys.length).toBeGreaterThan(150);
expect(EN_KEYS.keys.length).toBe(new Set(EN_KEYS.keys).size);
expect(ZH_KEYS.keys.length).toBe(new Set(ZH_KEYS.keys).size);
expect(EN_KEYS.keys.length).toBeGreaterThan(3000);
Expand Down
Loading
Loading