fix(walkthrough): offer German, and catch the next locale that is only half added

German was added to the interface but not to the walkthrough's own language
list, and nothing failed: the picker offered Deutsch because it is built from
the interface locales, the server resolved the tag to English, and a German
reader paid for a walkthrough written in English while the picker still said
Deutsch.

The two lists cannot be one — the server cannot import from packages/ui — so
a test reads i18n/runtime.ts and compares them, in both directions and through
normalizeLanguage. Drift this quiet needs a test rather than vigilance.
This commit is contained in:
Bohdan Triapitsyn
2026-08-03 13:55:51 +03:00
parent 2dca614849
commit ce09ca6190
3 changed files with 68 additions and 1 deletions
@@ -136,7 +136,12 @@ silently. The prompt says so explicitly.
`languages.js` owns the accepted tags; they match the UI's `Locale` union, and
anything else — unknown, malformed, absent — resolves to English rather than
failing the request. The default language adds no instruction at all, since the
failing the request. The two lists cannot be one, because the server cannot
import from `packages/ui`, so `languages.test.js` reads `i18n/runtime.ts` and
compares them. That test exists because a locale added to the interface alone
fails silently in the worst way: the picker offers the language, the tag
resolves to English, and the reader pays for a walkthrough written in the wrong
one while the picker still names theirs. The default language adds no instruction at all, since the
system prompt is already English.
The language is part of the cache key. Without that, asking for a translation
@@ -17,6 +17,7 @@ export const DEFAULT_LANGUAGE = 'en';
// which every model handles more reliably than a switch mid-sentence.
const LANGUAGE_NAMES = {
en: 'English',
de: 'German',
fr: 'French',
'zh-CN': 'Simplified Chinese',
'zh-TW': 'Traditional Chinese',
@@ -57,3 +58,9 @@ export function normalizeLanguage(value) {
export function languageName(language) {
return LANGUAGE_NAMES[language] ?? LANGUAGE_NAMES[DEFAULT_LANGUAGE];
}
// The tags this list must agree with live in `packages/ui/src/lib/i18n`, which
// the server cannot import. `languages.test.js` compares the two by reading
// that file, because a locale added on one side only fails silently: the picker
// offers the language and the walkthrough comes back in English.
export const __testing = { LANGUAGE_NAMES };
@@ -0,0 +1,55 @@
import fs from 'fs';
import { fileURLToPath } from 'url';
import { describe, expect, it } from 'vitest';
import { normalizeLanguage, __testing } from './languages.js';
// The languages a walkthrough may be written in have to agree with the locales
// the interface ships, because the picker offers exactly those and the server
// decides what the prompt asks for. The two lists cannot be one list — the
// server cannot import from `packages/ui` — so they are compared here instead.
//
// This exists because German was added to the interface and not here. Nothing
// broke loudly: the picker offered Deutsch, `normalizeLanguage` quietly resolved
// it to English, and a German user paid for a walkthrough written in English
// while the picker still said Deutsch. A drift this quiet needs a test, not
// vigilance.
const RUNTIME_TS = fileURLToPath(new URL('../../../../ui/src/lib/i18n/runtime.ts', import.meta.url));
const interfaceLocales = () => {
const source = fs.readFileSync(RUNTIME_TS, 'utf8');
const match = source.match(/export const LOCALES = \[([^\]]*)\]/);
if (!match) throw new Error(`Could not find LOCALES in ${RUNTIME_TS}`);
return match[1]
.split(',')
.map((entry) => entry.trim().replace(/^['"]|['"]$/g, ''))
.filter(Boolean);
};
describe('supported languages', () => {
it('covers every locale the interface offers', () => {
const missing = interfaceLocales().filter((locale) => !Object.hasOwn(__testing.LANGUAGE_NAMES, locale));
expect(missing, `add these to LANGUAGE_NAMES in languages.js: ${missing.join(', ')}`).toEqual([]);
});
it('offers nothing the interface cannot label', () => {
const locales = new Set(interfaceLocales());
const extra = Object.keys(__testing.LANGUAGE_NAMES).filter((tag) => !locales.has(tag));
// A language here that the interface does not know is not harmful, but it
// is unreachable: the picker is built from the interface list.
expect(extra, `unreachable from the picker: ${extra.join(', ')}`).toEqual([]);
});
it('resolves every interface locale to itself rather than to the default', () => {
for (const locale of interfaceLocales()) {
expect(normalizeLanguage(locale)).toBe(locale);
}
});
it('names every supported language in English, for the prompt', () => {
for (const [tag, name] of Object.entries(__testing.LANGUAGE_NAMES)) {
expect(name, tag).toMatch(/^[A-Z][A-Za-z ]+$/);
}
});
});