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
2 changes: 1 addition & 1 deletion .ai/manifests/governance.json
Original file line number Diff line number Diff line change
Expand Up @@ -174,7 +174,7 @@
"file": "rules/19-logging-observability-and-redaction.md"
},
{
"bytes": 4264,
"bytes": 4914,
"file": "rules/20-i18n-and-user-facing-messages.md"
},
{
Expand Down
4 changes: 2 additions & 2 deletions .ai/manifests/hashes.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
".ai/manifests/environment-variables.json": "33602cbe",
".ai/manifests/event-graph.json": "c1cd05bc",
".ai/manifests/frontend-routes.json": "a52b7ece",
".ai/manifests/governance.json": "e4492ebd",
".ai/manifests/governance.json": "1160e343",
".ai/manifests/i18n.json": "3befb756",
".ai/manifests/nginx-routes.json": "53c65be1",
".ai/manifests/packages.json": "cc928a4a",
Expand All @@ -18,7 +18,7 @@
".ai/manifests/rabbitmq-events.json": "bcaeb411",
".ai/manifests/repository.json": "047d93d0",
".ai/manifests/services.json": "9c92d2c4",
".ai/manifests/tests.json": "4044f562",
".ai/manifests/tests.json": "6eb2e2ae",
".ai/manifests/workspace-dependency-graph.json": "80e5438b",
".ai/manifests/workspaces.json": "b5133e99",
".ai/packs/README.md": "2e64753b",
Expand Down
4 changes: 2 additions & 2 deletions .ai/manifests/tests.json
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@
},
"claw-frontend": {
"runner": "vitest",
"testFiles": 410
"testFiles": 411
},
"claw-health-service": {
"runner": "jest",
Expand Down Expand Up @@ -102,5 +102,5 @@
}
},
"generated": true,
"total": 1122
"total": 1123
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
import { describe, expect, it } from 'vitest';

import { LEARN_CONTENT_BY_LOCALE } from '@/constants/learn-content.constants';
import { Locale } from '@/enums/locale.enum';

/**
* Flattens a `LearnDictionary` into `path -> string` pairs, the same shape
* the i18n completeness tests use (see `chinese-completeness.test.ts`,
* `translations.test.ts`). Arrays (e.g. `seo.keywords`) flatten by index,
* which is exactly what parity needs: a locale with two keywords instead of
* three shows up as a missing key, not a silently shorter array.
*/
function flatten(value: unknown, prefix = '', result: Record<string, string> = {}): Record<string, string> {
if (typeof value === 'string') {
result[prefix] = value;
return result;
}
if (typeof value !== 'object' || value === null) {
return result;
}
for (const [key, child] of Object.entries(value)) {
flatten(child, prefix === '' ? key : `${prefix}.${key}`, result);
}
return result;
}

function placeholders(value: string): string[] {
return [...value.matchAll(/\{[^{}]+\}/gu)].map((match) => match[0]).sort();
}

/**
* Section anchor ids (`sections.<n>.id`) are deliberately identical across
* every locale — "Stable across locales so a translated page keeps its deep
* links" (`LearnSection` in `types/learn.types.ts`). They are structural,
* not prose, so they are excluded from the "did anyone actually translate
* this" check below, the same way a `data-testid` would be.
*/
function isStructuralKey(key: string): boolean {
return key.endsWith('.id');
}

/**
* Values that are correctly identical across every locale: proper nouns,
* acronyms, product/model names, and other technical terms nobody
* translates. Anything NOT on this list that matches English exactly is
* either an untranslated string or a coincidence rare enough to be worth a
* second look — see `chinese-completeness.test.ts` for the same pattern
* against the general UI dictionary.
*/
const LEGITIMATE_UNCHANGED_VALUES = new Set<string>([
'ClawAI',
'RAG',
'GPU',
'CPU',
'API',
'LLM',
'PDF',
'URL',
'GitHub',
'Ollama',
'llama.cpp',
'Ollama vs llama.cpp',
// French "orchestration" is spelled identically to English (it is in fact
// the older of the two — the word entered English FROM French). Not a
// missed translation: fr correctly translates the same English word to
// "Orchestrazione"/"Orchestrierung" is what it/de do, which is a different
// language having a different native form, not evidence fr is wrong.
'Orchestration',
// "Routing" is a standard, widely-used loanword in German and Italian
// technical writing (as common as "Router" itself) — this repo's own de/it
// copy elsewhere also keeps other loanwords (e.g. "GPU") rather than
// coining an unfamiliar native term. Flagged here for visibility: if a
// native speaker prefers a translated term, remove this line and fix the
// two eyebrow values it currently allows through.
'Routing',
// SEO keyword arrays intentionally keep the English query term a non-English
// searcher would actually type — "best of N" is ML/statistics jargon, not
// prose, and translating it would target a search query nobody makes.
'best of N',
]);

const english = flatten(LEARN_CONTENT_BY_LOCALE[Locale.EN]);
const nonEnglishLocales = Object.values(Locale).filter((locale) => locale !== Locale.EN);

describe('/learn content locale completeness', () => {
it.each(nonEnglishLocales)('%s defines every English key with no missing sections/faq/keywords', (locale) => {
const localized = flatten(LEARN_CONTENT_BY_LOCALE[locale]);
expect(Object.keys(localized).sort()).toEqual(Object.keys(english).sort());
});

it.each(nonEnglishLocales)('%s preserves every interpolation placeholder', (locale) => {
const localized = flatten(LEARN_CONTENT_BY_LOCALE[locale]);
for (const key of Object.keys(english)) {
expect(placeholders(localized[key] ?? ''), key).toEqual(placeholders(english[key] ?? ''));
}
});

it.each(nonEnglishLocales)(
'%s has no untranslated English fallback outside the approved technical terms',
(locale) => {
const localized = flatten(LEARN_CONTENT_BY_LOCALE[locale]);
const unexpectedlyUnchanged = Object.keys(english)
.filter((key) => !isStructuralKey(key))
.filter((key) => localized[key] === english[key])
.filter((key) => !LEGITIMATE_UNCHANGED_VALUES.has(english[key] ?? ''));

expect(unexpectedlyUnchanged).toEqual([]);
},
);
});
Original file line number Diff line number Diff line change
Expand Up @@ -7378,7 +7378,7 @@
"file": "rules/19-logging-observability-and-redaction.md"
},
{
"bytes": 4264,
"bytes": 4914,
"file": "rules/20-i18n-and-user-facing-messages.md"
},
{
Expand Down Expand Up @@ -9516,7 +9516,7 @@
},
"claw-frontend": {
"runner": "vitest",
"testFiles": 410
"testFiles": 411
},
"claw-health-service": {
"runner": "jest",
Expand Down Expand Up @@ -10276,7 +10276,7 @@
"serviceCount": 18,
"sharedPackageCount": 6,
"staleClaimCount": 0,
"totalTestFiles": 1122,
"totalTestFiles": 1123,
"workspaceCount": 25
}
}
9 changes: 9 additions & 0 deletions rules/20-i18n-and-user-facing-messages.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,15 @@ imply a coverage that was not there.
`western-locales-translation-regression.test.ts`** — catch English left
untranslated, with an explicit allow-list of approved technical terms.
- **`supported-locales.test.ts`** — every `Locale` member has a dictionary.
- **`constants/__tests__/learn-content-locale-completeness.test.ts`** (2026-09-01) —
the four tests above all assert against `lib/i18n/locales/*`, the UI-chrome
dictionary; none of them touch `constants/learn-content/*.constants.ts`, the
`/learn` article prose. This test covers that separately-maintained content
cluster for all 13 locales: key/section/FAQ parity, placeholder parity, and
the same untranslated-English-fallback check with its own allow-list. Any
other long-form content cluster added the same way (see
`public-comparison-content/`) needs the same treatment — it will not be
caught by the UI-dictionary tests either.
- **TS config** — an `i18n.types.ts` mismatch fails `npm run typecheck`.
- **Review checklist** — visual spot-check of one non-EN locale.

Expand Down
Loading