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
226 changes: 207 additions & 19 deletions .github/scripts/check-translation-output.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,12 @@ const LENGTH_TOLERANCE = 0.4;
*/
const LOCALE_EXPANSION = {
'zh-Hans': 0.54,
// Measured against its zh-Hans SOURCE, not against English — see
// `DERIVED_FROM`. Traditional and Simplified are the same text in two
// orthographies, so a correct conversion is 1:1 in characters and the band
// this produces is tight on purpose: it is a check on the converter, not on
// a translator.
'zh-Hant': 1.00,
ja: 0.69,
ko: 0.67,
de: 1.10,
Expand Down Expand Up @@ -153,6 +159,49 @@ function walk(dir, out = []) {
const localeOf = (f) => LOCALES.find((l) => f.endsWith(`.${l}.mdx`)) ?? null;
const englishOf = (f, l) => `${f.slice(0, -`.${l}.mdx`.length)}.mdx`;

/**
* Locales DERIVED FROM ANOTHER LOCALE rather than translated from English, and
* the locale each one is derived from.
*
* Every rule below asks "is this file faithful to the thing it was produced
* from?", and until `zh-Hant` shipped that thing was always the English
* sibling. It is not, for a locale generated by
* `apps/docs/scripts/gen-zh-hant.mjs`: Traditional Chinese is produced from the
* SIMPLIFIED page by an orthographic conversion, so the English page is two
* derivations away and comparing against it measures the wrong pair.
*
* That is not a nicety. Measured on the tree this landed on, comparing the 62
* generated Traditional pages against English produced 31 blocking findings,
* every one of them a verbatim re-report of a finding already open against the
* Simplified page it was converted from — debt that is unfixable in the file
* being accused, because the file is generated and the fix belongs upstream.
* A gate that accuses a file nobody can edit for a defect it did not introduce
* is a gate people learn to override.
*
* Compared against its real source the same rules become a genuine check on
* the CONVERTER, and a strict one: the generator copies fenced code blocks
* through byte for byte and rewrites only Han characters outside them, so
* `fence`, `url`, `frontmatter` and `component` must all match the Simplified
* source exactly. A converter that started rewriting a code sample, dropping a
* link or losing a frontmatter key goes red here.
*
* `unsafe` is untouched by any of this — it needs no sibling, runs over the
* whole corpus, and is the one rule that is never scoped.
*/
const DERIVED_FROM = { 'zh-Hant': 'zh-Hans' };

/**
* The file a locale file must be faithful to: its source-locale sibling for a
* derived locale, the English sibling for a translated one.
*/
function sourceOf(f, l) {
const from = DERIVED_FROM[l];
return from ? `${f.slice(0, -`.${l}.mdx`.length)}.${from}.mdx` : englishOf(f, l);
}

/** How a finding names the thing a file was compared against. */
const sourceLabel = (l) => (DERIVED_FROM[l] ? `${DERIVED_FROM[l]} source` : 'English source');

/* ---------------------------------------------------------------- parsing --
* All of it text-level and deliberately small. See the zero-dependency note in
* the header: this has to run with nothing installed.
Expand Down Expand Up @@ -396,32 +445,37 @@ function checkUnsafe(path, text) {
return found;
}

/** Fidelity rules. Compare a translation against its English sibling. */
function checkFidelity(localePath, enPath, locale) {
/**
* Fidelity rules. Compare a locale file against the file it was produced from —
* the English sibling for a translated locale, the source-locale sibling for a
* derived one (`DERIVED_FROM`).
*/
function checkFidelity(localePath, sourcePath, locale) {
const t = readFileSync(localePath, 'utf8');
const e = readFileSync(enPath, 'utf8');
const e = readFileSync(sourcePath, 'utf8');
const source = sourceLabel(locale);
const file = rel(localePath);
const found = [];
const add = (rule, detail) => found.push({ rule, file, detail });

const ts = split(stripFrontmatter(t));
const es = split(stripFrontmatter(e));

// 1. fenced code blocks byte-identical to the English source
// 1. fenced code blocks byte-identical to the source
if (ts.fences.length !== es.fences.length) {
add('fence', `${ts.fences.length} code fence(s), English has ${es.fences.length}`);
add('fence', `${ts.fences.length} code fence(s), ${source} has ${es.fences.length}`);
} else {
for (let i = 0; i < es.fences.length; i += 1) {
if (ts.fences[i] !== es.fences[i]) {
add('fence', `code fence #${i + 1} differs from the English source`);
add('fence', `code fence #${i + 1} differs from the ${source}`);
break;
}
}
}

// 2. the page's URL set is a subset of the English page's
// 2. the page's URL set is a subset of its source page's
const extra = [...urls(ts.prose)].filter((u) => !urls(es.prose).has(u));
if (extra.length) add('url', `link(s) the English page does not have: ${extra.join(', ')}`);
if (extra.length) add('url', `link(s) the ${source} does not have: ${extra.join(', ')}`);

// 3. frontmatter keys match exactly (values are translated, keys are not)
const tf = frontmatter(t);
Expand Down Expand Up @@ -466,7 +520,7 @@ function checkFidelity(localePath, enPath, locale) {
if (ratio < lo || ratio > hi) {
add(
'length',
`prose is ${ratio.toFixed(2)}x the English page; ${locale} translations run ${expected}x ` +
`prose is ${ratio.toFixed(2)}x the ${source}; ${locale} runs ${expected}x ` +
`(band ${lo.toFixed(2)}–${hi.toFixed(2)})`,
);
}
Expand All @@ -483,9 +537,12 @@ function survey() {
findings.push(...checkUnsafe(f, readFileSync(f, 'utf8')));
const l = localeOf(f);
if (!l) continue;
const en = englishOf(f, l);
if (!existsSync(en)) continue; // orphans are check-translations.mjs's verdict
findings.push(...checkFidelity(f, en, l));
const src = sourceOf(f, l);
// A missing source is `check-translations.mjs`'s verdict: an orphan for a
// translated locale, and for a derived one a file the generator no longer
// produces (`gen:zh-hant --check` names it).
if (!existsSync(src)) continue;
findings.push(...checkFidelity(f, src, l));
}
return { files: all.length, findings };
}
Expand Down Expand Up @@ -596,11 +653,13 @@ function calibrate() {
for (const f of walk(DOCS).sort()) {
const l = localeOf(f);
if (!l) continue;
const en = englishOf(f, l);
if (!existsSync(en)) continue;
const enLen = proseLength(readFileSync(en, 'utf8'));
if (!enLen) continue;
(byLocale[l] ??= []).push(proseLength(readFileSync(f, 'utf8')) / enLen);
// The same base the gate uses. Calibrating a derived locale against English
// would print a number the `length` rule never compares anything to.
const src = sourceOf(f, l);
if (!existsSync(src)) continue;
const srcLen = proseLength(readFileSync(src, 'utf8'));
if (!srcLen) continue;
(byLocale[l] ??= []).push(proseLength(readFileSync(f, 'utf8')) / srcLen);
}
console.log('Median prose-length ratio per locale (paste into LOCALE_EXPANSION):');
console.log('');
Expand Down Expand Up @@ -882,6 +941,129 @@ const SPLIT_CASES = [
{ name: 'comment closing with a space before the brace', doc: 'a {/* c */ } b', comments: ['{/* c */ }'], prose: 'a b' },
];

/**
* Cases for a DERIVED locale (`DERIVED_FROM`), which the two-file harness above
* cannot express: the whole point is which of THREE files the comparison picks.
*
* The fixture reproduces the shape that made this necessary. The source-locale
* page carries a code fence that differs from the English one — the corpus has
* 30 such pages — and the derived page is a faithful conversion of it. Case 1
* asserts both halves of that: silent against its real source, and RED against
* English. Without the second assertion the case would be green for a
* validator that had simply stopped checking derived files at all.
*/
const DERIVED_CASES = [
{
name: 'faithful conversion of a source whose fence differs from English',
expect: [],
alsoRedAgainstEnglish: ['fence'],
},
{
name: 'converter edited a code fence',
expect: ['fence'],
to: (s) => s.replace(SOURCE_FENCE_CMD, `${SOURCE_FENCE_CMD}-v2`),
},
{
name: 'converter invented a link the source does not have',
expect: ['url'],
to: (s) => s.replace('](/docs/configure/permissions)', '](https://example.invalid/zh)'),
},
{
name: 'converter dropped a frontmatter key',
expect: ['frontmatter'],
to: (s) => s.replace(/^description: .*\n/m, ''),
},
{
name: 'converter truncated the page',
expect: ['length'],
to: (s) => s.slice(0, Math.floor(s.length / 2)),
},
];

/** `@generated` marker the generator stamps, as a frontmatter YAML comment. */
const DERIVED_MARKER = '# @generated -- do not edit.';

/**
* The command line inside the SOURCE fixture's fence. Named rather than spelled
* twice: the source deliberately differs from English here, so a case that
* mutated the English spelling would be a no-op replace — a fixture that
* changes nothing and reports green. (It was, on the first run of this block.)
*/
const SOURCE_FENCE_CMD = 'os lint --rule sicherheit-rolle-wort';

/**
* Run `DERIVED_CASES` for every entry in `DERIVED_FROM`, and assert the map
* itself is consistent with `i18n.ts`. A `DERIVED_FROM` entry naming a locale
* that is no longer declared would silently stop being exercised, which is the
* shape of dead configuration this file's own header argues against.
*/
function derivedTest() {
let failed = 0;
for (const [derived, from] of Object.entries(DERIVED_FROM)) {
for (const [what, l] of [['derived', derived], ['source', from]]) {
if (!LOCALES.includes(l)) {
console.error(`✗ DERIVED_FROM names a ${what} locale absent from i18n.ts: "${l}"`);
failed += 1;
}
}
if (LOCALE_EXPANSION[derived] === undefined) {
console.error(`✗ derived locale "${derived}" has no LOCALE_EXPANSION entry — length is never judged`);
failed += 1;
}
if (!LOCALES.includes(derived) || !LOCALES.includes(from)) continue;

const dir = mkdtempSync(join(tmpdir(), 'translation-derived-'));
const en = join(dir, 'page.mdx');
const src = join(dir, `page.${from}.mdx`);
const out = join(dir, `page.${derived}.mdx`);
try {
// The source page's fence deliberately differs from English: that is the
// debt the derived page must not be accused of inheriting.
const sourceText = fixtureXx(from).replace('os lint --rule security-role-word', SOURCE_FENCE_CMD);
if (!sourceText.includes(SOURCE_FENCE_CMD)) {
throw new Error('derived fixture: the source fence mutation matched nothing');
}
writeFileSync(en, fixtureEn());
writeFileSync(src, sourceText);

for (const c of DERIVED_CASES) {
// A conversion changes characters, not structure, so the derived
// fixture starts as a byte copy of its source plus the marker.
const base = sourceText.replace(/^---\n/, `---\n${DERIVED_MARKER}\n`);
writeFileSync(out, (c.to ?? ((s) => s))(base));

const fired = [...new Set(checkFidelity(out, src, derived).map((f) => f.rule))].sort();
const want = [...c.expect].sort();
let ok = fired.join(',') === want.join(',');

// The other half of case 1: prove the fixture would have been red the
// old way, so its green is a measurement and not a vacuum.
if (c.alsoRedAgainstEnglish) {
const vsEn = [...new Set(checkFidelity(out, en, derived).map((f) => f.rule))]
.filter((r) => c.alsoRedAgainstEnglish.includes(r))
.sort();
if (vsEn.join(',') !== [...c.alsoRedAgainstEnglish].sort().join(',')) {
console.error(
` against English this fixture fired [${vsEn.join(' ') || '—'}], ` +
`expected [${c.alsoRedAgainstEnglish.join(' ')}] — it proves nothing`,
);
ok = false;
}
}

if (!ok) failed += 1;
console.log(
`${ok ? '✓' : '✗'} derived ${derived}: ${c.name.padEnd(56)} fired [${fired.join(' ') || '—'}]` +
(ok ? '' : ` expected [${want.join(' ') || '—'}]`),
);
}
} finally {
rmSync(dir, { recursive: true, force: true });
}
}
return failed;
}

function selfTest() {
const locale = LOCALES.find((l) => LOCALE_EXPANSION[l] !== undefined);
if (!locale) throw new Error('no calibrated locale in i18n.ts — cannot self-test the length rule');
Expand Down Expand Up @@ -930,6 +1112,9 @@ function selfTest() {
}
}

console.log('');
failed += derivedTest();

console.log('');
const covered = new Set(CASES.flatMap((c) => c.expect));
for (const rule of RULES) {
Expand All @@ -949,9 +1134,12 @@ function selfTest() {
console.error(`\n✗ self-test: ${failed} case(s) did not behave as declared`);
process.exit(1);
}
const derivedCount = Object.keys(DERIVED_FROM).length * DERIVED_CASES.length;
console.log(
`✓ self-test: ${CASES.length} rule case(s) and ${SPLIT_CASES.length} split case(s) on locale ` +
`"${locale}" — every rule demonstrated able to fail, every prose rule demonstrated able to stay silent`,
`✓ self-test: ${CASES.length} rule case(s), ${SPLIT_CASES.length} split case(s) and ` +
`${derivedCount} derived-locale case(s) on locale "${locale}" — every rule demonstrated able ` +
`to fail, every prose rule demonstrated able to stay silent, and every derived locale ` +
`demonstrated to be compared against its source rather than English`,
);
}

Expand Down
16 changes: 16 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,22 @@ jobs:
node-version: 22
cache: pnpm
- run: pnpm install --frozen-lockfile

# `content/docs/**/*.zh-Hant.mdx` and `meta.zh-Hant.json` are generated
# from the Simplified siblings by `apps/docs/scripts/gen-zh-hant.mjs` and
# committed, because `lib/seo.ts` tells a real translation from an English
# fallback by the presence of a locale-suffixed FILE — a conversion done
# while rendering would leave the locale out of every sitemap entry and
# hreflang cluster. Committed output needs a gate or it drifts: this
# regenerates in memory and compares bytes, so a hand edit, a stale file
# whose source was retired, and a converter upgrade nobody re-ran all fail
# here with the same one-line fix.
#
# Before `type-check` on purpose: it needs no build, and a content drift
# reported as a type error is a wrong first diagnosis.
- name: Generated zh-Hant is current
run: node apps/docs/scripts/gen-zh-hant.mjs --check

- run: pnpm turbo run type-check --continue
- run: pnpm turbo run build

Expand Down
31 changes: 29 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ ObjectOS — the commercial runtime environment for ObjectStack applications (Cl

### 1. English is the single source of truth for all content

All marketing copy, UI strings, and documentation are authored in **English first**. Every other locale (`zh-Hans`, `ja`, `de`, `es`, `fr`, `ko`) is a **translation derived from English**.
All marketing copy, UI strings, and documentation are authored in **English first**. Every other locale (`zh-Hans`, `ja`, `de`, `es`, `fr`, `ko`) is a **translation derived from English**. `zh-Hant` is derived one step further — it is generated from `zh-Hans` by `apps/docs/scripts/gen-zh-hant.mjs`, never translated and never hand-written (see [Traditional Chinese](#traditional-chinese-is-generated-not-translated)).

- **Edit English, and only English.** Translations are generated, not authored — a PR that hand-edits a locale file is rejected by CI (see [Translation workflow](#translation-workflow)).
- If a typo or wording change only appears in a translation, fix English. The translation is re-derived from it.
Expand All @@ -37,7 +37,7 @@ Stack: Next.js 16 App Router + Fumadocs UI 16 + Fumadocs MDX. Many UI affordance

### Locale conventions

- Supported locales, as BCP 47 tags — `apps/docs/lib/i18n.ts` is the authority: `en` (default), `zh-Hans`, `ja`, `de`, `es`, `fr`, `ko`.
- Supported locales, as BCP 47 tags — `apps/docs/lib/i18n.ts` is the authority: `en` (default), `zh-Hans`, `zh-Hant`, `ja`, `de`, `es`, `fr`, `ko`.
- ⛔ **There is no `cn` locale.** It is a legacy code that `middleware.ts` 308-redirects to `zh-Hans`. A file named `foo.cn.mdx` would never render **and would raise no error** — it is simply ignored, which is the worst possible failure mode for a translation. Simplified Chinese is `zh-Hans`.
- Default locale has no prefix (`/docs/...`); other locales are prefixed (`/zh-Hans/docs/...`). This is set by `hideLocale: 'default-locale'` in `lib/i18n.ts`.
- **MDX translations:** add a sibling file with the locale tag next to the English `.mdx` — `foo.zh-Hans.mdx`, `foo.ja.mdx`, and so on. Fumadocs auto-falls-back to English when a translation is missing — you can ship translations incrementally without breaking links.
Expand All @@ -48,6 +48,32 @@ Stack: Next.js 16 App Router + Fumadocs UI 16 + Fumadocs MDX. Many UI affordance

Folder `meta.json` files declare a section's title, page order, and `defaultOpen: false` to make the group collapsible and collapsed by default. The root `content/docs/meta.json` references folders by name (`"deploy"`, `"build"`, …), not by the `"...deploy"` spread + `---Deploy---` separator pattern (the old pattern produced a flat ~50-item sidebar).

### Traditional Chinese is generated, not translated

`zh-Hant` is the one locale with no translation pass behind it. Simplified and
Traditional Chinese are one language in two orthographies, so the Traditional
pages are produced from the Simplified ones by an orthographic conversion
(OpenCC `s2twp`, the same preset `www.objectos.ai` runs) and committed:

```bash
pnpm --filter @objectos/docs gen:zh-hant # rewrite the Traditional set
pnpm --filter @objectos/docs gen:zh-hant --check # what CI runs
```

- ⛔ **Never hand-edit a `*.zh-Hant.mdx` or `meta.zh-Hant.json` file**, and never
translate one from English. Fix the English source; the Simplified page is
re-derived from it and the Traditional page from that. CI runs `--check` on
every PR and fails on any byte of drift.
- Retiring a page or its Simplified sibling takes the Traditional file with it —
re-run the generator, which prunes what it no longer produces.
- Coverage tracks Simplified exactly (62 of 79 pages today). The 17 without a
Simplified sibling have no Traditional one either, so they are never
advertised in the sitemap or an hreflang cluster — they simply render English.
- Committed output, not on-the-fly conversion, because `lib/seo.ts` tells a real
translation from an English fallback by the presence of a locale-suffixed
file. A conversion done while rendering produces no such file, and the locale
would be invisible to search engines while looking correct in a browser.

### Translation workflow

**You edit English. You do not edit translations.** Every `*.<locale>.mdx` file is a derived artifact, refreshed by a separate periodic pass under [`docs/TRANSLATION.md`](docs/TRANSLATION.md). `.github/scripts/check-translation-ownership.mjs` rejects any PR that mixes the two — hand-maintained siblings used to be **86% of the diff** in a typical docs PR, which is the cost this split removes.
Expand All @@ -71,6 +97,7 @@ node .github/scripts/check-translations.mjs --worklist # what the next pass wil
- Don't set `alt="ObjectOS"` on the logo image when the adjacent text already says "ObjectOS" — screen readers read it twice. Use `alt=""` + `aria-hidden`.
- Don't add translation-only strings or files. If it doesn't have an English source, it shouldn't exist yet.
- Don't write a `.cn.mdx` sibling. That locale does not exist; the file is ignored silently. Use `.zh-Hans.mdx`.
- Don't hand-write a `.zh-Hant.mdx` sibling either. It is generated from the `.zh-Hans.mdx` file and CI fails on any hand edit.
- Don't hand-edit a `*.<locale>.mdx` file, and don't "just fix" one while you're in there. Fix the English source instead.

## Commands
Expand Down
Loading