Ship only the active locale, behind DX_SHIP_ONE_LOCALE
Every leaf inlines all eleven catalogues, so a reader who opens the map in
one language downloads the other ten. Measured on doxa-simple-map: the ten
unused locales are 113 KB gzipped, 40% of what a reader actually fetches.
With the flag on, the build inlines en — it is the fallbackLocale, so it
has to stay — and emits the other ten as <bundle>/locales/<lc>.json holding
exactly the areas that bundle uses. Same areasForBundle() answer the area
narrowing already uses, so the two dimensions cannot drift apart, and
emitted from the same plugin so composite-freshness, fix:sri and the
.gated/ split cover them with no new step. Off by default: the build is
byte-identical until someone sets it.
Not Vite's dynamic import. That was tried in April and GREW the bundle
~22 KB, because IIFE output needs inlineDynamicImports, which pulls every
chunk back in and adds a wrapper closure each. This is a plain fetch.
The base URL is captured as the script evaluates. Rollup shims
import.meta.url to document.currentScript, which is null once the script
has run, so a base read at mount would resolve against document.baseURI —
right on our own pages, wrong on every host that serves the bundle from
another path. deriveTreeBase() carries that same warning but returns the
tree root; these assets sit one level deeper, in the bundle's own
directory.
Each emitted file's sha384 is baked into the bundle and passed to
fetch({ integrity }), hashed from the same bytes the emit writes, so a
swapped catalogue fails closed to English instead of injecting text.
The fetch starts at module evaluation from the detected locale, so it
overlaps the bundle's own startup, and localeChromeReady() lets an RTL
locale hold its text chrome for up to 1.5s. For ar the flash is a layout
flip, not a text swap, which is worth a short hold; the map never waits,
and the timeout means a failed fetch degrades to English rather than to a
blank panel. loadLocale() runs after the instance exists, never inside
createI18n, because setLocaleMessage touches no effect scope while
createGlobal is the call that throws inside a stopped one. It also installs
into each instance that asks, since vue-i18n copies messages at
construction and a catalogue arriving later would otherwise reach only the
instances built after it.
Measured, doxa-simple-map: 284.6 KB gzip -> 165.2 KB, with ar as a 13 KB
asset. Ten assets emitted, every hash matching, ar no longer inlined, en
inlined.
The treeshake locale arm reads the narrowed build correctly (reports "10
asset") and fails when an asset is deleted. Its length floor is now per
script: a 25-character Chinese string is a whole sentence, so a
Latin-calibrated floor found no zh in a composite carrying short labels and
called it absent.
This commit is contained in:
parent
bba33e783d
commit
41d33d9a40
3 changed files with 183 additions and 6 deletions
|
|
@ -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'
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
// "<bundle>/<bundle>.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(/[^/]+$/, '') // …/<bundle>/ (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<boolean>} 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 <doxa-map> 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.
|
||||
//
|
||||
|
|
|
|||
|
|
@ -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 <bundle>/locales/<lc>.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 <bundle>/<bundle>/locales/, which the runtime would never find.
|
||||
if (json) this.emitFile({ type: 'asset', fileName: `locales/${lc}.json`, source: json })
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue