From 30da86042faffeebfecebccb530d917ff15952d8 Mon Sep 17 00:00:00 2001 From: jason5ng32 Date: Sat, 22 Aug 2026 19:50:35 +0800 Subject: [PATCH 01/12] Refactor(i18n): central locale registry with glob discovery and exact-first matching One registry (common/locale-registry.js) now owns the locale list; the lazy-load maps, picker options, , zh->zh-CN mappings and test locale lists all derive from it, so adding a language becomes one JSON pack plus one registry line. Browser-language detection matches exact tags before base languages, clearing the path for regional packs like zh-TW. Pure refactor: behavior for the four shipped locales is unchanged except the picker now renders in registry order. Co-Authored-By: Claude Fable 5 --- common/langs.js | 7 +- common/locale-registry.js | 36 ++++++++ frontend/components/IpInfos.vue | 6 +- frontend/components/PrivacyPolicy.vue | 12 +-- .../advanced-tools/EnhancedDnsLeakTest.vue | 10 +-- .../advanced-tools/SecurityChecklist.vue | 12 +-- frontend/components/widgets/Preferences.vue | 9 +- frontend/components/widgets/QueryIP.vue | 3 +- frontend/composables/use-maxmind.js | 5 +- frontend/locales/i18n.js | 53 ++++++----- frontend/utils/locale-registry.js | 3 + tests/changelog.test.js | 3 +- tests/connectivity-import-lists.test.js | 3 +- tests/locale-registry.test.js | 89 +++++++++++++++++++ tests/persona-i18n.test.js | 20 +++-- tests/pulse-statuses.test.js | 3 +- tests/time-utils.test.js | 5 +- vite.config.js | 17 +++- 18 files changed, 218 insertions(+), 78 deletions(-) create mode 100644 common/locale-registry.js create mode 100644 frontend/utils/locale-registry.js create mode 100644 tests/locale-registry.test.js diff --git a/common/langs.js b/common/langs.js index 96d95100b..eb28b61d2 100644 --- a/common/langs.js +++ b/common/langs.js @@ -1,9 +1,10 @@ // Shared language allow-list for handlers that accept a ?lang query param. // Each consumer keeps its own default; pickLang validates against this list. -// Mirrors the UI's locale list (zh maps to zh-CN before hitting the API). -// Unknown values — including stale clients still sending lang=tr — fall -// back to the caller's default via pickLang. +// Mirrors the apiTag column of common/locale-registry.js by hand, not by +// derivation: a new locale lands here only once the upstream is confirmed to +// accept its tag. Unknown values — including stale clients still sending +// lang=tr — fall back to the caller's default via pickLang. export const SUPPORTED_LANGS = ['zh-CN', 'en', 'fr', 'ru']; // Return raw if it's a supported language, otherwise the given fallback. diff --git a/common/locale-registry.js b/common/locale-registry.js new file mode 100644 index 000000000..7b7da0095 --- /dev/null +++ b/common/locale-registry.js @@ -0,0 +1,36 @@ +// Central registry of the languages the UI ships. Adding one = a locale pack +// plus a line here. Front end imports it via `@/utils/locale-registry.js`. +// +// One exception stays hand-maintained: the backend's `?lang` allow-list +// (common/langs.js) — the private upstream's tolerance for unknown tags is +// unverified. + +// code=UI code + locale file name · apiTag=tag sent upstream · +// htmlLang= · status=full|beta +export const LOCALES = [ + { code: 'en', nativeName: 'English', flag: 'us', apiTag: 'en', htmlLang: 'en', status: 'full' }, + { code: 'zh', nativeName: '简体中文', flag: 'cn', apiTag: 'zh-CN', htmlLang: 'zh-CN', status: 'full' }, + { code: 'fr', nativeName: 'Français', flag: 'fr', apiTag: 'fr', htmlLang: 'fr', status: 'full' }, + { code: 'ru', nativeName: 'Русский', flag: 'ru', apiTag: 'ru', htmlLang: 'ru', status: 'full' }, +]; + +// Registry order — also the order the language picker renders. +export const LOCALE_CODES = LOCALES.map((locale) => locale.code); + +export const getLocale = (code) => LOCALES.find((locale) => locale.code === code); + +// Both mappings pass an unregistered code through rather than blanking it. +export const toApiTag = (code) => getLocale(code)?.apiTag ?? code; + +export const toHtmlLang = (code) => getLocale(code)?.htmlLang ?? code; + +// Exact match first, base language second — `zh-TW` prefers a zh-TW pack and +// only falls back to `zh` when there is none. +export const matchLocale = (tag, codes = LOCALE_CODES) => { + if (!tag) return null; + const wanted = String(tag).toLowerCase(); + const exact = codes.find((code) => code.toLowerCase() === wanted); + if (exact) return exact; + const base = wanted.split('-')[0]; + return codes.find((code) => code.toLowerCase() === base) ?? null; +}; diff --git a/frontend/components/IpInfos.vue b/frontend/components/IpInfos.vue index 52f7656ce..e53dda9d4 100644 --- a/frontend/components/IpInfos.vue +++ b/frontend/components/IpInfos.vue @@ -36,6 +36,7 @@ import { useMainStore } from '@/store'; import { useI18n } from 'vue-i18n'; import { trackEvent } from '@/utils/analytics'; import { isUsablePublicIP } from '@/utils/valid-ip.js'; +import { toApiTag } from '@/utils/locale-registry.js'; import { transformDataFromIPapi } from '@/utils/transform-ip-data.js'; import { getIPFromIPIP, getIPFromCloudflare_V4, getIPFromCloudflare_V6, getIPFromIPChecking64, getIPFromIPChecking4, getIPFromIPChecking6 } from '@/utils/getips'; import { emitAppEvent, waitForAppEvent } from '@/utils/app-events'; @@ -295,10 +296,7 @@ const fetchIPDetails = async (cardIndex, ip, sourceID = null) => { sourceID = sourceID || ipGeoSource.value; const card = ipDataCards[cardIndex]; card.ip = ip; - let setLang = lang.value; - if (setLang === 'zh') { - setLang = 'zh-CN'; - } + const setLang = toApiTag(lang.value); // Check if the IP data is already in the cache if (ipDataCache.has(ip)) { diff --git a/frontend/components/PrivacyPolicy.vue b/frontend/components/PrivacyPolicy.vue index c2dbf94ac..11bc0003e 100644 --- a/frontend/components/PrivacyPolicy.vue +++ b/frontend/components/PrivacyPolicy.vue @@ -78,12 +78,12 @@ const isPersonaCheckEnabled = computed(() => store.configs?.originalSite === tru // Privacy copy is loaded on demand per locale (mirrors the security-checklist // dataset pattern), then merged into i18n so t() / tm() can resolve it. -const privacyLoaders = { - en: () => import('@/locales/privacy/en.json'), - zh: () => import('@/locales/privacy/zh.json'), - fr: () => import('@/locales/privacy/fr.json'), - ru: () => import('@/locales/privacy/ru.json'), -}; +// Discovered by glob, keyed by locale code; a locale with no file of its own +// falls back to en in loadPrivacy() below. +const privacyPacks = import.meta.glob('../locales/privacy/*.json'); +const privacyLoaders = Object.fromEntries( + Object.entries(privacyPacks).map(([path, loader]) => [path.match(/([^/]+)\.json$/)[1], loader]), +); const loaded = new Set(); const ready = ref(false); diff --git a/frontend/components/advanced-tools/EnhancedDnsLeakTest.vue b/frontend/components/advanced-tools/EnhancedDnsLeakTest.vue index ed28d83be..dea365394 100644 --- a/frontend/components/advanced-tools/EnhancedDnsLeakTest.vue +++ b/frontend/components/advanced-tools/EnhancedDnsLeakTest.vue @@ -273,6 +273,7 @@ import { trackEvent } from '@/utils/analytics'; import { emitAppEvent } from '@/utils/app-events'; import { authenticatedFetch } from '@/utils/authenticated-fetch'; import getCountryName from '@/data/country-name.js'; +import { toApiTag } from '@/utils/locale-registry.js'; import { Button } from '@/components/ui/button'; import { Card, CardContent } from '@/components/ui/card'; import { Spinner } from '@/components/ui/spinner'; @@ -518,13 +519,8 @@ const animateProbes = async () => { } }; -// store.lang is 'zh' but the upstream keys on 'zh-CN' (same mapping as IpInfos/WebRtcTest). -const apiLang = () => { - const current = lang.value; - if (current === 'zh') return 'zh-CN'; - if (current && ['en', 'fr', 'ru', 'zh-CN'].includes(current)) return current; - return 'zh-CN'; -}; +// store.lang is a UI code ('zh'); the upstream keys on the apiTag ('zh-CN'). +const apiLang = () => toApiTag(lang.value); // authenticatedFetch attaches the Firebase ID token as Authorization; our // backend then forwards request headers to the upstream IPCheck.ing API. diff --git a/frontend/components/advanced-tools/SecurityChecklist.vue b/frontend/components/advanced-tools/SecurityChecklist.vue index bb8f234e2..e9f2c31c9 100644 --- a/frontend/components/advanced-tools/SecurityChecklist.vue +++ b/frontend/components/advanced-tools/SecurityChecklist.vue @@ -317,12 +317,12 @@ const { t, locale } = useI18n(); // The checklist dataset is large (~30 KB gzipped per language) and only this tool // reads it, so it's loaded on demand for the active locale instead of being baked // into the initial i18n bundle (see frontend/locales/i18n.js). -const securityDataLoaders = { - en: () => import('@/locales/security-checklist/en.json'), - zh: () => import('@/locales/security-checklist/zh.json'), - fr: () => import('@/locales/security-checklist/fr.json'), - ru: () => import('@/locales/security-checklist/ru.json'), -}; +// Discovered by glob, keyed by locale code; a locale with no dataset of its +// own falls back to en in loadSecurityChecklist() below. +const securityDataPacks = import.meta.glob('../../locales/security-checklist/*.json'); +const securityDataLoaders = Object.fromEntries( + Object.entries(securityDataPacks).map(([path, loader]) => [path.match(/([^/]+)\.json$/)[1], loader]), +); const securityChecklist = ref(null); diff --git a/frontend/components/widgets/Preferences.vue b/frontend/components/widgets/Preferences.vue index 9a9ad2bbd..99af1d973 100644 --- a/frontend/components/widgets/Preferences.vue +++ b/frontend/components/widgets/Preferences.vue @@ -174,6 +174,7 @@ import { useI18n } from 'vue-i18n'; import { trackEvent } from '@/utils/analytics'; import { emitAppEvent } from '@/utils/app-events.js'; import { clampRetentionDays } from '@/utils/ip-history.js'; +import { LOCALES } from '@/utils/locale-registry.js'; import { Sheet, SheetContent, SheetClose } from '@/components/ui/sheet'; import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group'; import { Slider } from '@/components/ui/slider'; @@ -206,13 +207,11 @@ const onOpenChange = (val) => { store.setOpenSheet(val ? 'preferences' : null); }; -// Language options (data driven; flag use circle-flags ISO code) +// "Follow the system" plus one entry per registered locale (flag = circle-flags +// ISO code). const langOptions = [ { value: 'auto', label: t('nav.preferences.systemAuto'), flag: '' }, - { value: 'zh', label: '中文', flag: 'cn' }, - { value: 'en', label: 'English', flag: 'us' }, - { value: 'ru', label: 'Русский', flag: 'ru' }, - { value: 'fr', label: 'Français', flag: 'fr' }, + ...LOCALES.map(({ code, nativeName, flag }) => ({ value: code, label: nativeName, flag })), ]; const currentLang = computed(() => langOptions.find(l => l.value === userPreferences.value.lang) || langOptions[0] diff --git a/frontend/components/widgets/QueryIP.vue b/frontend/components/widgets/QueryIP.vue index f2e1d7142..870a988f4 100644 --- a/frontend/components/widgets/QueryIP.vue +++ b/frontend/components/widgets/QueryIP.vue @@ -63,6 +63,7 @@ import { ref, computed, watch, nextTick } from 'vue'; import { useMainStore } from '@/store'; import { isValidIP, isUsablePublicIP } from '@/utils/valid-ip.js'; +import { toApiTag } from '@/utils/locale-registry.js'; import FitText from '@/components/widgets/FitText.vue'; import { HERO_TIERS } from '@/composables/use-fit-text.js'; import { transformDataFromIPapi } from '@/utils/transform-ip-data.js'; @@ -142,7 +143,7 @@ const openQueryIP = () => { const openModal = () => onOpenChange(true); const fetchIPForModal = async (ip) => { - const selectedLang = lang.value === 'zh' ? 'zh-CN' : lang.value; + const selectedLang = toApiTag(lang.value); const sources = store.ipDBs.filter(s => s.enabled); // Cyclic walk from the user's preferred source diff --git a/frontend/composables/use-maxmind.js b/frontend/composables/use-maxmind.js index 7e642c13b..31bd0d7c4 100644 --- a/frontend/composables/use-maxmind.js +++ b/frontend/composables/use-maxmind.js @@ -16,6 +16,7 @@ import { useI18n } from 'vue-i18n'; import { fetchWithTimeout } from '../utils/fetch-with-timeout.js'; import { transformDataFromIPapi } from '../utils/transform-ip-data.js'; import getCountryName from '../data/country-name.js'; +import { toApiTag } from '../utils/locale-registry.js'; // key → resolved result (successes only) / key → in-flight promise. const lookupCache = new Map(); @@ -50,8 +51,8 @@ export function useMaxmind() { if (!source) return null; const lang = store.lang; - // ip-api.com style locale tag — same mapping the legacy call sites used. - const apiLang = lang === 'zh' ? 'zh-CN' : lang; + // ip-api.com style locale tag. + const apiLang = toApiTag(lang); // `country` below is localized, so the cache key must carry the lang. return dedupedLookup(`${ip}|${lang}`, async () => { diff --git a/frontend/locales/i18n.js b/frontend/locales/i18n.js index 1a9a8effb..a9d9d49c4 100644 --- a/frontend/locales/i18n.js +++ b/frontend/locales/i18n.js @@ -1,5 +1,6 @@ import { createI18n } from 'vue-i18n'; import { PREFS_STORAGE_KEY } from '../data/default-preferences.js'; +import { LOCALE_CODES, matchLocale, toHtmlLang } from '../utils/locale-registry.js'; // Locale messages are loaded on demand so the first-paint path carries only the // language actually in use. Bundling all four eagerly cost ~44 KB gzipped of dead @@ -10,12 +11,19 @@ import { PREFS_STORAGE_KEY } from '../data/default-preferences.js'; // NOTE: the security-checklist datasets (security-checklist/*.json) are likewise // kept off this path — that tool loads its own locale's dataset on demand // (see SecurityChecklist.vue). -const localeLoaders = { - en: () => import('./en.json'), - zh: () => import('./zh.json'), - fr: () => import('./fr.json'), - ru: () => import('./ru.json'), -}; +// +// Packs are discovered by glob, the registry decides which of them the UI +// offers. The glob is a Vite build-time macro, and the Node test runner +// imports this module for real (through store.js) — hence the guard. +let localePacks = {}; +try { + localePacks = import.meta.glob('./*.json'); +} catch { /* not running under Vite */ } +const localeLoaders = Object.fromEntries( + LOCALE_CODES + .filter((code) => localePacks[`./${code}.json`]) + .map((code) => [code, localePacks[`./${code}.json`]]), +); const supportedLanguages = Object.keys(localeLoaders); const FALLBACK_LOCALE = 'en'; @@ -32,27 +40,19 @@ function readStoredLang() { return null; } -// Set language. -function setLanguage() { +// Stored preference → ?hl= → browser language → en. `?hl=` is an explicit +// request and matches a code exactly; a browser language is a system setting, +// so matchLocale accepts its regional tags too. +const setLanguage = () => { const storedLang = readStoredLang(); if (storedLang) return storedLang; - let locale = 'en'; - const searchParams = new URLSearchParams(window.location.search); + const hl = new URLSearchParams(window.location.search).get('hl'); + if (hl) return supportedLanguages.includes(hl) ? hl : 'en'; + const browserLanguage = navigator.language || navigator.userLanguage; - const hl = searchParams.get('hl'); - if (hl && supportedLanguages.includes(hl)) { - locale = hl; - } else if (!hl) { - const bl = browserLanguage.substring(0, 2); - if (supportedLanguages.includes(bl)) { - locale = bl; - } else { - locale = 'en'; - } - } - return locale; -} + return matchLocale(browserLanguage, supportedLanguages) || 'en'; +}; const activeLocale = setLanguage(); @@ -91,10 +91,9 @@ function updateMeta() { // browser auto-translate mis-detect the page and offer to re-translate // already-translated content (Chrome-iOS translate churn crashes on the // home page's high-frequency DOM updates). Also what screen readers key on. - // Our zh locale is Simplified-only: declare zh-CN so Han glyph fallback - // stays Simplified on ja / zh-TW systems and translate prompts treat the - // content unambiguously. The other locale codes are precise as-is. - document.documentElement.lang = activeLocale === 'zh' ? 'zh-CN' : activeLocale; + // htmlLang is the precise tag: zh declares zh-CN so Han glyph fallback stays + // Simplified on ja / zh-TW systems. + document.documentElement.lang = toHtmlLang(activeLocale); document.title = i18n.global.t('page.title'); diff --git a/frontend/utils/locale-registry.js b/frontend/utils/locale-registry.js new file mode 100644 index 000000000..2e9843e3d --- /dev/null +++ b/frontend/utils/locale-registry.js @@ -0,0 +1,3 @@ +// Thin re-export of common/locale-registry.js (shared with the tests) so +// front-end code keeps importing from `@/utils/...`. +export { LOCALES, LOCALE_CODES, getLocale, toApiTag, toHtmlLang, matchLocale } from '../../common/locale-registry.js'; diff --git a/tests/changelog.test.js b/tests/changelog.test.js index 0cb9c93dd..c02aa0d75 100644 --- a/tests/changelog.test.js +++ b/tests/changelog.test.js @@ -6,8 +6,9 @@ import { describe, it } from 'node:test'; import fs from 'node:fs'; import changelog from '../frontend/data/changelog.json' with { type: 'json' }; +import { LOCALE_CODES } from '../common/locale-registry.js'; -const REQUIRED_LOCALES = ['en', 'zh', 'fr', 'ru']; +const REQUIRED_LOCALES = 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..8d51448ea 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 { 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`); @@ -146,7 +147,7 @@ describe('import lists data integrity', () => { // 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 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/locale-registry.test.js b/tests/locale-registry.test.js new file mode 100644 index 000000000..63b2bb53c --- /dev/null +++ b/tests/locale-registry.test.js @@ -0,0 +1,89 @@ +// Unit tests for common/locale-registry.js: the entry shape every consumer +// relies on, plus the two shared mappings — apiTag and browser-language +// matching. + +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; +import fs from 'node:fs'; + +import { + LOCALES, + LOCALE_CODES, + getLocale, + toApiTag, + toHtmlLang, + matchLocale, +} from '../common/locale-registry.js'; +import { LOCALES as BRIDGED } from '../frontend/utils/locale-registry.js'; + +describe('locale registry entries', () => { + it('every entry carries the full field set', () => { + for (const locale of LOCALES) { + assert.match(locale.code, /^[a-z]{2}(-[A-Za-z]{2,4})?$/, `bad code ${locale.code}`); + assert.ok(locale.nativeName?.length, `${locale.code}: no nativeName`); + assert.match(locale.flag, /^[a-z]{2}$/, `${locale.code}: flag must be a circle-flags code`); + assert.ok(locale.apiTag?.length, `${locale.code}: no apiTag`); + assert.ok(locale.htmlLang?.length, `${locale.code}: no htmlLang`); + assert.ok(['full', 'beta'].includes(locale.status), `${locale.code}: bad status`); + } + }); + + it('codes are unique and en is registered (it is the fallback locale)', () => { + assert.equal(new Set(LOCALE_CODES).size, LOCALE_CODES.length); + assert.ok(LOCALE_CODES.includes('en')); + }); + + it('every registered code has a message pack on disk', () => { + for (const code of LOCALE_CODES) { + assert.ok(fs.existsSync(new URL(`../frontend/locales/${code}.json`, import.meta.url)), + `frontend/locales/${code}.json is missing`); + } + }); + + it('the front-end bridge re-exports the same registry', () => { + assert.equal(BRIDGED, LOCALES); + }); + + it('getLocale finds an entry and returns undefined for anything else', () => { + assert.equal(getLocale('zh').nativeName, '简体中文'); + assert.equal(getLocale('tr'), undefined); + }); +}); + +describe('toApiTag / toHtmlLang', () => { + it('maps zh to zh-CN and leaves the others alone', () => { + assert.equal(toApiTag('zh'), 'zh-CN'); + assert.equal(toHtmlLang('zh'), 'zh-CN'); + for (const code of ['en', 'fr', 'ru']) { + assert.equal(toApiTag(code), code); + assert.equal(toHtmlLang(code), code); + } + }); + + it('passes an unregistered code through untouched', () => { + assert.equal(toApiTag('tr'), 'tr'); + assert.equal(toHtmlLang('tr'), 'tr'); + }); +}); + +describe('matchLocale', () => { + it('prefers an exact match over the base language', () => { + assert.equal(matchLocale('zh-TW', ['zh', 'zh-TW']), 'zh-TW'); + assert.equal(matchLocale('zh-TW', ['zh']), 'zh'); + }); + + it('matches case-insensitively and strips any subtags', () => { + assert.equal(matchLocale('EN-US'), 'en'); + assert.equal(matchLocale('zh-Hans-CN'), 'zh'); + }); + + it('never widens a base tag into a regional pack', () => { + assert.equal(matchLocale('zh', ['en', 'zh-TW']), null); + }); + + it('returns null for an unknown or empty tag', () => { + assert.equal(matchLocale('tr'), null); + assert.equal(matchLocale(''), null); + assert.equal(matchLocale(undefined), null); + }); +}); diff --git a/tests/persona-i18n.test.js b/tests/persona-i18n.test.js index 2f0486adb..cdc35a81b 100644 --- a/tests/persona-i18n.test.js +++ b/tests/persona-i18n.test.js @@ -1,6 +1,6 @@ // Guards the Persona Check's i18n coverage the way changelog.test.js // guards the changelog: a check id, a not-applicable reason or a detail field -// arriving from the evaluating API without its four locale entries fails here +// arriving from the evaluating API without its locale entries fails here // rather than rendering a raw key in front of a visitor. // // The expected vocabulary comes from utils/persona/check-ids.js, which is this @@ -9,11 +9,9 @@ import assert from 'node:assert/strict'; import { describe, it } from 'node:test'; +import fs from 'node:fs'; -import en from '../frontend/locales/en.json' with { type: 'json' }; -import zh from '../frontend/locales/zh.json' with { type: 'json' }; -import fr from '../frontend/locales/fr.json' with { type: 'json' }; -import ru from '../frontend/locales/ru.json' with { type: 'json' }; +import { LOCALE_CODES } from '../common/locale-registry.js'; import { PERSONA_CHECK_IDS, PERSONA_DETAIL_KEYS, @@ -29,7 +27,11 @@ import { // the unknown reasons whitelisted to explain themselves. const RENDERED_REASONS = [...PERSONA_NOT_APPLICABLE_REASONS, ...PERSONA_UNKNOWN_REASONS]; -const LOCALES = { en, zh, fr, ru }; +// Read from disk so the registry alone decides which languages are checked. +const LOCALES = Object.fromEntries(LOCALE_CODES.map((code) => [ + code, + JSON.parse(fs.readFileSync(new URL(`../frontend/locales/${code}.json`, import.meta.url), 'utf8')), +])); const flatten = (value, prefix = '') => { const keys = new Set(); @@ -45,7 +47,7 @@ const flatten = (value, prefix = '') => { }; describe('persona check i18n coverage', () => { - it('every check has a title, a description and a fix in all four locales', () => { + it('every check has a title, a description and a fix in every locale', () => { for (const id of PERSONA_CHECK_IDS) { for (const [lang, pack] of Object.entries(LOCALES)) { const entry = pack.personacheck.checks[id]; @@ -145,8 +147,8 @@ describe('persona check i18n coverage', () => { } }); - it('keeps the four locales structurally identical', () => { - const reference = flatten(en.personacheck); + it('keeps every locale structurally identical to en', () => { + const reference = flatten(LOCALES.en.personacheck); for (const [lang, pack] of Object.entries(LOCALES)) { if (lang === 'en') continue; const other = flatten(pack.personacheck); diff --git a/tests/pulse-statuses.test.js b/tests/pulse-statuses.test.js index 906a4f547..d62e136f4 100644 --- a/tests/pulse-statuses.test.js +++ b/tests/pulse-statuses.test.js @@ -14,8 +14,9 @@ import { festivalsActiveOn, localDateString, } from '../frontend/data/pulse-statuses.js'; +import { LOCALE_CODES } from '../common/locale-registry.js'; -const REQUIRED_LOCALES = ['en', 'zh', 'fr', 'ru']; +const REQUIRED_LOCALES = LOCALE_CODES; const DATE_RE = /^\d{4}-\d{2}-\d{2}$/; const MONTH_DAY_RE = /^\d{2}-\d{2}$/; const ALL = [...PRESET_STATUSES, ...FESTIVAL_STATUSES]; diff --git a/tests/time-utils.test.js b/tests/time-utils.test.js index 1744d5fbc..acc2fdb40 100644 --- a/tests/time-utils.test.js +++ b/tests/time-utils.test.js @@ -19,6 +19,7 @@ import { isoToDateTime, formatIsoDate, } from '../frontend/utils/time-utils.js'; +import { LOCALE_CODES } from '../common/locale-registry.js'; /* ------------------------------------------------------------------ */ /* UTC offsets & zone wall-clock time */ @@ -150,7 +151,7 @@ describe('relativeTimeFromMinutes', () => { }); it('separates numerals from their unit in every shipped locale', () => { - for (const locale of ['en', 'zh', 'fr', 'ru']) { + for (const locale of LOCALE_CODES) { for (const minutes of [5, 90, 3 * 24 * 60]) { assertSpacedNumerals(relativeTimeFromMinutes(minutes, locale)); } @@ -190,7 +191,7 @@ describe('formatDuration', () => { // Duration stays deliberately compact ("2d 3h"), so only the CJK rule applies. it('spaces numerals against CJK units', () => { - for (const locale of ['en', 'zh', 'fr', 'ru']) { + for (const locale of LOCALE_CODES) { assertCjkSpacing(formatDuration(26 * 60 * 60 * 1000, locale)); } assert.match(formatDuration(26 * 60 * 60 * 1000, 'zh'), /1 天 2 小时/); diff --git a/vite.config.js b/vite.config.js index 8c3ea1b10..08579bf93 100644 --- a/vite.config.js +++ b/vite.config.js @@ -85,6 +85,16 @@ function siteUrlHtmlPlugin() { const localePreloadPlugin = () => { const preloadScript = (chunks) => `(function () { var chunks = ${JSON.stringify(chunks)}; + // Mirrors matchLocale() in common/locale-registry.js. + var match = function (tag) { + var wanted = String(tag || '').toLowerCase(); + if (!wanted) return null; + var codes = Object.keys(chunks); + for (var i = 0; i < codes.length; i++) if (codes[i].toLowerCase() === wanted) return codes[i]; + var base = wanted.split('-')[0]; + for (var j = 0; j < codes.length; j++) if (codes[j].toLowerCase() === base) return codes[j]; + return null; + }; var lang = null; try { var stored = JSON.parse(localStorage.getItem(${JSON.stringify(PREFS_STORAGE_KEY)}) || '{}').lang; @@ -95,8 +105,7 @@ const localePreloadPlugin = () => { if (hl) { lang = chunks[hl] ? hl : 'en'; } else { - var bl = (navigator.language || '').slice(0, 2).toLowerCase(); - lang = chunks[bl] ? bl : 'en'; + lang = match(navigator.language) || 'en'; } } (lang === 'en' ? ['en'] : [lang, 'en']).forEach(function (l) { @@ -118,7 +127,9 @@ const localePreloadPlugin = () => { for (const [fileName, chunk] of Object.entries(ctx.bundle || {})) { if (chunk.type !== 'chunk') continue; const facade = (chunk.facadeModuleId || '').replaceAll('\\', '/'); - const match = facade.match(/\/frontend\/locales\/([a-z]{2})\.json$/); + // Optional region subtag leaves room for `zh-TW`; the privacy / + // security-checklist packs sit a folder deeper and never match. + const match = facade.match(/\/frontend\/locales\/([a-z]{2}(?:-[A-Za-z]{2,4})?)\.json$/); if (match) chunks[match[1]] = '/' + fileName; } if (Object.keys(chunks).length === 0) return html; From 6301d8b83825d8a9deb93e06dc6737fa5b9f4ed4 Mon Sep 17 00:00:00 2001 From: jason5ng32 Date: Sat, 22 Aug 2026 20:13:14 +0800 Subject: [PATCH 02/12] Feat(i18n): beta locale support with fallback chains, pack gate and status dashboard A locale can now ship partially translated: registry status drives a Beta badge in the picker, missing keys walk variant -> base -> en (one chain definition feeds vue-i18n, the privacy/checklist datasets and the changelog), and matchLocale gained family matching so pt-PT finds a pt-BR pack. Changelog history is only required of full locales, removing the biggest barrier for new-language PRs. tests/locale-packs.test.js is the hard gate a pack must pass; scripts/i18n-status.js (un-gitignored for contributors) reports coverage without ever failing. Only visible behavior change: ?hl= resolves regional tags (?hl=zh-CN -> zh) instead of dropping to English. Co-Authored-By: Claude Fable 5 --- .gitignore | 6 +- common/locale-registry.js | 28 +++- frontend/components/Footer.vue | 8 +- frontend/components/PrivacyPolicy.vue | 13 +- .../advanced-tools/SecurityChecklist.vue | 5 +- frontend/components/widgets/Preferences.vue | 8 +- frontend/locales/en.json | 2 +- frontend/locales/fr.json | 2 +- frontend/locales/i18n.js | 47 +++--- frontend/locales/ru.json | 2 +- frontend/locales/zh.json | 2 +- frontend/utils/locale-registry.js | 12 +- package.json | 3 +- scripts/i18n-status.js | 86 +++++++++++ tests/changelog.test.js | 6 +- tests/connectivity-import-lists.test.js | 6 +- tests/locale-packs.test.js | 135 ++++++++++++++++++ tests/locale-registry.test.js | 54 +++++-- tests/persona-i18n.test.js | 7 +- tests/pulse-statuses.test.js | 5 +- vite.config.js | 29 ++-- 21 files changed, 394 insertions(+), 72 deletions(-) create mode 100644 scripts/i18n-status.js create mode 100644 tests/locale-packs.test.js diff --git a/.gitignore b/.gitignore index d19780a0f..b5ee05809 100644 --- a/.gitignore +++ b/.gitignore @@ -54,10 +54,12 @@ 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 i18n-status.js +# to see how far along a translation is) scripts/* !scripts/fetch-favicons.js +!scripts/i18n-status.js # Section banners — deploy-time data (ads and campaign promos stay out of git) frontend/data/banners/* diff --git a/common/locale-registry.js b/common/locale-registry.js index 7b7da0095..b5ff13c35 100644 --- a/common/locale-registry.js +++ b/common/locale-registry.js @@ -17,6 +17,11 @@ export const LOCALES = [ // Registry order — also the order the language picker renders. export const LOCALE_CODES = LOCALES.map((locale) => locale.code); +// Locales held to full coverage; beta ones may ship a partial pack. +export const FULL_LOCALE_CODES = LOCALES.filter((l) => l.status === 'full').map((l) => l.code); + +export const FALLBACK_LOCALE = 'en'; + export const getLocale = (code) => LOCALES.find((locale) => locale.code === code); // Both mappings pass an unregistered code through rather than blanking it. @@ -24,13 +29,26 @@ export const toApiTag = (code) => getLocale(code)?.apiTag ?? code; export const toHtmlLang = (code) => getLocale(code)?.htmlLang ?? code; -// Exact match first, base language second — `zh-TW` prefers a zh-TW pack and -// only falls back to `zh` when there is none. +// The chain a missing translation walks: variant → base → en. A base only +// joins the chain when it is registered. This is the single definition of the +// order — i18n, the privacy / checklist datasets and the changelog all use it. +export const fallbackChain = (code) => { + const chain = [code]; + const base = String(code).split('-')[0]; + if (base !== code && LOCALE_CODES.includes(base)) chain.push(base); + if (!chain.includes(FALLBACK_LOCALE)) chain.push(FALLBACK_LOCALE); + return chain; +}; + +// Resolve a BCP-47 tag against `codes` in three steps: exact, then the base +// language, then any locale of that family (pt-PT → pt-BR), registry order +// deciding between siblings. export const matchLocale = (tag, codes = LOCALE_CODES) => { if (!tag) return null; const wanted = String(tag).toLowerCase(); - const exact = codes.find((code) => code.toLowerCase() === wanted); - if (exact) return exact; const base = wanted.split('-')[0]; - return codes.find((code) => code.toLowerCase() === base) ?? null; + return codes.find((code) => code.toLowerCase() === wanted) + ?? codes.find((code) => code.toLowerCase() === base) + ?? codes.find((code) => code.toLowerCase().split('-')[0] === base) + ?? null; }; diff --git a/frontend/components/Footer.vue b/frontend/components/Footer.vue index 4a4e0a60c..475e083ec 100644 --- a/frontend/components/Footer.vue +++ b/frontend/components/Footer.vue @@ -125,7 +125,7 @@ - {{ item.change[locale] || item.change.en }} + {{ changeText(item.change) }} @@ -165,6 +165,7 @@ import { useI18n } from 'vue-i18n'; import changelogData from '@/data/changelog.json'; import { trackEvent } from '@/utils/analytics'; import { formatIsoDate } from '@/utils/time-utils'; +import { fallbackChain } from '@/utils/locale-registry.js'; import { Sheet, SheetContent, SheetClose } from '@/components/ui/sheet'; import { JnTooltip } from '@/components/ui/tooltip'; import { Button } from '@/components/ui/button'; @@ -184,6 +185,11 @@ const tabs = ['about', 'changelog', 'acknowledgement']; const content = ref('about'); // Static data from JSON — reverse once via computed so the template stays tidy. const changelogReversed = computed(() => changelogData.slice().reverse()); + +// Entries carry one string per language; history is only guaranteed for full +// locales, so a beta one walks its fallback chain. +const changeText = (change) => fallbackChain(locale.value).map((code) => change[code]).find(Boolean) ?? ''; + const sheetBody = ref(null); const personalLinks = [ diff --git a/frontend/components/PrivacyPolicy.vue b/frontend/components/PrivacyPolicy.vue index 11bc0003e..1cdf0f2da 100644 --- a/frontend/components/PrivacyPolicy.vue +++ b/frontend/components/PrivacyPolicy.vue @@ -47,6 +47,7 @@ import { useMainStore } from '@/store'; import { isAnalyticsEnabled } from '@/utils/analytics'; import { isDocsConfigured } from '@/composables/use-docs-assistant.js'; import { useDocumentMeta } from '@/composables/use-document-meta.js'; +import { fallbackChain } from '@/utils/locale-registry.js'; import Footer from '@/components/Footer.vue'; import StandalonePageHeader from '@/components/StandalonePageHeader.vue'; import { Spinner } from '@/components/ui/spinner'; @@ -79,7 +80,7 @@ const isPersonaCheckEnabled = computed(() => store.configs?.originalSite === tru // Privacy copy is loaded on demand per locale (mirrors the security-checklist // dataset pattern), then merged into i18n so t() / tm() can resolve it. // Discovered by glob, keyed by locale code; a locale with no file of its own -// falls back to en in loadPrivacy() below. +// resolves to the first one on its fallback chain that has one. const privacyPacks = import.meta.glob('../locales/privacy/*.json'); const privacyLoaders = Object.fromEntries( Object.entries(privacyPacks).map(([path, loader]) => [path.match(/([^/]+)\.json$/)[1], loader]), @@ -93,21 +94,21 @@ const ready = ref(false); // active locale and paint the wrong language. const loadPrivacy = async (loc) => { if (loaded.has(loc)) return; - const load = privacyLoaders[loc] || privacyLoaders.en; + const load = fallbackChain(loc).map((code) => privacyLoaders[code]).find(Boolean); const { default: msgs } = await load(); mergeLocaleMessage(loc, msgs); loaded.add(loc); }; -// Reveal only after the ACTIVE locale's copy is merged — otherwise the en -// fallback load could resolve first and paint English (t() falling back) until -// the next re-render. The en fallback (covering any key the active locale might +// Reveal only after the ACTIVE locale's copy is merged — otherwise a fallback +// load could resolve first and paint English (t() falling back) until the next +// re-render. The rest of the chain (covering any key the active locale might // miss) loads in the background and doesn't gate the reveal. watch(locale, async (loc) => { ready.value = false; await loadPrivacy(loc); if (loc === locale.value) ready.value = true; // ignore a stale load if locale changed mid-flight - if (loc !== 'en') loadPrivacy('en'); + for (const code of fallbackChain(loc).slice(1)) loadPrivacy(code); }, { immediate: true }); // Ordered section ids, gated on which collection actually happens here. The diff --git a/frontend/components/advanced-tools/SecurityChecklist.vue b/frontend/components/advanced-tools/SecurityChecklist.vue index e9f2c31c9..f128a0165 100644 --- a/frontend/components/advanced-tools/SecurityChecklist.vue +++ b/frontend/components/advanced-tools/SecurityChecklist.vue @@ -257,6 +257,7 @@ import { useMainStore } from '@/store'; import { useI18n } from 'vue-i18n'; import { trackEvent } from '@/utils/analytics'; import { emitAppEvent } from '@/utils/app-events.js'; +import { fallbackChain } from '@/utils/locale-registry.js'; import { CircleProgressBar } from 'circle-progress.vue'; import VueMarkdown from 'vue-markdown-render'; import { Collapsible, CollapsibleTrigger, CollapsibleContent } from '@/components/ui/collapsible'; @@ -318,7 +319,7 @@ const { t, locale } = useI18n(); // reads it, so it's loaded on demand for the active locale instead of being baked // into the initial i18n bundle (see frontend/locales/i18n.js). // Discovered by glob, keyed by locale code; a locale with no dataset of its -// own falls back to en in loadSecurityChecklist() below. +// own resolves to the first one on its fallback chain that has one. const securityDataPacks = import.meta.glob('../../locales/security-checklist/*.json'); const securityDataLoaders = Object.fromEntries( Object.entries(securityDataPacks).map(([path, loader]) => [path.match(/([^/]+)\.json$/)[1], loader]), @@ -330,7 +331,7 @@ const securityChecklist = ref(null); // surfaces the template's existing loading state during the swap. const loadSecurityChecklist = async () => { fullList.value = null; - const load = securityDataLoaders[locale.value] || securityDataLoaders.en; + const load = fallbackChain(locale.value).map((code) => securityDataLoaders[code]).find(Boolean); const { default: data } = await load(); securityChecklist.value = data; fullList.value = initSecurityList(securityChecklist.value); diff --git a/frontend/components/widgets/Preferences.vue b/frontend/components/widgets/Preferences.vue index 99af1d973..99230e682 100644 --- a/frontend/components/widgets/Preferences.vue +++ b/frontend/components/widgets/Preferences.vue @@ -41,6 +41,9 @@ class="size-4 shrink-0" /> {{ lang.label }} + + Beta @@ -180,6 +183,7 @@ import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group'; import { Slider } from '@/components/ui/slider'; import { Switch } from '@/components/ui/switch'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; +import { Badge } from '@/components/ui/badge'; import { Icon } from '@iconify/vue'; import { AppWindow, @@ -211,7 +215,9 @@ const onOpenChange = (val) => { // ISO code). const langOptions = [ { value: 'auto', label: t('nav.preferences.systemAuto'), flag: '' }, - ...LOCALES.map(({ code, nativeName, flag }) => ({ value: code, label: nativeName, flag })), + ...LOCALES.map(({ code, nativeName, flag, status }) => ({ + value: code, label: nativeName, flag, beta: status === 'beta', + })), ]; const currentLang = computed(() => langOptions.find(l => l.value === userPreferences.value.lang) || langOptions[0] diff --git a/frontend/locales/en.json b/frontend/locales/en.json index c03384ddf..c7398c0a1 100644 --- a/frontend/locales/en.json +++ b/frontend/locales/en.json @@ -1418,7 +1418,7 @@ "PersonaCheck": "Open In-depth Persona Check panel" }, "page": { - "title": "IPCheck.ing - Check My IP Address and Geolocation - IP Leak Test - DNS Leak Test - IP Quality Check - Test Network Speed - Jason Ng Open Source", + "title": "IPCheck.ing - Check My IP Address and Geolocation - IP Leak Test - DNS Leak Test - IP Quality Check - Test Network Speed - All-in-one IP Toolbox", "description": "A better and open-source IP toolbox: check your IP address & geolocation, test IP for WebRTC and DNS IP leaks, run an IP quality check, browser fingerprint check, plus speed test, global latency test, MTR test, Whois search, and more.", "keywords": "MyIP, IP tool, IP check, IP Leak Check, IP quality check, IP lookup, DNS leak test, WebRTC leak test, browser fingerprint check, speed test, global latency test, MTR test, DNS lookup, Whois search", "footerLink": "https://github.com/jason5ng32/MyIP", diff --git a/frontend/locales/fr.json b/frontend/locales/fr.json index 27425d9ba..f09321693 100644 --- a/frontend/locales/fr.json +++ b/frontend/locales/fr.json @@ -1418,7 +1418,7 @@ "PersonaCheck": "Ouvrir le panneau Vérification de persona approfondie" }, "page": { - "title": "IPCheck.ing - Vérifier mon adresse IP et géolocalisation - Test de fuite IP - Test de fuite DNS - Vérification de la qualité IP - Test de vitesse réseau - Jason Ng Open Source", + "title": "IPCheck.ing - Vérifier mon adresse IP et géolocalisation - Test de fuite IP - Test de fuite DNS - Vérification de la qualité IP - Test de vitesse réseau - Boîte à outils IP tout-en-un", "description": "Une boîte à outils IP open source et tout-en-un : vérifiez votre adresse IP et sa géolocalisation, testez les fuites IP via WebRTC et DNS, effectuez une vérification de la qualité IP, une vérification de l'empreinte du navigateur, ainsi qu'un test de vitesse, un test de latence mondiale, un test MTR, une recherche Whois, et plus encore.", "keywords": "MonIP, Outil IP, Vérification IP, Test de fuite IP, Test de fuite DNS, Qualité IP, Test de fuite WebRTC, Empreinte du navigateur, Test de vitesse, Test de latence mondiale, Test MTR, Recherche DNS, Recherche Whois", "footerLink": "https://github.com/jason5ng32/MyIP", diff --git a/frontend/locales/i18n.js b/frontend/locales/i18n.js index a9d9d49c4..2109559ee 100644 --- a/frontend/locales/i18n.js +++ b/frontend/locales/i18n.js @@ -1,6 +1,12 @@ import { createI18n } from 'vue-i18n'; import { PREFS_STORAGE_KEY } from '../data/default-preferences.js'; -import { LOCALE_CODES, matchLocale, toHtmlLang } from '../utils/locale-registry.js'; +import { + LOCALE_CODES, + FALLBACK_LOCALE, + fallbackChain, + matchLocale, + toHtmlLang, +} from '../utils/locale-registry.js'; // Locale messages are loaded on demand so the first-paint path carries only the // language actually in use. Bundling all four eagerly cost ~44 KB gzipped of dead @@ -26,7 +32,6 @@ const localeLoaders = Object.fromEntries( ); const supportedLanguages = Object.keys(localeLoaders); -const FALLBACK_LOCALE = 'en'; // Read the saved language from the current prefs key. The key comes from // default-preferences.js so it stays in step with what store.js writes. @@ -40,27 +45,40 @@ function readStoredLang() { return null; } -// Stored preference → ?hl= → browser language → en. `?hl=` is an explicit -// request and matches a code exactly; a browser language is a system setting, -// so matchLocale accepts its regional tags too. +// Stored preference → ?hl= → browser language → en. Both tags go through +// matchLocale, so a regional one (?hl=zh-CN, a zh-TW browser) lands on the +// closest pack instead of dropping to English. const setLanguage = () => { const storedLang = readStoredLang(); if (storedLang) return storedLang; const hl = new URLSearchParams(window.location.search).get('hl'); - if (hl) return supportedLanguages.includes(hl) ? hl : 'en'; + if (hl) return matchLocale(hl, supportedLanguages) || FALLBACK_LOCALE; const browserLanguage = navigator.language || navigator.userLanguage; - return matchLocale(browserLanguage, supportedLanguages) || 'en'; + return matchLocale(browserLanguage, supportedLanguages) || FALLBACK_LOCALE; }; const activeLocale = setLanguage(); -// Create i18n instance (messages are empty at startup, injected by loadActiveLocaleMessages). +// Per-locale fallback chains; only regional variants need one of their own +// (zh-TW → zh → en), everything else takes the default. +const fallbackLocale = Object.fromEntries( + LOCALE_CODES + .map((code) => [code, fallbackChain(code).slice(1)]) + .filter(([, chain]) => chain.length > 1), +); +fallbackLocale.default = [FALLBACK_LOCALE]; + +// Messages are empty at startup, injected by loadActiveLocaleMessages. +// A beta locale ships an incomplete pack by design, so a key resolving down +// the chain is normal — the warnings would be noise. const i18n = createI18n({ legacy: false, locale: activeLocale, - fallbackLocale: FALLBACK_LOCALE, + fallbackLocale, + missingWarn: false, + fallbackWarn: false, messages: {}, }); @@ -73,14 +91,11 @@ async function loadOne(locale) { loaded.add(locale); } -// Load the active locale (plus the fallback, so a missing key still resolves to -// English instead of showing the raw key). Awaited in main.js before mount so the -// first render is already translated. The two loads run in parallel. +// Load the active locale's whole fallback chain — vue-i18n can only fall back +// to messages that are actually in the instance. Awaited in main.js before +// mount so the first render is already translated; the loads run in parallel. export async function loadActiveLocaleMessages() { - await Promise.all([ - loadOne(activeLocale), - activeLocale === FALLBACK_LOCALE ? null : loadOne(FALLBACK_LOCALE), - ]); + await Promise.all(fallbackChain(activeLocale).map((code) => loadOne(code))); updateMeta(); } diff --git a/frontend/locales/ru.json b/frontend/locales/ru.json index 3f2197c0e..6fa20462f 100644 --- a/frontend/locales/ru.json +++ b/frontend/locales/ru.json @@ -1418,7 +1418,7 @@ "PersonaCheck": "Открыть панель углублённой проверки портрета" }, "page": { - "title": "IPCheck.ing — проверка IP-адреса и геолокации — тест утечки IP и DNS — качество IP — скорость сети — проект Jason Ng с открытым кодом", + "title": "IPCheck.ing — проверка IP-адреса и геолокации — тест утечки IP и DNS — качество IP — скорость сети — универсальный набор IP-инструментов", "description": "Удобный набор IP-инструментов с открытым кодом: проверка IP-адреса и геолокации, проверка утечек IP через WebRTC и DNS, проверка качества IP, отпечатка браузера, скорости, глобальной задержки и MTR, поиск Whois и другие функции.", "keywords": "MyIP, инструменты IP, проверка IP, утечка IP, качество IP, поиск IP, тест утечки DNS, тест утечки WebRTC, отпечаток браузера, тест скорости, глобальная задержка, тест MTR, поиск DNS, поиск Whois", "footerLink": "https://github.com/jason5ng32/MyIP", diff --git a/frontend/locales/zh.json b/frontend/locales/zh.json index d55e47e38..67cb344b0 100644 --- a/frontend/locales/zh.json +++ b/frontend/locales/zh.json @@ -1418,7 +1418,7 @@ "PersonaCheck": "打开深度画像检测面板" }, "page": { - "title": "IPCheck.ing - 查看我的 IP 地址及归属地 - IP 泄露检测 - DNS 泄露检测 - IP 质量检查 - 网速测试 - Jason Ng 阿禅开源作品", + "title": "IPCheck.ing - 查看我的 IP 地址及归属地 - IP 泄露检测 - DNS 泄露检测 - IP 质量检查 - 网速测试 - 多合一 IP 工具箱", "description": "好用和开源的全能 IP 工具箱。轻松查看你的 IP 地址和信息、检查 IP 泄露、DNS 与 WebRTC 泄露检查、查看 IP 质量分数、浏览器指纹检查。还有网速测试、全球延迟测试、MTR 测试、Whois 查询等。", "keywords": "我的IP,IP工具,IP查询,IP泄露检测,DNS泄露检测,IP质量检测,WebRTC泄露检测,浏览器指纹检测,网速测试,全球延迟测试,MTR测试,DNS查询,Whois搜索", "footerLink": "https://github.com/jason5ng32/MyIP", diff --git a/frontend/utils/locale-registry.js b/frontend/utils/locale-registry.js index 2e9843e3d..0d56fc200 100644 --- a/frontend/utils/locale-registry.js +++ b/frontend/utils/locale-registry.js @@ -1,3 +1,13 @@ // Thin re-export of common/locale-registry.js (shared with the tests) so // front-end code keeps importing from `@/utils/...`. -export { LOCALES, LOCALE_CODES, getLocale, toApiTag, toHtmlLang, matchLocale } from '../../common/locale-registry.js'; +export { + LOCALES, + LOCALE_CODES, + FULL_LOCALE_CODES, + FALLBACK_LOCALE, + getLocale, + toApiTag, + toHtmlLang, + fallbackChain, + matchLocale, +} from '../../common/locale-registry.js'; diff --git a/package.json b/package.json index 51a9fbf43..aee845548 100644 --- a/package.json +++ b/package.json @@ -15,7 +15,8 @@ "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" }, "dependencies": { "@cloudflare/speedtest": "^1.13.0", diff --git a/scripts/i18n-status.js b/scripts/i18n-status.js new file mode 100644 index 000000000..9e5ea863e --- /dev/null +++ b/scripts/i18n-status.js @@ -0,0 +1,86 @@ +// 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'; + +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')); + +// path → leaf value, matching how the test gate reads a pack. +const flatten = (value, prefix = '', out = new Map()) => { + for (const [key, child] of Object.entries(value)) { + const path = prefix ? `${prefix}.${key}` : key; + if (child && typeof child === 'object') flatten(child, path, out); + else out.set(path, child); + } + return out; +}; + +// 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. +const compare = (dir, code) => { + const en = flatten(readPack(dir, FALLBACK_LOCALE)); + if (!fs.existsSync(packUrl(dir, code))) return { total: en.size, done: 0, missing: [...en.keys()], file: false }; + const pack = flatten(readPack(dir, code)); + const missing = [...en].filter(([key, enValue]) => { + const value = pack.get(key); + if (value === undefined) return true; + return String(value).trim() === '' && String(enValue).trim() !== ''; + }).map(([key]) => key); + return { total: en.size, done: en.size - missing.length, missing, 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); + + 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}`); + } + + 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/changelog.test.js b/tests/changelog.test.js index c02aa0d75..083c2adc9 100644 --- a/tests/changelog.test.js +++ b/tests/changelog.test.js @@ -6,9 +6,11 @@ import { describe, it } from 'node:test'; import fs from 'node:fs'; import changelog from '../frontend/data/changelog.json' with { type: 'json' }; -import { LOCALE_CODES } from '../common/locale-registry.js'; +import { FULL_LOCALE_CODES } from '../common/locale-registry.js'; -const REQUIRED_LOCALES = LOCALE_CODES; +// 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 8d51448ea..4d41a402f 100644 --- a/tests/connectivity-import-lists.test.js +++ b/tests/connectivity-import-lists.test.js @@ -23,7 +23,7 @@ import { CONNECTIVITY_TARGET_LIMIT, } from '../frontend/data/connectivity-import-lists.js'; import { fetchFavicons } from '../scripts/fetch-favicons.js'; -import { LOCALE_CODES } from '../common/locale-registry.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`); @@ -142,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 LOCALE_CODES) { + 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/locale-packs.test.js b/tests/locale-packs.test.js new file mode 100644 index 000000000..c540513bf --- /dev/null +++ b/tests/locale-packs.test.js @@ -0,0 +1,135 @@ +// The hard gate for translations: what a locale pack may and may not do. +// Every rule is expressed against en, the reference pack — a translation may +// lag behind it, never contradict it. +// +// full locales must be complete (main pack, privacy copy and the security +// checklist, all key-for-key with en); beta locales may leave keys — and the +// privacy / checklist files as a whole — untranslated, but whatever they do +// ship plays by the same rules. Coverage progress is not a failure: run +// `pnpm i18n-status` to see how far along a language is. + +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, +} from '../common/locale-registry.js'; + +const localesDir = new URL('../frontend/locales/', import.meta.url); + +// The three per-locale datasets, by the folder they live in. +const DATASETS = [ + { name: 'main pack', dir: '' }, + { name: 'privacy copy', dir: 'privacy/' }, + { name: 'security checklist', dir: 'security-checklist/' }, +]; + +const packUrl = (dir, code) => new URL(`${dir}${code}.json`, localesDir); +const packExists = (dir, code) => fs.existsSync(packUrl(dir, code)); +const readPack = (dir, code) => JSON.parse(fs.readFileSync(packUrl(dir, code), 'utf8')); + +// path → leaf value. Arrays flatten by index, so an entry added or dropped +// mid-array reads as a changed key rather than a silent re-alignment. +const flatten = (value, prefix = '', out = new Map()) => { + for (const [key, child] of Object.entries(value)) { + const path = prefix ? `${prefix}.${key}` : key; + if (child && typeof child === 'object') flatten(child, path, out); + else out.set(path, child); + } + return out; +}; + +const placeholders = (value) => new Set(String(value).match(/\{[^}]*\}/g) ?? []); + +const reference = new Map(DATASETS.map(({ dir }) => [dir, flatten(readPack(dir, FALLBACK_LOCALE))])); +const translations = LOCALE_CODES.filter((code) => code !== FALLBACK_LOCALE); + +describe('locale packs — registry and files agree', () => { + it('every registered locale ships a main pack', () => { + for (const code of LOCALE_CODES) { + assert.ok(packExists('', code), `frontend/locales/${code}.json is missing`); + } + }); + + it('every main pack in the folder is a registered locale', () => { + const onDisk = fs.readdirSync(localesDir) + .filter((name) => name.endsWith('.json')) + .map((name) => name.replace(/\.json$/, '')); + for (const code of onDisk) { + assert.ok(LOCALE_CODES.includes(code), + `frontend/locales/${code}.json has no entry in common/locale-registry.js`); + } + }); + + it('en ships all three datasets — it is what everything falls back to', () => { + for (const { name, dir } of DATASETS) { + assert.ok(packExists(dir, FALLBACK_LOCALE), `en is missing its ${name}`); + } + }); +}); + +describe('locale packs — rules every translation follows', () => { + for (const { name, dir } of DATASETS) { + for (const code of translations) { + if (!packExists(dir, code)) continue; + const en = reference.get(dir); + const pack = flatten(readPack(dir, code)); + const label = `${dir}${code}.json`; + + it(`${label} (${name}) invents no key en doesn't have`, () => { + const extra = [...pack.keys()].filter((key) => !en.has(key)); + assert.deepEqual(extra, [], `${label}: keys absent from en`); + }); + + it(`${label} (${name}) leaves nothing blank that en fills in`, () => { + for (const [key, value] of pack) { + if (String(value).trim() !== '') continue; + assert.equal(String(en.get(key) ?? '').trim(), '', + `${label}: ${key} is empty — drop the key instead, it falls back to en`); + } + }); + + it(`${label} (${name}) invents no placeholder en doesn't have`, () => { + for (const [key, value] of pack) { + const allowed = placeholders(en.get(key) ?? ''); + for (const token of placeholders(value)) { + assert.ok(allowed.has(token), + `${label}: ${key} uses ${token}, which en doesn't provide`); + } + } + }); + } + } + + // Slugs are URLs and priorities drive the badge colors — both are data, not + // copy, and the tool reads them positionally against every locale's file. + for (const code of translations) { + if (!packExists('security-checklist/', code)) continue; + it(`security-checklist/${code}.json keeps en's slugs and priorities`, () => { + const en = reference.get('security-checklist/'); + const pack = flatten(readPack('security-checklist/', code)); + for (const [key, value] of en) { + if (!/(^|\.)(slug|priority)$/.test(key)) continue; + assert.equal(pack.get(key), value, `security-checklist/${code}.json: ${key} was translated`); + } + }); + } +}); + +describe('locale packs — full locales are complete', () => { + for (const code of FULL_LOCALE_CODES) { + if (code === FALLBACK_LOCALE) continue; + for (const { name, dir } of DATASETS) { + it(`${dir}${code}.json (${name}) covers every en key`, () => { + assert.ok(packExists(dir, code), + `${code} is a full locale but has no ${name} — ship the file or mark it beta`); + const pack = flatten(readPack(dir, code)); + const missing = [...reference.get(dir).keys()].filter((key) => !pack.has(key)); + assert.deepEqual(missing, [], `${dir}${code}.json: keys missing against en`); + }); + } + } +}); diff --git a/tests/locale-registry.test.js b/tests/locale-registry.test.js index 63b2bb53c..c9333c32b 100644 --- a/tests/locale-registry.test.js +++ b/tests/locale-registry.test.js @@ -1,17 +1,19 @@ // Unit tests for common/locale-registry.js: the entry shape every consumer -// relies on, plus the two shared mappings — apiTag and browser-language -// matching. +// relies on, plus the shared mappings — apiTag, the fallback chain and +// browser-language matching. Pack contents are gated in locale-packs.test.js. import assert from 'node:assert/strict'; import { describe, it } from 'node:test'; -import fs from 'node:fs'; import { LOCALES, LOCALE_CODES, + FULL_LOCALE_CODES, + FALLBACK_LOCALE, getLocale, toApiTag, toHtmlLang, + fallbackChain, matchLocale, } from '../common/locale-registry.js'; import { LOCALES as BRIDGED } from '../frontend/utils/locale-registry.js'; @@ -28,16 +30,14 @@ describe('locale registry entries', () => { } }); - it('codes are unique and en is registered (it is the fallback locale)', () => { + it('codes are unique and the fallback locale is registered', () => { assert.equal(new Set(LOCALE_CODES).size, LOCALE_CODES.length); - assert.ok(LOCALE_CODES.includes('en')); + assert.ok(LOCALE_CODES.includes(FALLBACK_LOCALE)); }); - it('every registered code has a message pack on disk', () => { - for (const code of LOCALE_CODES) { - assert.ok(fs.existsSync(new URL(`../frontend/locales/${code}.json`, import.meta.url)), - `frontend/locales/${code}.json is missing`); - } + it('full locales are a subset of all locales', () => { + for (const code of FULL_LOCALE_CODES) assert.ok(LOCALE_CODES.includes(code)); + assert.ok(FULL_LOCALE_CODES.includes(FALLBACK_LOCALE), 'en must stay full — everything falls back to it'); }); it('the front-end bridge re-exports the same registry', () => { @@ -66,6 +66,25 @@ describe('toApiTag / toHtmlLang', () => { }); }); +describe('fallbackChain', () => { + it('ends at en, which falls back to nothing', () => { + assert.deepEqual(fallbackChain('en'), ['en']); + for (const code of LOCALE_CODES) { + assert.equal(fallbackChain(code).at(-1), FALLBACK_LOCALE); + } + }); + + it('sends a plain language straight to en', () => { + assert.deepEqual(fallbackChain('zh'), ['zh', 'en']); + assert.deepEqual(fallbackChain('tr'), ['tr', 'en']); + }); + + it('routes a variant through its base — but only a registered one', () => { + assert.deepEqual(fallbackChain('zh-TW'), ['zh-TW', 'zh', 'en']); + assert.deepEqual(fallbackChain('pt-BR'), ['pt-BR', 'en']); + }); +}); + describe('matchLocale', () => { it('prefers an exact match over the base language', () => { assert.equal(matchLocale('zh-TW', ['zh', 'zh-TW']), 'zh-TW'); @@ -77,8 +96,19 @@ describe('matchLocale', () => { assert.equal(matchLocale('zh-Hans-CN'), 'zh'); }); - it('never widens a base tag into a regional pack', () => { - assert.equal(matchLocale('zh', ['en', 'zh-TW']), null); + it('falls sideways within a family when the base itself is unregistered', () => { + // A pt-BR-only registry still serves a pt-PT visitor. + assert.equal(matchLocale('pt-PT', ['en', 'pt-BR']), 'pt-BR'); + assert.equal(matchLocale('pt', ['en', 'pt-BR']), 'pt-BR'); + assert.equal(matchLocale('zh-CN', ['en', 'zh-TW']), 'zh-TW'); + }); + + it('picks the first sibling in registry order', () => { + assert.equal(matchLocale('pt-AO', ['en', 'pt-PT', 'pt-BR']), 'pt-PT'); + }); + + it('never crosses into another language', () => { + assert.equal(matchLocale('de-CH', ['en', 'fr', 'zh-TW']), null); }); it('returns null for an unknown or empty tag', () => { diff --git a/tests/persona-i18n.test.js b/tests/persona-i18n.test.js index cdc35a81b..2b4735595 100644 --- a/tests/persona-i18n.test.js +++ b/tests/persona-i18n.test.js @@ -11,7 +11,7 @@ import assert from 'node:assert/strict'; import { describe, it } from 'node:test'; import fs from 'node:fs'; -import { LOCALE_CODES } from '../common/locale-registry.js'; +import { FULL_LOCALE_CODES } from '../common/locale-registry.js'; import { PERSONA_CHECK_IDS, PERSONA_DETAIL_KEYS, @@ -27,8 +27,9 @@ import { // the unknown reasons whitelisted to explain themselves. const RENDERED_REASONS = [...PERSONA_NOT_APPLICABLE_REASONS, ...PERSONA_UNKNOWN_REASONS]; -// Read from disk so the registry alone decides which languages are checked. -const LOCALES = Object.fromEntries(LOCALE_CODES.map((code) => [ +// Read from disk so the registry alone decides which languages are checked — +// full ones only, a beta pack is allowed to still be missing these keys. +const LOCALES = Object.fromEntries(FULL_LOCALE_CODES.map((code) => [ code, JSON.parse(fs.readFileSync(new URL(`../frontend/locales/${code}.json`, import.meta.url), 'utf8')), ])); diff --git a/tests/pulse-statuses.test.js b/tests/pulse-statuses.test.js index d62e136f4..e40397a59 100644 --- a/tests/pulse-statuses.test.js +++ b/tests/pulse-statuses.test.js @@ -14,9 +14,10 @@ import { festivalsActiveOn, localDateString, } from '../frontend/data/pulse-statuses.js'; -import { LOCALE_CODES } from '../common/locale-registry.js'; +import { FULL_LOCALE_CODES } from '../common/locale-registry.js'; -const REQUIRED_LOCALES = LOCALE_CODES; +// Coverage is required of full locales only; a beta pack may still be filling in. +const REQUIRED_LOCALES = FULL_LOCALE_CODES; const DATE_RE = /^\d{4}-\d{2}-\d{2}$/; const MONTH_DAY_RE = /^\d{2}-\d{2}$/; const ALL = [...PRESET_STATUSES, ...FESTIVAL_STATUSES]; diff --git a/vite.config.js b/vite.config.js index 08579bf93..8b64f8a2d 100644 --- a/vite.config.js +++ b/vite.config.js @@ -5,6 +5,7 @@ import tailwindcss from '@tailwindcss/vite' import { CodeInspectorPlugin } from 'code-inspector-plugin'; import { sentryVitePlugin } from '@sentry/vite-plugin'; import { PREFS_STORAGE_KEY } from './frontend/data/default-preferences.js'; +import { LOCALE_CODES } from './common/locale-registry.js'; dotenv.config(); @@ -76,9 +77,9 @@ function siteUrlHtmlPlugin() { // round-trip on the boot critical path. This plugin finds the emitted // locale-pack chunks in the bundle and injects a small head script that // picks the language exactly like locales/i18n.js (stored prefs → -// ?hl= → browser language → en) and appends -// for it (plus the en fallback pack, which -// non-English boots also await) while the HTML is still streaming — the +// ?hl= → browser language → en) and appends a +// for every pack on its fallback chain — the +// same set mount awaits — while the HTML is still streaming; the // packs then download in parallel with the main bundle. A wrong pick only // wastes one preload; the real import decides. Dev serves no bundle, so // nothing is injected there. @@ -93,6 +94,7 @@ const localePreloadPlugin = () => { for (var i = 0; i < codes.length; i++) if (codes[i].toLowerCase() === wanted) return codes[i]; var base = wanted.split('-')[0]; for (var j = 0; j < codes.length; j++) if (codes[j].toLowerCase() === base) return codes[j]; + for (var k = 0; k < codes.length; k++) if (codes[k].toLowerCase().split('-')[0] === base) return codes[k]; return null; }; var lang = null; @@ -102,13 +104,14 @@ const localePreloadPlugin = () => { } catch (e) { /* malformed entry — fall through to the default pick */ } if (!lang) { var hl = new URLSearchParams(location.search).get('hl'); - if (hl) { - lang = chunks[hl] ? hl : 'en'; - } else { - lang = match(navigator.language) || 'en'; - } + lang = match(hl || navigator.language) || 'en'; } - (lang === 'en' ? ['en'] : [lang, 'en']).forEach(function (l) { + // Same chain as fallbackChain(): variant, base, en. + var chain = [lang]; + var base = lang.split('-')[0]; + if (base !== lang && chunks[base]) chain.push(base); + if (chain.indexOf('en') === -1) chain.push('en'); + chain.forEach(function (l) { if (!chunks[l]) return; var link = document.createElement('link'); link.rel = 'modulepreload'; @@ -132,10 +135,14 @@ const localePreloadPlugin = () => { const match = facade.match(/\/frontend\/locales\/([a-z]{2}(?:-[A-Za-z]{2,4})?)\.json$/); if (match) chunks[match[1]] = '/' + fileName; } - if (Object.keys(chunks).length === 0) return html; + // Registry order, so the sibling `match()` settles on is the one the + // app itself would pick. + const ordered = {}; + for (const code of LOCALE_CODES) if (chunks[code]) ordered[code] = chunks[code]; + if (Object.keys(ordered).length === 0) return html; return { html, - tags: [{ tag: 'script', children: preloadScript(chunks), injectTo: 'head' }], + tags: [{ tag: 'script', children: preloadScript(ordered), injectTo: 'head' }], }; }, }, From 084fe65eaa3473774efe038eee0b5da1fb25089c Mon Sep 17 00:00:00 2001 From: jason5ng32 Date: Sat, 22 Aug 2026 21:17:20 +0800 Subject: [PATCH 03/12] Feat(i18n): hold index.html's inline copy to the locale registry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit index.html can't import the registry, so its boot quips, JSON-LD and inline language pick have always been synced by hand. A new spec now holds them to it: full locales must cover the boot copy (beta ones are exempt), JSON-LD inLanguage mirrors the full locales' htmlLang — fixing the imprecise "zh" — and a sentinel evaluates the inline matcher against matchLocale so the two can't drift apart silently. The inline pick itself gains the same exact/base/family steps, closing the split where ?hl=zh-CN booted in one language and mounted in another. Co-Authored-By: Claude Fable 5 --- index.html | 42 ++++++-- tests/index-html-i18n.test.js | 197 ++++++++++++++++++++++++++++++++++ 2 files changed, 229 insertions(+), 10 deletions(-) create mode 100644 tests/index-html-i18n.test.js diff --git a/index.html b/index.html index 5f54f5b40..dce1b50a6 100644 --- a/index.html +++ b/index.html @@ -19,7 +19,7 @@ - IPCheck.ing - Check My IP Address and Geolocation - IP Leak Test - DNS Leak Test - IP Quality Check - Test Network Speed - Jason Ng Open Source + IPCheck.ing - Check My IP Address and Geolocation - IP Leak Test - DNS Leak Test - IP Quality Check - Test Network Speed - All in one IP Toolbox @@ -56,7 +56,7 @@ "name": "IPCheck.ing", "url": "__SITE_URL__/", "description": "Open-source IP toolbox: lookup, connectivity tests, WebRTC and DNS leak detection, speed test, MTR, Whois, and browser fingerprint.", - "inLanguage": ["en", "zh", "fr", "ru"], + "inLanguage": ["en", "zh-CN", "fr", "ru"], "publisher": { "@type": "Person", "name": "Jason Ng" } } @@ -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/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(/