Files
Daniel Hiller 9069b24cfb chore: Add a locale completeness check
An incomplete locale falls back to English at runtime, so a gap breaks nothing
and nobody notices it on their own. 2.2.1 shipped fifty-six user-facing strings
that existed only in English and German, and it took someone looking for
something else to find them. `npm run client:locales:check` compares every
locale against the reference and exits non-zero when one is behind.

It compares `login.js` and `core.js` separately and never merges them.
`login.js` is the embedded bundle and is all that exists until a session is
established, so a key present only in `core.js` is still missing from every
screen shown before login — the two-factor challenge among them. Merging the
two hides exactly that, which is how the six strings of the challenge stayed
English in thirty-three languages.

Keys are compared on their base name, with the CLDR plural category stripped,
because how many categories a language needs is a property of that language:
English has two, Japanese one, Arabic six. Comparing the suffixed names would
have demanded an English `_one` from Japanese, where i18next never reads it.
Each plural key is then checked against the categories the language itself
declares, through the same Intl.PluralRules that i18next resolves with — a
missing category falls back to `_other` silently, and Russian `few` and `many`
are reachable with counts a user really sees.

It is deliberately not part of `lint`: a pull request that adds a string is
expected to leave the other locales behind for a while, and a check that fails
every such request would only be switched off. This one belongs before a
release.
2026-09-08 19:54:27 +02:00

181 lines
6.4 KiB
JavaScript

/*!
* Copyright (c) 2026 PLANKA Software GmbH
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
*/
/**
* Compares every locale against the reference one and reports translation keys that are
* missing. Run it before cutting a release: an incomplete locale falls back to English at
* runtime, so a gap never breaks anything and nobody notices it on their own.
*
* npm run locales:check --prefix client
*
* `login.js` and `core.js` are compared separately and never merged. `login.js` is the
* embedded bundle and is all that exists until a session is established, so a key present
* only in `core.js` is still missing from every screen shown before login — the two-factor
* challenge among them.
*
* Keys are compared on their base name, with the CLDR plural category stripped. How many
* categories a language needs is a property of that language: English has two, Japanese has
* one, Arabic has six. Comparing the suffixed names would demand an English `_one` from
* Japanese, where i18next would never read it. Contexts such as `_title` are NOT stripped —
* those must match across every language. Each plural key is then checked against the
* categories the language itself declares, through the same Intl.PluralRules that i18next
* resolves with: a missing category silently falls back to `_other`, which reads wrong
* wherever the forms differ, and Russian `few` and `many` are reachable with real counts.
*
* Exits non-zero when a locale is incomplete. It is deliberately not part of `lint`: a pull
* request that adds a string is expected to leave the other locales behind for a while.
*/
import { mkdtempSync, readdirSync, rmSync } from 'fs';
import os from 'os';
import path from 'path';
import { fileURLToPath } from 'url';
import { createServer } from 'vite';
const REFERENCE_LANGUAGE = 'en-US';
const BUNDLES = ['login.js', 'core.js'];
const PLURAL_SUFFIX = /_(zero|one|two|few|many|other)$/;
const localesDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../src/locales');
// Only `translation` holds our own strings. The sibling exports (`format`, `dateFns`,
// `timeAgo`, `markdownEditor`) are per-locale data that is meant to differ.
function flatten(value, prefix = '') {
return Object.entries(value).flatMap(([key, nested]) =>
nested && typeof nested === 'object' && !Array.isArray(nested)
? flatten(nested, `${prefix}${key}.`)
: [`${prefix}${key}`],
);
}
function keysOf(locale) {
return new Set(
Object.entries(locale.translation ?? {}).flatMap(([namespace, strings]) =>
flatten(strings, `${namespace}.`),
),
);
}
function baseKey(key) {
return key.replace(PLURAL_SUFFIX, '');
}
// The categories i18next will actually ask for, straight from the language's own rules.
function pluralCategories(language) {
try {
return new Intl.PluralRules(language).resolvedOptions().pluralCategories;
} catch {
return ['other'];
}
}
// The check only needs module resolution, so it skips the app's Vite config and its plugin
// chain, and keeps its cache in a throwaway directory rather than invalidating the app's.
const cacheDir = mkdtempSync(path.join(os.tmpdir(), 'planka-locales-'));
const server = await createServer({
root: path.dirname(localesDir),
configFile: false,
cacheDir,
optimizeDeps: { noDiscovery: true, include: [] },
server: { middlewareMode: true, watch: null },
appType: 'custom',
logLevel: 'error',
});
const languages = readdirSync(localesDir, { withFileTypes: true })
.filter((entry) => entry.isDirectory())
.map((entry) => entry.name)
.sort();
async function inspect(bundle) {
const loaded = await Promise.all(
// Loaded through Vite so each locale's own imports resolve exactly as in the app.
languages.map(async (language) => {
const module = await server.ssrLoadModule(path.join(localesDir, language, bundle));
return [language, keysOf(module.default)];
}),
);
const keysByLanguage = Object.fromEntries(loaded);
const reference = keysByLanguage[REFERENCE_LANGUAGE];
if (!reference) {
throw new Error(`Reference locale ${REFERENCE_LANGUAGE} not found in ${localesDir}`);
}
const referenceBase = [...new Set([...reference].map(baseKey))];
const plurals = referenceBase.filter((key) => reference.has(`${key}_other`));
const behind = languages
.filter((language) => language !== REFERENCE_LANGUAGE)
.map((language) => {
const own = keysByLanguage[language];
const present = new Set([...own].map(baseKey));
const missingKeys = referenceBase.filter((key) => !present.has(key));
const missingForms = plurals
.filter((key) => present.has(key))
.flatMap((key) =>
pluralCategories(language)
.filter((category) => !own.has(`${key}_${category}`))
.map((category) => `${key}_${category}`),
);
return { language, bundle, missing: [...missingKeys, ...missingForms].sort() };
})
.filter(({ missing }) => missing.length > 0)
.sort((a, b) => a.missing.length - b.missing.length || a.language.localeCompare(b.language));
return { bundle, total: referenceBase.length, behind };
}
let report;
try {
report = await Promise.all(BUNDLES.map(inspect));
} finally {
await server.close();
rmSync(cacheDir, { recursive: true, force: true });
}
// Each key is listed once per bundle, with how many locales still need it.
const summarise = ({ bundle, total, behind }) => {
const heading = `${bundle} — reference ${REFERENCE_LANGUAGE}: ${total} keys`;
if (behind.length === 0) {
return `${heading}\n all locales complete\n`;
}
const pending = behind.reduce(
(counts, { missing }) =>
missing.reduce((acc, key) => acc.set(key, (acc.get(key) ?? 0) + 1), counts),
new Map(),
);
const lines = [...pending.entries()]
.sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))
.map(([key, count]) => ` ${String(count).padStart(3)}x ${key}`);
return [
heading,
` ${behind.length} locales incomplete, ${pending.size} keys pending:`,
...lines,
'',
].join('\n');
};
process.stdout.write(`${report.map(summarise).join('\n')}\n`);
const incomplete = new Set(report.flatMap(({ behind }) => behind.map(({ language }) => language)));
if (incomplete.size > 0) {
process.stdout.write(`${incomplete.size} locale(s) incomplete.\n`);
process.exit(1);
}
process.stdout.write('All locales complete.\n');