diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml
new file mode 100644
index 000000000..d17aacd1d
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/config.yml
@@ -0,0 +1,11 @@
+blank_issues_enabled: true
+contact_links:
+ - name: 📖 Documentation
+ url: https://docs.ipcheck.ing
+ about: Deployment, MaxMind setup, environment variables, and how each tool works.
+ - name: 💬 Questions & help
+ url: https://github.com/jason5ng32/MyIP/discussions/categories/q-a
+ about: Setup help, "why does my result look like this", and anything that isn't a bug.
+ - name: 🔒 Report a security vulnerability
+ url: https://github.com/jason5ng32/MyIP/security/advisories/new
+ about: Private report, visible only to the maintainer. Please don't open a public issue.
diff --git a/.gitignore b/.gitignore
index d19780a0f..887bc6f90 100644
--- a/.gitignore
+++ b/.gitignore
@@ -54,10 +54,13 @@ common/as-rel-db/*.next
docs/
.plan/
-# Local Scripts (fetch-favicons.js is the one public exception —
-# contributors need it for the Connectivity favicon pipeline)
+# Local Scripts (the listed exceptions are public — contributors need
+# fetch-favicons.js for the Connectivity favicon pipeline, and the i18n pair
+# to scaffold a locale pack and see how far along it is)
scripts/*
!scripts/fetch-favicons.js
+!scripts/i18n-status.js
+!scripts/i18n-scaffold.js
# Section banners — deploy-time data (ads and campaign promos stay out of git)
frontend/data/banners/*
diff --git a/AGENTS.md b/AGENTS.md
index 972977c05..1d511db7b 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -15,7 +15,7 @@ halves: a Vue 3 SPA front-end and an Express 5 back-end API.
| Layer | Technology |
|---|---|
-| Frontend | Vue 3 (`
@@ -320,9 +320,11 @@
@@ -381,13 +383,33 @@
ru: 'Слишком долго? Попробуйте IPCheck.ing Lite или обновите страницу.',
};
+ // Hand-copy of matchLocale() in common/locale-registry.js — index.html
+ // ships outside the bundle and can't import it. Resolves a BCP-47 tag in
+ // three steps: exact, then the base language, then any locale of the same
+ // family, `codes` order deciding between siblings.
+ // tests/index-html-i18n.test.js runs both implementations side by side;
+ // change one and change the other.
+ const matchLang = (tag, codes) => {
+ if (!tag) return null;
+ const wanted = String(tag).toLowerCase();
+ const base = wanted.split('-')[0];
+ return codes.find((code) => code.toLowerCase() === wanted)
+ ?? codes.find((code) => code.toLowerCase() === base)
+ ?? codes.find((code) => code.toLowerCase().split('-')[0] === base)
+ ?? null;
+ };
+
+ // Which languages the inline copy above covers — beta locales may not be
+ // among them and then resolve to a neighbour or to English.
+ const QUIP_LANGS = Object.keys(QUIPS);
+
const pickLang = () => {
- const stored = window.jnReadPrefs ? window.jnReadPrefs().lang : null;
- if (QUIPS[stored]) return stored;
+ const prefs = window.jnReadPrefs ? window.jnReadPrefs() : {};
const hl = new URLSearchParams(window.location.search).get('hl');
- if (QUIPS[hl]) return hl;
- const nav = (navigator.language || '').slice(0, 2).toLowerCase();
- return QUIPS[nav] ? nav : 'en';
+ return matchLang(prefs.lang, QUIP_LANGS)
+ ?? matchLang(hl, QUIP_LANGS)
+ ?? matchLang(navigator.language, QUIP_LANGS)
+ ?? 'en';
};
const lang = pickLang();
diff --git a/package.json b/package.json
index 51a9fbf43..a5cc42ab9 100644
--- a/package.json
+++ b/package.json
@@ -15,7 +15,10 @@
"start-frontend": "node frontend-server.js",
"start": "concurrently \"node frontend-server.js\" \"node --import ./sentry-instrument.js backend-server.js\"",
"purge-index": "node scripts/purge-index-cache.js",
- "fetch-favicons": "node scripts/fetch-favicons.js"
+ "fetch-favicons": "node scripts/fetch-favicons.js",
+ "i18n-status": "node scripts/i18n-status.js",
+ "i18n-new": "node scripts/i18n-scaffold.js new",
+ "i18n-sync": "node scripts/i18n-scaffold.js sync"
},
"dependencies": {
"@cloudflare/speedtest": "^1.13.0",
diff --git a/scripts/i18n-scaffold.js b/scripts/i18n-scaffold.js
new file mode 100644
index 000000000..c3e952400
--- /dev/null
+++ b/scripts/i18n-scaffold.js
@@ -0,0 +1,303 @@
+// scripts/i18n-scaffold.js — creates and maintains the empty-value skeletons
+// that locale packs are built from. Committed contributor tooling:
+//
+// pnpm i18n-new scaffold frontend/locales/.json (every key of
+// en.json, every value "") and register the locale
+// pnpm i18n-new --privacy --checklist
+// the same for the two optional datasets, once the
+// language is registered
+// pnpm i18n-sync realign the main packs with en (new keys as "",
+// dead ones dropped, en's order); the optional files
+// are reported, not written — they ship whole
+//
+// The convention both serve: an untranslated string is "", never a missing
+// key, so a translation PR reads as `"" → text`. tests/locale-packs.test.js
+// enforces the shape; vite.config.js drops the empties on the way into the
+// bundle. The pure half below is exported for its spec; the CLI is a shell.
+import fs from 'node:fs';
+import path from 'node:path';
+import { fileURLToPath } from 'node:url';
+
+import { LOCALES, LOCALE_CODES, FALLBACK_LOCALE } from '../common/locale-registry.js';
+import { flattenPack } from '../common/locale-pack.js';
+
+const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
+const localesDir = path.join(repoRoot, 'frontend', 'locales');
+const registryFile = path.join(repoRoot, 'common', 'locale-registry.js');
+
+// A locale code is `xx`, `xx-YY` (region) or `xx-Xxxx` (script).
+const CODE_SHAPE = /^[a-z]{2}(-([A-Z]{2}|[A-Z][a-z]{3}))?$/;
+
+// Languages whose flag isn't derivable from the code. Only the ones a
+// contributor is likely to reach for — anything else prints a "check this".
+const LANGUAGE_FLAGS = {
+ ar: 'sa', cs: 'cz', da: 'dk', el: 'gr', en: 'us', fa: 'ir', he: 'il', hi: 'in',
+ ja: 'jp', ko: 'kr', ms: 'my', nb: 'no', sv: 'se', uk: 'ua', vi: 'vn', zh: 'cn',
+};
+
+/* ------------------------------------------------------------------ */
+/* Pure half */
+/* ------------------------------------------------------------------ */
+
+// Checklist slugs and priorities are data — the gate requires them to match
+// en, so a skeleton hands them over already filled in.
+export const isChecklistDataKey = (key) => key === 'slug' || key === 'priority';
+
+// en's structure with every string blanked, except the keys `keepKey` claims.
+// Non-string leaves are data too, so they are carried over as they are.
+export const buildSkeleton = (reference, keepKey = () => false) => {
+ const walk = (node, key) => {
+ if (typeof node === 'string') return keepKey(key) ? node : '';
+ if (Array.isArray(node)) return node.map((child) => walk(child, key));
+ if (node && typeof node === 'object') {
+ return Object.fromEntries(Object.entries(node).map(([child_key, child]) => [child_key, walk(child, child_key)]));
+ }
+ return node;
+ };
+ return walk(reference, '');
+};
+
+// Rebuild `existing` against `reference`: en's keys in en's order, existing
+// translations kept, new keys blank, keys en dropped gone. Reports both sides
+// of the diff by path so the CLI can say what it did.
+export const syncPack = (reference, existing, prefix = '', report = { added: [], removed: [] }) => {
+ const source = existing && typeof existing === 'object' ? existing : {};
+ const at = (key) => (prefix ? `${prefix}.${key}` : key);
+
+ for (const key of Object.keys(source)) {
+ if (Array.isArray(reference) ? Number(key) < reference.length : key in reference) continue;
+ report.removed.push(at(key));
+ }
+
+ const merge = (key, child) => {
+ const current = source[key];
+ if (child && typeof child === 'object') return syncPack(child, current, at(key), report).pack;
+ if (typeof child !== 'string') return child; // non-copy leaf: keep en's value
+ if (typeof current === 'string') return current; // the translation, or a "" already there
+ report.added.push(at(key));
+ return '';
+ };
+
+ const pack = Array.isArray(reference)
+ ? reference.map((child, index) => merge(String(index), child))
+ : Object.fromEntries(Object.entries(reference).map(([key, child]) => [key, merge(key, child)]));
+
+ return { pack, added: report.added, removed: report.removed };
+};
+
+// The locale's own name for itself, capitalized the way the registry writes it.
+export const nativeNameFor = (code) => {
+ let name = code;
+ try {
+ name = new Intl.DisplayNames([code], { type: 'language' }).of(code) || code;
+ } catch { /* no data for this tag — the caller is told to fill it in */ }
+ return name.charAt(0).toUpperCase() + name.slice(1);
+};
+
+// circle-flags code: the region subtag if there is one, else a known mapping.
+// `guessed: false` means the caller has to check it by hand.
+export const flagFor = (code) => {
+ const region = code.split('-')[1];
+ if (region && /^[A-Z]{2}$/.test(region)) return { flag: region.toLowerCase(), guessed: true };
+ const known = LANGUAGE_FLAGS[code.split('-')[0]];
+ return known ? { flag: known, guessed: true } : { flag: code.toLowerCase(), guessed: false };
+};
+
+export const buildRegistryEntry = (code) => ({
+ code,
+ nativeName: nativeNameFor(code),
+ flag: flagFor(code).flag,
+ apiTag: code,
+ htmlLang: code,
+ status: 'beta',
+});
+
+export const formatRegistryLine = (entry) => ` { code: '${entry.code}', `
+ + `nativeName: '${entry.nativeName}', flag: '${entry.flag}', apiTag: '${entry.apiTag}', `
+ + `htmlLang: '${entry.htmlLang}', status: '${entry.status}' },`;
+
+// Append the line to the LOCALES array in the registry's source text.
+export const insertRegistryLine = (source, line) => {
+ const marker = source.match(/export const LOCALES = \[\n[\s\S]*?\n(\];)/);
+ if (!marker) throw new Error('could not find the LOCALES array in common/locale-registry.js');
+ const closing = marker.index + marker[0].length - marker[1].length;
+ return `${source.slice(0, closing)}${line}\n${source.slice(closing)}`;
+};
+
+// Everything that makes a code unusable, plus the softer "are you sure".
+export const validateNewCode = (code, registered = LOCALE_CODES) => {
+ const errors = [];
+ const warnings = [];
+ if (!CODE_SHAPE.test(code)) {
+ errors.push(`"${code}" is not a locale code — use xx (zh), xx-YY (pt-BR) or xx-Xxxx (sr-Latn)`);
+ return { errors, warnings };
+ }
+ if (registered.includes(code)) errors.push(`"${code}" is already registered`);
+
+ const [base, region] = code.split('-');
+ if (region && !registered.some((other) => other === base || other.startsWith(`${base}-`))) {
+ warnings.push(`"${code}" is the first ${base} pack. The default variant of a language `
+ + `takes the bare code ("${base}") — keep the region only if this is genuinely a variant.`);
+ }
+ if (!flagFor(code).guessed) warnings.push(`no flag known for "${code}" — check the flag column by hand`);
+ return { errors, warnings };
+};
+
+// An optional dataset rides on an already-registered language, and the script
+// never writes over copy somebody may have translated.
+export const validateExtraPack = (code, dir, { registered, hasMainPack, exists }) => {
+ const errors = [];
+ if (!registered || !hasMainPack) {
+ errors.push(`"${code}" has no main pack yet — run \`pnpm i18n-new ${code}\` first`);
+ }
+ if (exists) errors.push(`frontend/locales/${dir}${code}.json already exists — i18n-new never overwrites a pack`);
+ return errors;
+};
+
+// The three per-locale datasets. Only the main pack is scaffolded by default;
+// the other two are opt-in flags.
+const DATASETS = {
+ main: { dir: '', label: 'main pack', sync: 'write' },
+ privacy: { dir: 'privacy/', label: 'privacy copy', sync: 'report' },
+ checklist: { dir: 'security-checklist/', label: 'security checklist', sync: 'report', keepKey: isChecklistDataKey },
+};
+
+/* ------------------------------------------------------------------ */
+/* CLI */
+/* ------------------------------------------------------------------ */
+
+const packPath = (dir, code) => path.join(localesDir, dir, `${code}.json`);
+const readJson = (file) => JSON.parse(fs.readFileSync(file, 'utf8'));
+const writeJson = (file, value) => fs.writeFileSync(file, `${JSON.stringify(value, null, 2)}\n`);
+
+const runNew = (code, flags) => {
+ if (!code) throw new Error('usage: pnpm i18n-new [--privacy] [--checklist]');
+ const extras = ['privacy', 'checklist'].filter((name) => flags.has(`--${name}`));
+ if (extras.length > 0) addExtraPacks(code, extras);
+ else createLocale(code);
+};
+
+// Default run: the main pack plus the registry line, for a language that has
+// neither yet.
+const createLocale = (code) => {
+ const { errors, warnings } = validateNewCode(code);
+ if (fs.existsSync(packPath('', code))) errors.push(`frontend/locales/${code}.json already exists`);
+ if (errors.length > 0) throw new Error(errors.join('\n'));
+
+ const entry = buildRegistryEntry(code);
+ const skeleton = buildSkeleton(readJson(packPath('', FALLBACK_LOCALE)));
+ writeJson(packPath('', code), skeleton);
+ fs.writeFileSync(registryFile, insertRegistryLine(fs.readFileSync(registryFile, 'utf8'), formatRegistryLine(entry)));
+
+ console.log(`\n📦 frontend/locales/${code}.json — ${flattenPack(skeleton).size} keys, all ""`);
+ console.log(`🗂 common/locale-registry.js — ${formatRegistryLine(entry).trim()}`);
+ for (const warning of warnings) console.log(`⚠️ ${warning}`);
+ console.log(`
+Next:
+ 1. Translate what you like in frontend/locales/${code}.json — leave the rest "".
+ Check the registry line above reads right (nativeName, flag, apiTag).
+ 2. pnpm test the gate: keys must match en exactly, "" is a fine value
+ 3. pnpm i18n-status how far along you are, and what to do next
+ 4. pnpm dev pick the language in Preferences and look at it
+ The privacy policy and the security checklist are separate, optional files —
+ each ships finished or not at all. \`pnpm i18n-new ${code} --privacy --checklist\`
+ scaffolds them when you are ready to do one in a sitting.
+ Conventions and the answers to "why is this still English": TRANSLATING.md\n`);
+};
+
+// Opt-in run: a skeleton for one or both optional datasets of a language that
+// is already registered.
+const addExtraPacks = (code, names) => {
+ const registered = LOCALE_CODES.includes(code);
+ const hasMainPack = fs.existsSync(packPath('', code));
+ const errors = names.flatMap((name) => validateExtraPack(code, DATASETS[name].dir, {
+ registered,
+ hasMainPack,
+ exists: fs.existsSync(packPath(DATASETS[name].dir, code)),
+ }));
+ if (errors.length > 0) throw new Error([...new Set(errors)].join('\n'));
+
+ for (const name of names) {
+ const { dir, label, keepKey } = DATASETS[name];
+ const skeleton = buildSkeleton(readJson(packPath(dir, FALLBACK_LOCALE)), keepKey);
+ writeJson(packPath(dir, code), skeleton);
+ console.log(`\n📦 frontend/locales/${dir}${code}.json — ${flattenPack(skeleton).size} keys (${label})`);
+ console.log(' ⚠️ This file has no per-key fallback, so the gate rejects a "" in it.');
+ console.log(' ⚠️ pnpm test stays RED until every value is filled in — do not commit a');
+ console.log(' half-done file: delete it again if you are not finishing it now.');
+ }
+ console.log(`\nThen: pnpm test · pnpm i18n-status --locale ${code} · TRANSLATING.md\n`);
+};
+
+const listKeys = (sign, keys) => {
+ for (const key of keys.slice(0, 10)) console.log(` ${sign} ${key}`);
+ if (keys.length > 10) console.log(` ${sign} …${keys.length - 10} more`);
+};
+
+// Rewrite one pack against en. The optional files are report-only: they have
+// no per-key fallback, so an auto-added "" would ship a half-English page and
+// fail the gate — the translator decides what to do about it.
+const syncOne = (code, status, name) => {
+ const { dir, label, sync } = DATASETS[name];
+ if (!fs.existsSync(packPath(dir, code))) return 0;
+
+ const reference = readJson(packPath(dir, FALLBACK_LOCALE));
+ const before = fs.readFileSync(packPath(dir, code), 'utf8');
+ const { pack, added, removed } = syncPack(reference, readJson(packPath(dir, code)));
+ const after = `${JSON.stringify(pack, null, 2)}\n`;
+ const where = `${code} ${label}`;
+
+ if (sync === 'report') {
+ if (added.length === 0 && removed.length === 0) return 0;
+ console.log(`🔍 ${where} — en moved: ${added.length} new key(s), ${removed.length} gone. Not written:`);
+ listKeys('+', added);
+ listKeys('-', removed);
+ console.log(' this file ships finished or not at all — translate or drop it');
+ return 0;
+ }
+
+ if (after === before) {
+ console.log(`✅ ${where} — already aligned with en`);
+ return 0;
+ }
+ fs.writeFileSync(packPath(dir, code), after);
+ console.log(`✏️ ${where} — +${added.length} new key(s) as "", -${removed.length} dropped, order realigned`);
+ listKeys('+', added);
+ listKeys('-', removed);
+ if (status === 'full' && added.length > 0) {
+ console.log(` ⚠️ ${code} is a full locale: those "" will fail pnpm test until translated`);
+ }
+ return added.length + 1;
+};
+
+const runSync = () => {
+ let written = 0;
+ let addedTotal = 0;
+ for (const { code, status } of LOCALES) {
+ if (code === FALLBACK_LOCALE) continue;
+ for (const name of ['main', 'privacy', 'checklist']) {
+ const result = syncOne(code, status, name);
+ if (result === 0) continue;
+ written += 1;
+ addedTotal += result - 1;
+ }
+ }
+ if (written === 0) console.log('\nNothing to do.\n');
+ else if (addedTotal === 0) console.log(`\n${written} pack(s) rewritten — key order only.\n`);
+ else console.log(`\n${written} pack(s) rewritten. Translate the new "" values, then pnpm test.\n`);
+};
+
+// Only when run as a CLI — importing the module (tests) must have no effect.
+if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
+ const args = process.argv.slice(2);
+ const flags = new Set(args.filter((arg) => arg.startsWith('--')));
+ const [command, argument] = args.filter((arg) => !arg.startsWith('--'));
+ try {
+ if (command === 'new') runNew(argument, flags);
+ else if (command === 'sync') runSync();
+ else throw new Error('usage: pnpm i18n-new | pnpm i18n-sync');
+ } catch (error) {
+ console.error(`\n❌ ${error.message}\n`);
+ process.exit(1);
+ }
+}
diff --git a/scripts/i18n-status.js b/scripts/i18n-status.js
new file mode 100644
index 000000000..b90760d56
--- /dev/null
+++ b/scripts/i18n-status.js
@@ -0,0 +1,92 @@
+// scripts/i18n-status.js — translation progress dashboard for contributors.
+// Prints, per registered locale, how much of each dataset (main pack, privacy
+// copy, security checklist) is translated and which keys come next.
+//
+// This is a report, never a gate: it always exits 0, whatever it finds.
+// The rules a pack must obey live in tests/locale-packs.test.js.
+//
+// Usage: pnpm i18n-status [--locale zh] [--limit 20]
+import fs from 'node:fs';
+import { parseArgs } from 'node:util';
+
+import { LOCALES, FALLBACK_LOCALE } from '../common/locale-registry.js';
+import { flattenPack, isUntranslated } from '../common/locale-pack.js';
+
+const localesDir = new URL('../frontend/locales/', import.meta.url);
+
+const DATASETS = [
+ { name: 'main pack', dir: '' },
+ { name: 'privacy copy', dir: 'privacy/' },
+ { name: 'checklist', dir: 'security-checklist/' },
+];
+
+const packUrl = (dir, code) => new URL(`${dir}${code}.json`, localesDir);
+const readPack = (dir, code) => JSON.parse(fs.readFileSync(packUrl(dir, code), 'utf8'));
+
+// Checklist slugs and priorities are data, not copy — they are supposed to be
+// identical to en, so they stay out of the sameAsEn signal below.
+const DATA_KEY = /(^|\.)(slug|priority)$/;
+
+// A key counts as translated when it is present and not blank — except where
+// en is blank too, which makes the empty value the correct translation.
+// `sameAsEn` is a soft signal for reviewers: copied-over English reads as
+// translated to every other check here, and sometimes that is even correct
+// (product names, "MTR"), so it is reported, never judged.
+const compare = (dir, code) => {
+ const en = flattenPack(readPack(dir, FALLBACK_LOCALE));
+ if (!fs.existsSync(packUrl(dir, code))) {
+ return { total: en.size, done: 0, missing: [...en.keys()], sameAsEn: 0, file: false };
+ }
+ const pack = flattenPack(readPack(dir, code));
+ const missing = [...en].filter(([key, enValue]) => {
+ const value = pack.get(key);
+ if (value === undefined) return true;
+ return isUntranslated(value) && !isUntranslated(enValue);
+ }).map(([key]) => key);
+ const sameAsEn = [...en].filter(([key, enValue]) =>
+ !DATA_KEY.test(key) && !isUntranslated(enValue) && pack.get(key) === enValue).length;
+ return { total: en.size, done: en.size - missing.length, missing, sameAsEn, file: true };
+};
+
+const bar = (ratio, width = 24) => {
+ const filled = Math.round(ratio * width);
+ return `${'█'.repeat(filled)}${'░'.repeat(width - filled)}`;
+};
+
+const { values } = parseArgs({
+ options: { locale: { type: 'string' }, limit: { type: 'string', default: '10' } },
+});
+const limit = Math.max(0, Number.parseInt(values.limit, 10) || 0);
+const targets = LOCALES.filter((l) => l.code !== FALLBACK_LOCALE && (!values.locale || l.code === values.locale));
+
+console.log(`\nTranslation status — reference locale: ${FALLBACK_LOCALE}\n`);
+
+if (targets.length === 0) {
+ console.log(values.locale ? `No registered locale "${values.locale}".` : 'No locales to report yet.');
+} else {
+ for (const { code, nativeName, status } of targets) {
+ const reports = DATASETS.map((dataset) => ({ ...dataset, ...compare(dataset.dir, code) }));
+ const total = reports.reduce((sum, r) => sum + r.total, 0);
+ const done = reports.reduce((sum, r) => sum + r.done, 0);
+
+ const sameAsEn = reports.reduce((sum, r) => sum + r.sameAsEn, 0);
+
+ console.log(`${code} — ${nativeName} [${status}] ${bar(done / total)} ${((done / total) * 100).toFixed(1)}% (${done}/${total})`);
+ for (const report of reports) {
+ const note = report.file ? '' : ' (no file yet)';
+ const pct = ((report.done / report.total) * 100).toFixed(1);
+ console.log(` ${report.name.padEnd(12)} ${pct.padStart(5)}% ${report.done}/${report.total}${note}`);
+ }
+
+ if (sameAsEn > 0) console.log(` ${'identical to en'.padEnd(12)} ${sameAsEn} value(s) — worth a spot check`);
+
+ const nextUp = reports.flatMap((r) => r.missing.map((key) => `${r.dir}${key}`));
+ if (nextUp.length > 0 && limit > 0) {
+ console.log(` next up: ${nextUp.slice(0, limit).join(', ')}`);
+ if (nextUp.length > limit) console.log(` …and ${nextUp.length - limit} more`);
+ }
+ console.log('');
+ }
+}
+
+console.log('Rules a pack must follow: tests/locale-packs.test.js — this report never fails a build.\n');
diff --git a/tests/api-handlers.test.js b/tests/api-handlers.test.js
index fa0410232..657f0b4f8 100644
--- a/tests/api-handlers.test.js
+++ b/tests/api-handlers.test.js
@@ -537,6 +537,49 @@ describe('dns-leak-test getSessionResult', () => {
assert.equal(res.statusCode, 500);
assert.deepEqual(res.body, { error: 'API key is missing' });
});
+
+ // ?lang is a pass-through: the upstream owns tag resolution, so the handler
+ // neither validates nor substitutes a default.
+ describe('lang forwarding', () => {
+ const callWithLang = async (query) => {
+ process.env.IPCHECKING_API_KEY = 'test-key';
+ process.env.IPCHECKING_API_ENDPOINT = 'https://upstream.invalid';
+ let requested;
+ globalThis.fetch = async (url) => {
+ requested = new URL(String(url));
+ return { status: 200, ok: true, json: async () => ({}) };
+ };
+ // The success path sets a Cache-Control header on the way out.
+ const res = createResponse();
+ res.set = () => res;
+ await dnsLeakGetResult({
+ method: 'GET', headers: {}, query,
+ params: { token: 'a'.repeat(32) },
+ }, res);
+ assert.equal(res.statusCode, 200);
+ return requested;
+ };
+
+ it('forwards the caller tag verbatim, family variants included', async () => {
+ for (const lang of ['zh-TW', 'ja', 'pt-PT', 'tr']) {
+ const url = await callWithLang({ lang });
+ assert.equal(url.searchParams.get('lang'), lang);
+ }
+ });
+
+ it('sends no lang at all when the caller omits it', async () => {
+ const url = await callWithLang({});
+ assert.equal(url.searchParams.has('lang'), false);
+ // The apikey still rides along — proof the request was built, not skipped.
+ assert.equal(url.searchParams.get('apikey'), 'test-key');
+ });
+
+ it('drops a non-string lang rather than stringifying it', async () => {
+ // Express turns a repeated ?lang= into an array; "a,b" is not a tag.
+ const url = await callWithLang({ lang: ['zh-CN', 'en'] });
+ assert.equal(url.searchParams.has('lang'), false);
+ });
+ });
});
// -- ipcheck-ing handler --------------------------------------------------
diff --git a/tests/changelog.test.js b/tests/changelog.test.js
index 0cb9c93dd..083c2adc9 100644
--- a/tests/changelog.test.js
+++ b/tests/changelog.test.js
@@ -6,8 +6,11 @@ import { describe, it } from 'node:test';
import fs from 'node:fs';
import changelog from '../frontend/data/changelog.json' with { type: 'json' };
+import { FULL_LOCALE_CODES } from '../common/locale-registry.js';
-const REQUIRED_LOCALES = ['en', 'zh', 'fr', 'ru'];
+// Beta locales are exempt: back-translating the whole history is the single
+// biggest deterrent to a first translation PR.
+const REQUIRED_LOCALES = FULL_LOCALE_CODES;
const VALID_TYPES = new Set(['add', 'improve', 'fix']);
describe('changelog.json', () => {
diff --git a/tests/connectivity-import-lists.test.js b/tests/connectivity-import-lists.test.js
index 644c90b82..4d41a402f 100644
--- a/tests/connectivity-import-lists.test.js
+++ b/tests/connectivity-import-lists.test.js
@@ -23,6 +23,7 @@ import {
CONNECTIVITY_TARGET_LIMIT,
} from '../frontend/data/connectivity-import-lists.js';
import { fetchFavicons } from '../scripts/fetch-favicons.js';
+import { FULL_LOCALE_CODES } from '../common/locale-registry.js';
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const faviconFile = (id) => path.join(repoRoot, 'public', 'favicons', `${id}.png`);
@@ -141,12 +142,12 @@ describe('import lists data integrity', () => {
}
});
- it('every list (and the system list) is named in all four locale packs', () => {
+ it('every list (and the system list) is named in every full locale pack', () => {
// ConnectivityAddDialog renders `connectivity.importLists.` for
// IMPORT_LISTS and SYSTEM_IMPORT_LIST alike. Translations can't be
// auto-filled — the failure message names the exact file and key.
const listIds = [...IMPORT_LISTS.map((l) => l.id), SYSTEM_IMPORT_LIST.id];
- for (const locale of ['en', 'zh', 'fr', 'ru']) {
+ for (const locale of FULL_LOCALE_CODES) {
const packPath = path.join(repoRoot, 'frontend', 'locales', `${locale}.json`);
const names = JSON.parse(readFileSync(packPath, 'utf8')).connectivity?.importLists ?? {};
for (const id of listIds) {
diff --git a/tests/i18n-scaffold.test.js b/tests/i18n-scaffold.test.js
new file mode 100644
index 000000000..22da89a2f
--- /dev/null
+++ b/tests/i18n-scaffold.test.js
@@ -0,0 +1,179 @@
+// Tests for the pure half of scripts/i18n-scaffold.js — what `pnpm i18n-new`
+// and `pnpm i18n-sync` write. The CLI shell (reading and writing files) is out
+// of scope; everything that decides content is here.
+
+import assert from 'node:assert/strict';
+import { describe, it } from 'node:test';
+import fs from 'node:fs';
+
+import {
+ buildSkeleton,
+ isChecklistDataKey,
+ validateExtraPack,
+ syncPack,
+ nativeNameFor,
+ flagFor,
+ buildRegistryEntry,
+ formatRegistryLine,
+ insertRegistryLine,
+ validateNewCode,
+} from '../scripts/i18n-scaffold.js';
+import { flattenPack } from '../common/locale-pack.js';
+import { LOCALE_CODES } from '../common/locale-registry.js';
+
+const en = JSON.parse(fs.readFileSync(new URL('../frontend/locales/en.json', import.meta.url), 'utf8'));
+
+describe('buildSkeleton', () => {
+ it('keeps en\'s shape and blanks every string', () => {
+ assert.deepEqual(buildSkeleton({ a: 'x', b: { c: 'y', d: ['p', 'q'] } }), { a: '', b: { c: '', d: ['', ''] } });
+ });
+
+ it('carries non-copy leaves over as they are', () => {
+ assert.deepEqual(buildSkeleton({ n: 3, b: true, z: null }), { n: 3, b: true, z: null });
+ });
+
+ it('keeps the keys the caller claims as data', () => {
+ const checklist = [{ title: 'Auth', slug: 'auth', checklist: [{ point: 'Use 2FA', priority: 'Essential' }] }];
+ assert.deepEqual(buildSkeleton(checklist, isChecklistDataKey),
+ [{ title: '', slug: 'auth', checklist: [{ point: '', priority: 'Essential' }] }]);
+ });
+
+ it('produces exactly en\'s key set — this is what the gate compares against', () => {
+ const skeleton = flattenPack(buildSkeleton(en));
+ assert.deepEqual([...skeleton.keys()], [...flattenPack(en).keys()]);
+ assert.ok([...skeleton.values()].every((value) => value === ''));
+ });
+});
+
+describe('syncPack', () => {
+ it('adds en\'s new keys as "" and reports them', () => {
+ const { pack, added } = syncPack({ a: 'x', fresh: 'new' }, { a: 'traduzido' });
+ assert.deepEqual(pack, { a: 'traduzido', fresh: '' });
+ assert.deepEqual(added, ['fresh']);
+ });
+
+ it('drops what en no longer has and reports that too', () => {
+ const { pack, removed } = syncPack({ a: 'x' }, { a: 'traduzido', gone: 'stale' });
+ assert.deepEqual(pack, { a: 'traduzido' });
+ assert.deepEqual(removed, ['gone']);
+ });
+
+ it('reports nested paths, not bare key names', () => {
+ const { added, removed } = syncPack({ deep: { fresh: 'new' } }, { deep: { gone: 'stale' } });
+ assert.deepEqual(added, ['deep.fresh']);
+ assert.deepEqual(removed, ['deep.gone']);
+ });
+
+ it('keeps an existing "" without calling it new', () => {
+ const { pack, added } = syncPack({ a: 'x' }, { a: '' });
+ assert.deepEqual(pack, { a: '' });
+ assert.deepEqual(added, []);
+ });
+
+ it('takes en\'s key order, whatever order the pack was in', () => {
+ const { pack } = syncPack({ first: 'a', second: 'b' }, { second: 'dois', first: 'um' });
+ assert.deepEqual(Object.keys(pack), ['first', 'second']);
+ });
+
+ it('resizes arrays to en\'s length', () => {
+ const { pack } = syncPack({ list: ['a', 'b', 'c'] }, { list: ['um', 'dois', 'tres', 'quatro'] });
+ assert.deepEqual(pack.list, ['um', 'dois', 'tres']);
+ });
+
+ it('is a no-op on a pack that is already aligned', () => {
+ const aligned = { a: 'traduzido', b: { c: '' } };
+ const { pack, added, removed } = syncPack({ a: 'x', b: { c: 'y' } }, aligned);
+ assert.deepEqual(pack, aligned);
+ assert.deepEqual([...added, ...removed], []);
+ });
+
+ it('scaffolds a whole pack when there is nothing to sync yet', () => {
+ const { pack } = syncPack({ a: 'x', b: { c: 'y' } }, {});
+ assert.deepEqual(pack, buildSkeleton({ a: 'x', b: { c: 'y' } }));
+ });
+});
+
+describe('validateExtraPack', () => {
+ const ok = { registered: true, hasMainPack: true, exists: false };
+
+ it('lets an optional dataset ride on a registered language', () => {
+ assert.deepEqual(validateExtraPack('pt-BR', 'privacy/', ok), []);
+ });
+
+ it('sends an unknown language back to the plain i18n-new run', () => {
+ for (const state of [{ ...ok, registered: false }, { ...ok, hasMainPack: false }]) {
+ assert.match(validateExtraPack('pt-BR', 'privacy/', state)[0], /run `pnpm i18n-new pt-BR` first/);
+ }
+ });
+
+ it('never writes over a file that is already there', () => {
+ assert.match(validateExtraPack('pt-BR', 'privacy/', { ...ok, exists: true })[0], /already exists/);
+ });
+});
+
+describe('registry entry', () => {
+ it('names the language the way its own speakers write it', () => {
+ assert.equal(nativeNameFor('pt-BR'), 'Português (Brasil)');
+ assert.equal(nativeNameFor('fr'), 'Français');
+ });
+
+ it('takes the flag from the region, then from the known list', () => {
+ assert.deepEqual(flagFor('pt-BR'), { flag: 'br', guessed: true });
+ assert.deepEqual(flagFor('ja'), { flag: 'jp', guessed: true });
+ assert.deepEqual(flagFor('xh'), { flag: 'xh', guessed: false });
+ });
+
+ it('formats a line the registry file can take verbatim', () => {
+ assert.equal(
+ formatRegistryLine(buildRegistryEntry('pt-BR')),
+ " { code: 'pt-BR', nativeName: 'Português (Brasil)', flag: 'br', apiTag: 'pt-BR', htmlLang: 'pt-BR', status: 'beta' },",
+ );
+ });
+
+ it('starts every new locale as beta, with the code as its own tags', () => {
+ const entry = buildRegistryEntry('sv');
+ assert.equal(entry.status, 'beta');
+ assert.equal(entry.apiTag, 'sv');
+ assert.equal(entry.htmlLang, 'sv');
+ });
+
+ it('inserts the line as the last entry of LOCALES', () => {
+ const source = 'export const LOCALES = [\n { code: \'en\' },\n];\n\nexport const OTHER = [\n];\n';
+ const inserted = insertRegistryLine(source, ' { code: \'sv\' },');
+ assert.equal(inserted, 'export const LOCALES = [\n { code: \'en\' },\n { code: \'sv\' },\n];\n\nexport const OTHER = [\n];\n');
+ });
+
+ it('refuses to guess when the registry doesn\'t look like itself', () => {
+ assert.throws(() => insertRegistryLine('const NOPE = [];', ' {}'), /LOCALES/);
+ });
+});
+
+describe('validateNewCode', () => {
+ it('accepts a plain language, a region variant and a script variant', () => {
+ for (const code of ['sv', 'pt-BR', 'sr-Latn']) {
+ assert.deepEqual(validateNewCode(code, ['en']).errors, [], code);
+ }
+ });
+
+ it('rejects anything that isn\'t a locale code', () => {
+ for (const code of ['Klingon', 'PT', 'pt_BR', 'p', 'pt-br', '']) {
+ assert.equal(validateNewCode(code, ['en']).errors.length, 1, code);
+ }
+ });
+
+ it('rejects a code already in the registry', () => {
+ assert.match(validateNewCode('en', LOCALE_CODES).errors[0], /already registered/);
+ });
+
+ it('warns — but does not refuse — on the first regional pack of a family', () => {
+ const first = validateNewCode('pt-BR', ['en']);
+ assert.deepEqual(first.errors, []);
+ assert.match(first.warnings[0], /takes the bare code/);
+ // Once the family is on the registry, a sibling is unremarkable.
+ assert.deepEqual(validateNewCode('pt-PT', ['en', 'pt-BR']).warnings, []);
+ });
+
+ it('warns when it has no flag to offer', () => {
+ assert.match(validateNewCode('xh', ['en']).warnings.at(-1), /no flag known/);
+ });
+});
diff --git a/tests/index-html-i18n.test.js b/tests/index-html-i18n.test.js
new file mode 100644
index 000000000..2b7388cc0
--- /dev/null
+++ b/tests/index-html-i18n.test.js
@@ -0,0 +1,197 @@
+// index.html is hand-maintained outside the bundle: its boot-screen copy
+// (QUIPS / SLOW_HINTS), its JSON-LD and its language picker can't import
+// common/locale-registry.js. This spec reads the file as text and holds those
+// three inline copies to the registry, so a typo or a half-done sync fails here
+// instead of shipping.
+//
+// Full locales must be covered; beta ones may skip the boot copy by design
+// (docs/I18N-PLAN.md) — they resolve to a neighbour or to English at boot.
+
+import assert from 'node:assert/strict';
+import { describe, it } from 'node:test';
+import fs from 'node:fs';
+
+import {
+ LOCALE_CODES,
+ FULL_LOCALE_CODES,
+ FALLBACK_LOCALE,
+ toHtmlLang,
+ matchLocale,
+} from '../common/locale-registry.js';
+
+const indexUrl = new URL('../index.html', import.meta.url);
+const html = fs.readFileSync(indexUrl, 'utf8');
+
+// Slice out one `const = { … }` / `= (…) => { … }` literal by brace
+// matching and evaluate it. Anchoring on the declaration rather than on line
+// numbers keeps unrelated markup edits from moving the target.
+const readDeclaration = (name) => {
+ const start = html.indexOf(`const ${name} = `);
+ assert.notEqual(start, -1, `index.html no longer declares \`${name}\` — update this spec with it`);
+ const open = html.indexOf('{', start);
+ assert.notEqual(open, -1, `index.html: \`${name}\` has no body`);
+ let depth = 0;
+ let end = -1;
+ for (let i = open; i < html.length; i += 1) {
+ if (html[i] === '{') depth += 1;
+ else if (html[i] === '}') {
+ depth -= 1;
+ if (depth === 0) { end = i; break; }
+ }
+ }
+ assert.notEqual(end, -1, `index.html: \`${name}\` is unbalanced`);
+ return html.slice(start, end + 1);
+};
+
+const evalDeclaration = (name) => {
+ const source = readDeclaration(name);
+ try {
+ return new Function(`${source}; return ${name};`)();
+ } catch (err) {
+ assert.fail(`index.html: \`${name}\` did not evaluate as plain JS (${err.message})`);
+ }
+};
+
+const QUIPS = evalDeclaration('QUIPS');
+const SLOW_HINTS = evalDeclaration('SLOW_HINTS');
+
+// The boot copy objects, checked by the same rules.
+const COPY = [
+ { name: 'QUIPS', value: QUIPS },
+ { name: 'SLOW_HINTS', value: SLOW_HINTS },
+];
+
+describe('index.html boot copy — languages match the registry', () => {
+ for (const { name, value } of COPY) {
+ it(`${name} keys are all registered locale codes`, () => {
+ for (const code of Object.keys(value)) {
+ assert.ok(LOCALE_CODES.includes(code),
+ `index.html ${name}: "${code}" is not in common/locale-registry.js — typo, or the locale was never registered`);
+ }
+ });
+
+ it(`${name} covers every full locale`, () => {
+ for (const code of FULL_LOCALE_CODES) {
+ assert.ok(code in value,
+ `index.html ${name}: full locale "${code}" has no boot copy — translate it or mark the locale beta`);
+ }
+ });
+ }
+
+ it('QUIPS and SLOW_HINTS cover the same languages', () => {
+ // pickLang() picks from QUIPS and then indexes SLOW_HINTS with the
+ // result — a language in one object only renders `undefined`.
+ assert.deepEqual(Object.keys(QUIPS), Object.keys(SLOW_HINTS),
+ 'index.html: QUIPS and SLOW_HINTS must list the same locales, in the same order');
+ });
+});
+
+describe('index.html boot copy — shape holds across languages', () => {
+ const referenceQuips = QUIPS[FALLBACK_LOCALE];
+
+ it('en is present and its quip list is non-empty', () => {
+ assert.ok(Array.isArray(referenceQuips) && referenceQuips.length > 0,
+ 'index.html QUIPS.en is the reference list — it must exist and be non-empty');
+ });
+
+ for (const [code, quips] of Object.entries(QUIPS)) {
+ it(`QUIPS.${code} is a same-length list of non-empty strings`, () => {
+ // The rotation shows one quip per tick and holds on the last one,
+ // so a short list would end the copy early for that language only.
+ assert.ok(Array.isArray(quips), `index.html QUIPS.${code} must be an array`);
+ assert.equal(quips.length, referenceQuips.length,
+ `index.html QUIPS.${code}: ${quips.length} quips against en's ${referenceQuips.length}`);
+ for (const [i, quip] of quips.entries()) {
+ assert.ok(typeof quip === 'string' && quip.trim() !== '',
+ `index.html QUIPS.${code}[${i}] is empty`);
+ }
+ });
+ }
+
+ for (const [code, hint] of Object.entries(SLOW_HINTS)) {
+ it(`SLOW_HINTS.${code} is one sentence carrying the Lite link`, () => {
+ assert.ok(typeof hint === 'string' && hint.trim() !== '',
+ `index.html SLOW_HINTS.${code} is empty`);
+ const links = hint.match(//,
+ `index.html SLOW_HINTS.${code}: the link must point at IPCheck.ing Lite`);
+ });
+ }
+});
+
+describe('index.html markup — declared languages', () => {
+ // The JSON-LD block is the only application/ld+json script in the file.
+ const jsonLd = (() => {
+ const match = html.match(/