|
| 1 | +import assert from 'node:assert/strict'; |
| 2 | +import { readdirSync, readFileSync } from 'node:fs'; |
| 3 | +import path from 'node:path'; |
| 4 | +import { fileURLToPath } from 'node:url'; |
| 5 | +import test from 'node:test'; |
| 6 | + |
| 7 | +/* |
| 8 | + * Every locale must speak the same keys. A missing key renders its own name |
| 9 | + * in one language and works in the others - a bug that only speakers of that |
| 10 | + * language can see, which is nobody who can fix it. This walks the locale |
| 11 | + * files exactly as i18next loads them and diffs key sets against `en`, |
| 12 | + * ignoring plural-form suffixes (`files_one`, `files_few`, ...), which each |
| 13 | + * language legitimately carries a different subset of. |
| 14 | + */ |
| 15 | + |
| 16 | +const localesDirectory = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', 'src', 'i18n', 'locales'); |
| 17 | +const PLURAL_SUFFIX = /_(zero|one|two|few|many|other)$/; |
| 18 | + |
| 19 | +function locales() { |
| 20 | + return readdirSync(localesDirectory, { withFileTypes: true }).filter((entry) => entry.isDirectory()).map((entry) => entry.name).sort(); |
| 21 | +} |
| 22 | + |
| 23 | +function namespaceFiles(directory) { |
| 24 | + return readdirSync(directory).filter((name) => name.endsWith('.json')).sort(); |
| 25 | +} |
| 26 | + |
| 27 | +/** Dotted key paths of a parsed namespace, plural-form suffixes folded away. */ |
| 28 | +function keyPaths(value, prefix = '') { |
| 29 | + const keys = new Set(); |
| 30 | + for (const [name, child] of Object.entries(value ?? {})) { |
| 31 | + const base = prefix ? `${prefix}.${name.replace(PLURAL_SUFFIX, '')}` : name.replace(PLURAL_SUFFIX, ''); |
| 32 | + keys.add(base); |
| 33 | + if (child && typeof child === 'object' && !Array.isArray(child)) { |
| 34 | + for (const nested of keyPaths(child, base)) keys.add(nested); |
| 35 | + } |
| 36 | + } |
| 37 | + return keys; |
| 38 | +} |
| 39 | + |
| 40 | +function namespaceKeys(locale, file) { |
| 41 | + const parsed = JSON.parse(readFileSync(path.join(localesDirectory, locale, file), 'utf8')); |
| 42 | + assert.ok(parsed && typeof parsed === 'object' && !Array.isArray(parsed), `${locale}/${file}: the namespace must be a JSON object`); |
| 43 | + return keyPaths(parsed); |
| 44 | +} |
| 45 | + |
| 46 | +test('every locale carries exactly the keys the English one does, in every namespace', () => { |
| 47 | + const all = locales(); |
| 48 | + assert.ok(all.includes('en'), 'the reference locale "en" is missing'); |
| 49 | + const namespaced = new Map(all.map((locale) => { |
| 50 | + const names = new Map(namespaceFiles(path.join(localesDirectory, locale)).map((file) => [file, namespaceKeys(locale, file)])); |
| 51 | + return [locale, names]; |
| 52 | + })); |
| 53 | + |
| 54 | + const english = namespaced.get('en'); |
| 55 | + for (const [locale, namespaces] of namespaced) { |
| 56 | + if (locale === 'en') continue; |
| 57 | + assert.deepEqual([...namespaces.keys()].sort(), [...english.keys()].sort(), `${locale}: namespace files differ from en`); |
| 58 | + for (const [file, keys] of namespaces) { |
| 59 | + const expected = english.get(file); |
| 60 | + const missing = [...expected].filter((key) => !keys.has(key)); |
| 61 | + const extra = [...keys].filter((key) => !expected.has(key)); |
| 62 | + assert.deepEqual( |
| 63 | + { missing, extra }, |
| 64 | + { missing: [], extra: [] }, |
| 65 | + `${locale}/${file}: key sets drift from en (missing ${missing.length}, extra ${extra.length})`, |
| 66 | + ); |
| 67 | + } |
| 68 | + } |
| 69 | +}); |
| 70 | + |
| 71 | +test('a plural-suffixed key counts as its base key, so legitimate per-language plural forms pass', () => { |
| 72 | + const base = keyPaths({ files_one: 1, files_other: 2, title: 3 }); |
| 73 | + assert.deepEqual([...base].sort(), ['files', 'title']); |
| 74 | + assert.deepEqual([...keyPaths({ nested: { rows_few: 1 } })], ['nested', 'nested.rows']); |
| 75 | +}); |
0 commit comments