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(/