diff --git a/internal/check-i18n-treeshake.mjs b/internal/check-i18n-treeshake.mjs index aea8e369c..727b382fa 100644 --- a/internal/check-i18n-treeshake.mjs +++ b/internal/check-i18n-treeshake.mjs @@ -61,8 +61,14 @@ function localeReachable(code, loc, bundleDir, includedAreas) { // in the bundle is the translation and not a note about it. const SCRIPT = { ar: /[؀-ۿ]/, ru: /[Ѐ-ӿ]/, hi: /[ऀ-ॿ]/, zh: /[一-鿿]/ } const script = SCRIPT[loc] + // ⚠ THE LENGTH FLOOR IS PER SCRIPT. A 25-character Chinese string is a whole sentence, so a + // Latin-calibrated ">24" kept only the longest prose and found none of it in a composite that + // carries short labels — reporting zh absent from a bundle with Chinese plainly in it. Chinese + // says in ~8 characters what English needs 25 for; the floor only has to be long enough that the + // string is not a coincidence. + const floor = loc === 'zh' ? 8 : 24 const mine = areaValues(dir, includedAreas) - .filter((v) => v.length > 24 && !en.includes(v) && (!script || script.test(v))) + .filter((v) => v.length > floor && !en.includes(v) && (!script || script.test(v))) if (!mine.length) return 'unprobeable' return mine.slice(0, 40).some((v) => code.includes(v)) ? 'inlined' : 'absent' } diff --git a/library/i18n/index.js b/library/i18n/index.js index 0c3f8bf33..d1a79813d 100644 --- a/library/i18n/index.js +++ b/library/i18n/index.js @@ -48,7 +48,9 @@ import { watch, effectScope } from 'vue' import { createI18n } from 'vue-i18n' -// Eager glob — all locale subfolders inlined at build time +// Eager glob — the locale subfolders the build leaves in. vite.config's i18nAreasPlugin narrows +// this to the areas one bundle uses, and (when SHIP_ONE_LOCALE is on) to `en` alone; the other ten +// are emitted as assets beside the bundle and fetched by loadLocale() below. const rawModules = import.meta.glob('./locales/*/*.json', { eager: true, import: 'default' }) // rawModules keys look like './locales/ar/legend.json', './locales/ar/detail.json' const messages = {} @@ -60,6 +62,85 @@ for (const [path, subtree] of Object.entries(rawModules)) { messages[locale][area] = subtree } +// ★ THE BASE URL IS CAPTURED AS THIS SCRIPT EVALUATES, AND IT HAS TO BE. Rollup shims +// import.meta.url to document.currentScript in IIFE output, and currentScript is null the moment +// the script finishes running — so a base read at mount, or on a later locale switch, resolves +// against document.baseURI instead: right on our own pages, wrong on every host that serves the +// bundle from a different path. deriveTreeBase() already exists for exactly this and already +// carries the warning; this is the one call, at the top, on purpose. +// deriveTreeBase() is the same capture at the same moment, but it returns the TREE root — it strips +// "/.js" — and these assets live in the bundle's OWN directory one level deeper, so +// each bundle carries only the areas it uses. One less path segment removed, same rule. +const LOCALE_BASE = (() => { + try { + const cur = (typeof document !== 'undefined' && document.currentScript && document.currentScript.src) || '' + if (!cur) return '' + return cur.split(/[?#]/)[0].replace(/[^/]+$/, '') // …// (trailing slash kept) + } catch { return '' } +})() + +// Filled by the build (i18nAreasPlugin) with { ar: 'sha384-…', … } for the locales it emitted as +// assets. Embeds are SRI-pinned, and a fetched catalogue would not be unless its hash travels with +// the bundle — so a swapped file fails closed to English rather than injecting text into the page. +// Empty when every locale is inlined, which is also why loadLocale() is a no-op in that build. +const LOCALE_SRI = (typeof __DX_LOCALE_SRI__ !== 'undefined' ? __DX_LOCALE_SRI__ : {}) + +const _loaded = new Map() // code -> Promise, so concurrent instances share one request + +// ★ THE REQUEST STARTS NOW, NOT AT MOUNT. Kicked off as the script evaluates so it overlaps the +// bundle's own startup instead of queueing behind it — the catalogue is ~13 KB gzipped and the +// bundle is an order of magnitude more, so in practice it lands first. Nothing awaits this here; +// createAppI18n() picks up the same promise from _loaded. +let PRELOAD = null +// How long an RTL locale may hold its text chrome. For ar the flash is a LAYOUT change — English +// LTR, then a flip to RTL — which is visibly worse than the plain re-render the other locales get, +// so the chrome waits. The map never waits, and the timeout means a failed fetch degrades to +// English rather than to a blank panel. +const RTL_HOLD_MS = 1500 + +/** + * Fetch a locale that was emitted beside the bundle rather than inlined, and install it. + * + * Resolves to true when the locale is usable (already present, or fetched and installed) and false + * when it is not — the caller then simply stays on the English fallback, which is what a reader + * gets today for a locale with no catalogue. Never throws: a failed catalogue must not take the map + * down with it. + * + * ⚠ CALLED AFTER THE INSTANCE EXISTS, NEVER DURING createI18n. setLocaleMessage() touches no effect + * scope, so this stays clear of the stopped-scope failure that createGlobal() has (see below). + */ +export function loadLocale(i18n, code) { + const c = String(code || '').split('-')[0].toLowerCase() + if (!c || c === 'en' || !SUPPORTED_LOCALES.includes(c)) return Promise.resolve(c === 'en') + // Already here — inlined by this build, or fetched for an earlier instance. Still install it into + // THIS instance: vue-i18n copied `messages` when it was constructed, so a catalogue that arrived + // afterwards is in the shared map and not in this one. + if (messages[c]) { i18n?.global?.setLocaleMessage?.(c, messages[c]); return Promise.resolve(true) } + // One request per locale, however many instances ask — but each caller still installs the result. + if (_loaded.has(c)) { + return _loaded.get(c).then((okEarlier) => { + if (okEarlier && messages[c]) i18n?.global?.setLocaleMessage?.(c, messages[c]) + return okEarlier + }) + } + const run = (async () => { + try { + if (!LOCALE_BASE) return false // base unknown: stay on English + const url = new URL(`locales/${c}.json`, LOCALE_BASE).href + const init = LOCALE_SRI[c] ? { integrity: LOCALE_SRI[c] } : {} + const res = await fetch(url, init) + if (!res.ok) return false + const tree = await res.json() + if (!tree || typeof tree !== 'object') return false + messages[c] = tree + i18n?.global?.setLocaleMessage?.(c, tree) + return true + } catch { return false } + })() + _loaded.set(c, run) + return run +} + /** * The English catalogue, exported from the ONE module that already owns it. * @@ -121,6 +202,30 @@ export function detectLocale() { return SUPPORTED_LOCALES.includes(code) ? code : 'en' } +// Start the fetch as this module evaluates. Deliberately not awaited and deliberately not inside +// createAppI18n: the request overlaps the rest of the bundle's startup, and every instance that +// mounts later joins the same promise instead of issuing its own. +try { PRELOAD = loadLocale(null, detectLocale()) } catch { PRELOAD = null } + +/** + * Resolves when the active locale's text is ready to paint, or when the hold expires. + * + * Only an RTL locale actually waits. For the others a late catalogue is a re-render — vue-i18n is + * reactive, so the words simply change — but for ar it is a LAYOUT flip, and painting English LTR + * and then mirroring is the one outcome worth a short hold. Always resolves; never rejects. + * + * @returns {Promise} true when the catalogue landed in time + */ +export function localeChromeReady(code = detectLocale()) { + const c = String(code || '').split('-')[0].toLowerCase() + if (!RTL_LOCALES.has(c) || messages[c]) return Promise.resolve(true) + const pending = _loaded.get(c) || PRELOAD || Promise.resolve(false) + return Promise.race([ + pending.then((ok) => !!ok).catch(() => false), + new Promise((r) => setTimeout(() => r(false), RTL_HOLD_MS)), + ]) +} + /** * Build a fresh i18n instance. Called once per element. */ @@ -177,6 +282,15 @@ export function createAppI18n() { pluralRules: CLDR_PLURAL_RULES, })) + // ★ THE CATALOGUE MAY ARRIVE AFTER THIS INSTANCE DOES. vue-i18n copies `messages` at + // construction, so a fetch that lands later has to be installed per instance — the preload above + // fills the shared `messages` map, which is enough for instances created afterwards and not for + // the ones already built. Outside createI18n() on purpose: setLocaleMessage touches no effect + // scope, while createGlobal() is the call that throws inside a stopped one. + if (locale !== 'en' && !messages[locale]) { + loadLocale(i18n, locale).catch(() => { /* stays on the English fallback */ }) + } + // ★ INSTALLING i18n ALSO PROVIDES IT, so a bundle CANNOT forget. This is the durable half of a // bug that is invisible by construction. // diff --git a/vite.config.js b/vite.config.js index 76eb1b1cc..dcee8d18c 100644 --- a/vite.config.js +++ b/vite.config.js @@ -27,11 +27,16 @@ import { stripShadowCssCommentsPlugin } from './internal/vite-plugin-strip-shado import { backendDevPlugin, devBackendProxy } from './internal/backend-dev-plugin.mjs' import { devSyncPlugin } from './internal/dev-sync-plugin.mjs' import { fileURLToPath } from 'node:url' -import { resolve, dirname } from 'node:path' -import { readdirSync, statSync, existsSync } from 'node:fs' +import { resolve, dirname, join } from 'node:path' +import { readdirSync, statSync, existsSync, readFileSync } from 'node:fs' +import { createHash } from 'node:crypto' import { TREE_NAME, GATED_PROFILES } from './internal/_tree-name.mjs' const __dirname = dirname(fileURLToPath(import.meta.url)) +// Ship only the ACTIVE locale: inline `en` (the fallbackLocale) and emit the other ten beside each +// bundle for the runtime to fetch. Off by default — the build is byte-identical until it is set — +// so this can land, be measured, and be turned on as a separate decision. +const SHIP_ONE_LOCALE = process.env.DX_SHIP_ONE_LOCALE === '1' const profilesDir = resolve(__dirname, 'plugin-designs') // The multi-MFE output folder name is configured in vite.doxa-maps-build-config.json (`name`), @@ -66,6 +71,22 @@ function discoverBundles() { } // Narrow library/i18n/index.js's catalogue glob to the areas one bundle uses (see plugins below). +/** Locale folders on disk — directories only, so a README beside them is not read as a locale. */ +const localeDirs = () => readdirSync(join(__dirname, 'library/i18n/locales'), { withFileTypes: true }) + .filter((e) => e.isDirectory()).map((e) => e.name).sort() + +/** The exact JSON this build will emit for one locale, so its hash describes what actually ships. */ +function localeTree(areas, lc) { + const root = join(__dirname, 'library/i18n/locales') + const tree = {} + for (const a of areas) { + const p = join(root, lc, `${a}.json`) + if (!existsSync(p)) continue // missing area: falls back to English by design + try { tree[a] = JSON.parse(readFileSync(p, 'utf8')) } catch { /* skip an unreadable area */ } + } + return Object.keys(tree).length ? JSON.stringify(tree) : null +} + function i18nAreasPlugin(bundle) { let areas = null return { @@ -80,8 +101,44 @@ function i18nAreasPlugin(bundle) { if (!areas || !id.replace(/\\/g, '/').endsWith('/library/i18n/index.js')) return null const GLOB = "import.meta.glob('./locales/*/*.json'" if (!code.includes(GLOB)) this.error('i18n catalogue glob not found in library/i18n/index.js; cannot narrow it') - const list = areas.length ? '[' + areas.map((a) => `'./locales/*/${a}.json'`).join(', ') + ']' : "['./locales/*/__none__.json']" - return { code: code.replace(GLOB, 'import.meta.glob(' + list), map: null } + // ★ THE LOCALE STAR IS THE SECOND DIMENSION. With DX_SHIP_ONE_LOCALE on, only `en` is inlined + // — it is the fallbackLocale, so it must be — and the other ten are emitted below as assets + // this bundle fetches. Measured on doxa-simple-map: the ten unused locales are 113 KB gzip, + // 40% of what a reader downloads. Without the flag the glob keeps every locale, so this is + // the same build it has always been until someone turns it on. + const loc = SHIP_ONE_LOCALE ? 'en' : '*' + const list = areas.length ? '[' + areas.map((a) => `'./locales/${loc}/${a}.json'`).join(', ') + ']' : "['./locales/*/__none__.json']" + let out = code.replace(GLOB, 'import.meta.glob(' + list) + // ★ THE PIN TRAVELS WITH THE BUNDLE. Embeds are SRI-pinned and a fetched catalogue would not + // be, so each emitted file's sha384 is baked in here and passed to fetch({ integrity }). A + // swapped catalogue then fails closed to English instead of injecting text into the page. + // Hashed from localeTree() — the same bytes generateBundle() emits, so the pin cannot describe + // a file that was never shipped. + if (SHIP_ONE_LOCALE) { + const sri = {} + for (const lc of localeDirs()) { + if (lc === 'en') continue + const json = localeTree(areas, lc) + if (json) sri[lc] = 'sha384-' + createHash('sha384').update(json).digest('base64') + } + out = `const __DX_LOCALE_SRI__ = ${JSON.stringify(sri)};\n` + out + } + return { code: out, map: null } + }, + // Emit /locales/.json for every locale the IIFE no longer carries, holding exactly + // the areas this bundle uses — the same areasForBundle() answer the glob above was narrowed + // with, so the two dimensions cannot drift apart. Emitted from THIS plugin on purpose: the + // composite-freshness, landing-consumers, fix:sri and .gated/ steps already walk the build's + // own output, so assets that appear here are covered with no new pipeline step. + generateBundle() { + if (!SHIP_ONE_LOCALE || !areas?.length) return + for (const lc of localeDirs()) { + if (lc === 'en') continue // inlined: never fetched + const json = localeTree(areas, lc) + // fileName is already relative to this bundle's output dir — prefixing the bundle name + // again produced //locales/, which the runtime would never find. + if (json) this.emitFile({ type: 'asset', fileName: `locales/${lc}.json`, source: json }) + } }, } }