Each bundle carries only the translation areas it uses (people-groups-grid 611 KB -> 210 KB)
library/i18n/index.js globbed every catalogue area into every bundle: 25 areas x 11 locales, other plugins' strings included, and each new area grew every bundle (CRITIC-10). - internal/i18n-areas.mjs walks a bundle's SOURCE import graph (relative, @map/, dynamic import, import.meta.glob) and keeps an area when its name appears as a key prefix or a bare string literal anywhere reached; runtime-fetched data carries no i18n keys (measured), and every rule errs toward including. - vite.config.js: in a BUNDLE build, a pre-transform narrows index.js's glob to those areas; dev keeps all. - check:i18n-treeshake (gates:post): every area-prefixed key literal in each BUILT bundle belongs to an included area. Poisoned (one area dropped) it fails. Measured, raw (gzip): people-groups-grid 611,595 -> 210,308 (203,176 -> 72,658); wall 659,876 -> 332,644; doxa-simple-map 902,907 -> 686,910; research-dashboard 843,625 -> 637,940.
This commit is contained in:
parent
932bf553c7
commit
a4b79f6875
4 changed files with 170 additions and 1 deletions
37
internal/check-i18n-treeshake.mjs
Normal file
37
internal/check-i18n-treeshake.mjs
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
#!/usr/bin/env node
|
||||
/**
|
||||
* check-i18n-treeshake — each built bundle carries every translation area its code asks for, and (with
|
||||
* --sizes) reports what the narrowing saved. Headless.
|
||||
*
|
||||
* CRITIC-10 (2026-09-26): the build narrows library/i18n's catalogue glob to the areas
|
||||
* internal/i18n-areas.mjs finds in the bundle's source graph. Omitting an area a bundle uses would show
|
||||
* raw keys instead of text, so this re-reads the BUILT output: every "area.key" literal (and every bare
|
||||
* 'area' literal, for keys assembled at runtime) in <bundle>.js must belong to an area the build included
|
||||
* for that bundle. Only area-PREFIXED literals are judged here (a bare 'template' or 'options' is also
|
||||
* ordinary Vue/JS vocabulary); the build includes an area on a bare literal too, which only over-includes.
|
||||
* EXIT 0 pass · 1 an area the bundle needs was left out
|
||||
*/
|
||||
import { readFileSync, existsSync, readdirSync } from 'node:fs'
|
||||
import { join, resolve, dirname } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { TREE_NAME } from './_tree-name.mjs'
|
||||
import { allAreas, areasForBundle } from './i18n-areas.mjs'
|
||||
|
||||
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..')
|
||||
const AREAS = allAreas(ROOT)
|
||||
const TREE = join(ROOT, 'public', TREE_NAME)
|
||||
let bad = 0, checked = 0
|
||||
for (const b of readdirSync(join(ROOT, 'plugin-designs'))) {
|
||||
const js = join(TREE, b, `${b}.js`)
|
||||
if (!existsSync(join(ROOT, 'plugin-designs', b, 'index.js')) || !existsSync(js)) continue
|
||||
const code = readFileSync(js, 'utf8')
|
||||
const included = new Set(areasForBundle(ROOT, b).areas)
|
||||
const asked = AREAS.filter((a) => new RegExp('["\'`@]' + a + '\\.[A-Za-z_$]').test(code))
|
||||
const missing = asked.filter((a) => !included.has(a))
|
||||
checked++
|
||||
if (missing.length) { bad++; console.log(` FAIL ${b}: its built code asks for ${missing.join(', ')}, which the build left out`) }
|
||||
else console.log(` ok ${b.padEnd(24)} ${included.size}/${AREAS.length} areas bundled; every area its code asks for is in`)
|
||||
}
|
||||
if (!checked) { console.log('I18N-TREESHAKE: FAIL — no built bundles (vacuous)'); process.exit(1) }
|
||||
if (bad) { console.log(`I18N-TREESHAKE: FAIL — ${bad} bundle(s) would show raw keys.`); process.exit(1) }
|
||||
console.log(`I18N-TREESHAKE: PASS — ${checked} bundle(s): each carries every translation area its code asks for.`)
|
||||
105
internal/i18n-areas.mjs
Normal file
105
internal/i18n-areas.mjs
Normal file
|
|
@ -0,0 +1,105 @@
|
|||
/**
|
||||
* i18n-areas.mjs — which translation AREAS (library/i18n/locales/<lc>/<area>.json) a bundle can use.
|
||||
*
|
||||
* WHY (CRITIC-10, 2026-09-26): library/i18n/index.js globbed EVERY area into every bundle, so each of 19
|
||||
* bundles carried all 25 areas x 11 locales (~320 KB raw) — other plugins' strings included — and each new
|
||||
* area grew every bundle. The build now bundles only the areas this function returns.
|
||||
*
|
||||
* HOW, conservatively: walk the bundle's SOURCE import graph from plugin-designs/<bundle>/index.js
|
||||
* (relative and @map/ imports, dynamic import(), export-from, and import.meta.glob patterns), then keep an
|
||||
* area if its name appears in any reached file as a key prefix ('area.' in a string or template literal) or
|
||||
* as a bare string literal ('area', which covers keys assembled at runtime as `${area}.${key}`).
|
||||
* Runtime-fetched data carries no i18n keys (measured: no data/ JSON holds an "area.key" string), so the
|
||||
* source graph is the whole story. Over-inclusion costs bytes; omission would cost strings, so every rule
|
||||
* errs toward including. check:i18n-treeshake verifies the result against the BUILT bundle.
|
||||
*/
|
||||
import { readFileSync, existsSync, readdirSync, statSync } from 'node:fs'
|
||||
import { resolve, dirname, join, relative } from 'node:path'
|
||||
|
||||
export function allAreas(root) {
|
||||
return readdirSync(join(root, 'library/i18n/locales/en')).filter((f) => f.endsWith('.json')).map((f) => f.replace(/\.json$/, '')).sort()
|
||||
}
|
||||
|
||||
const IMPORT_RE = /(?:import|export)\s[^'"`;]*?from\s*['"]([^'"]+)['"]|import\s*['"]([^'"]+)['"]|import\s*\(\s*['"]([^'"]+)['"]\s*\)/g
|
||||
const GLOB_RE = /import\.meta\.glob\s*\(\s*(\[[^\]]*\]|['"][^'"]+['"])/g
|
||||
|
||||
function resolveFile(p) {
|
||||
for (const cand of [p, p + '.js', p + '.mjs', p + '.vue', p + '.json', join(p, 'index.js')]) {
|
||||
try { if (existsSync(cand) && statSync(cand).isFile()) return cand } catch { /* next */ }
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function expandGlob(pattern, fromDir) {
|
||||
// Only the shapes this repo uses: a directory part plus a file pattern with * (and optionally a * dir).
|
||||
const abs = resolve(fromDir, pattern)
|
||||
const parts = abs.split('/')
|
||||
const out = []
|
||||
const walk = (i, dir) => {
|
||||
if (i === parts.length) return
|
||||
const seg = parts[i]
|
||||
if (i === 0) return walk(1, '/')
|
||||
if (!seg.includes('*')) {
|
||||
const next = join(dir, seg)
|
||||
if (i === parts.length - 1) { if (existsSync(next)) out.push(next) } else walk(i + 1, next)
|
||||
return
|
||||
}
|
||||
const re = new RegExp('^' + seg.replace(/[.+^${}()|[\]\\]/g, '\\$&').replace(/\*\*/g, '.*').replace(/\*/g, '[^/]*') + '$')
|
||||
let entries = []
|
||||
try { entries = readdirSync(dir) } catch { return }
|
||||
for (const e of entries) if (re.test(e)) {
|
||||
if (i === parts.length - 1) out.push(join(dir, e)); else walk(i + 1, join(dir, e))
|
||||
}
|
||||
}
|
||||
walk(0, '/')
|
||||
return out
|
||||
}
|
||||
|
||||
/** Every source file reachable from the bundle's entry (library/i18n/locales excluded). */
|
||||
export function bundleGraph(root, bundle) {
|
||||
const entry = join(root, 'plugin-designs', bundle, 'index.js')
|
||||
const seen = new Set()
|
||||
const queue = [entry]
|
||||
const LOCALES = join(root, 'library/i18n/locales')
|
||||
while (queue.length) {
|
||||
const f = queue.pop()
|
||||
if (!f || seen.has(f) || f.startsWith(LOCALES) || f.includes('/node_modules/')) continue
|
||||
seen.add(f)
|
||||
let src = ''
|
||||
try { src = readFileSync(f, 'utf8') } catch { continue }
|
||||
const dir = dirname(f)
|
||||
const add = (spec) => {
|
||||
if (!spec) return
|
||||
let p = null
|
||||
if (spec.startsWith('@map/')) p = join(root, 'library', spec.slice(5))
|
||||
else if (spec.startsWith('.') || spec.startsWith('/')) p = spec.startsWith('/') ? join(root, spec) : resolve(dir, spec)
|
||||
else return // a package: carries none of our keys
|
||||
p = p.replace(/\?.*$/, '')
|
||||
const r = resolveFile(p)
|
||||
if (r) queue.push(r)
|
||||
}
|
||||
for (const m of src.matchAll(IMPORT_RE)) add(m[1] || m[2] || m[3])
|
||||
for (const m of src.matchAll(GLOB_RE)) {
|
||||
const pats = m[1].startsWith('[') ? [...m[1].matchAll(/['"]([^'"]+)['"]/g)].map((x) => x[1]) : [m[1].slice(1, -1)]
|
||||
for (const pat of pats) {
|
||||
if (pat.startsWith('!')) continue
|
||||
const base = pat.startsWith('@map/') ? join(root, 'library') : dir
|
||||
for (const hit of expandGlob(pat.replace(/^@map\//, ''), base)) queue.push(hit)
|
||||
}
|
||||
}
|
||||
}
|
||||
return [...seen]
|
||||
}
|
||||
|
||||
/** The areas a bundle needs, sorted. `extra` is always included (areas the i18n core itself reads). */
|
||||
export function areasForBundle(root, bundle, { extra = [] } = {}) {
|
||||
const areas = allAreas(root)
|
||||
const files = bundleGraph(root, bundle)
|
||||
const text = files.map((f) => { try { return readFileSync(f, 'utf8') } catch { return '' } }).join('\n')
|
||||
const used = new Set(extra)
|
||||
for (const a of areas) {
|
||||
const esc = a.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
||||
if (new RegExp(`['"\`]${esc}[.'"\`]`).test(text)) used.add(a)
|
||||
}
|
||||
return { areas: [...used].filter((a) => areas.includes(a)).sort(), files: files.map((f) => relative(root, f)) }
|
||||
}
|
||||
|
|
@ -91,6 +91,7 @@
|
|||
"check:host-locale-contract": "node internal/check-host-locale-contract.mjs",
|
||||
"check:i18n-never-translated": "node internal/check-i18n-never-translated.mjs",
|
||||
"check:i18n-parity": "node internal/check-i18n-parity.mjs",
|
||||
"check:i18n-treeshake": "node internal/check-i18n-treeshake.mjs",
|
||||
"check:kpi-unit": "node internal/check-kpi-unit-once.mjs",
|
||||
"check:label-joins": "node internal/check-label-joins.mjs",
|
||||
"check:leaf-engine-per-provider": "node internal/check-leaf-engine-per-provider.mjs",
|
||||
|
|
@ -197,7 +198,7 @@
|
|||
"dev": "bun x vite",
|
||||
"fix:sri": "node internal/fix-sri.mjs",
|
||||
"gallery": "node internal/generate-gallery.mjs",
|
||||
"gates:post": "node internal/run-parallel.mjs --label=post -- check:drop check:parse verify check:sri check:public-hygiene check:label-joins check:facet-cascade check:facet-one-field check:heartbeat check:kpi-unit check:composite-reg check:descriptions check:no-abs-paths check:no-codename check:dep-names check:composite-srcs check:embed-targets check:nested-token check:i18n-parity check:tabs-localized check:packets-all check:packet-strings-parity check:zone-census check:share-policy check:library-index check:csv-shape check:capability-citations check:catalog-identity check:no-orphan-gates check:quoted-scripts check:done-evidence check:script-flags check:backlog-status check:capability-exemplars check:packet-fixture check:packet-browser check:provider-switch check:locale-reachable check:url-resolver check:manifest-covers check:one-palette check:pin-dim-slider check:legend-source check:backlog-refs check:clustering-modes check:composite-embed-knobs check:composite-locale-leaks check:linked-vocabulary check:wp-embed check:dashboard-geocoder-provider check:leaf-engine-per-provider check:map-props-unit check:composites-mount check:composite-interop check:no-free-identifiers check:map-fly-reduced-motion check:mst-clustering check:list-prop-unit check:profiles-mount check:cdn-pins check:no-eval check:popup-escaping",
|
||||
"gates:post": "node internal/run-parallel.mjs --label=post -- check:drop check:parse verify check:sri check:public-hygiene check:label-joins check:facet-cascade check:facet-one-field check:heartbeat check:kpi-unit check:composite-reg check:descriptions check:no-abs-paths check:no-codename check:dep-names check:composite-srcs check:embed-targets check:nested-token check:i18n-parity check:tabs-localized check:packets-all check:packet-strings-parity check:zone-census check:share-policy check:library-index check:csv-shape check:capability-citations check:catalog-identity check:no-orphan-gates check:quoted-scripts check:done-evidence check:script-flags check:backlog-status check:capability-exemplars check:packet-fixture check:packet-browser check:provider-switch check:locale-reachable check:url-resolver check:manifest-covers check:one-palette check:pin-dim-slider check:legend-source check:backlog-refs check:clustering-modes check:composite-embed-knobs check:composite-locale-leaks check:linked-vocabulary check:wp-embed check:dashboard-geocoder-provider check:leaf-engine-per-provider check:map-props-unit check:composites-mount check:composite-interop check:no-free-identifiers check:map-fly-reduced-motion check:mst-clustering check:list-prop-unit check:profiles-mount check:cdn-pins check:no-eval check:popup-escaping check:i18n-treeshake",
|
||||
"gates:pre": "node internal/run-parallel.mjs --label=pre -- check:template-literal check:mjs-parse check:sfc-parse check:tab-radius check:synced-css check:doc-scripts",
|
||||
"library-index": "node internal/generate-library-index.mjs",
|
||||
"lists": "node library/scripts/people-groups/build-lists.mjs",
|
||||
|
|
@ -296,6 +297,7 @@
|
|||
"check:heartbeat:self-test": "THREE regression cases, each required to fire ITS OWN assertion: [literal] resolver replaced by the old hard-coded array -> B; [shipped-default] dashboard.config.js heartbeatThreshold changed -> A2; [shipped-mode] heartbeatMode flipped to quantile -> A2. Poisons IN MEMORY, never the file. Testing that the FEATURE works is not testing that the REGRESSION is caught — this gate shipped with only the first and missed the second.",
|
||||
"check:host-locale-contract": "a HOST's profile-config.locale reaches every leaf: each leaf page is loaded with ONLY \"locale\":\"ar\" added to its profile-config (no ?lang=, the page keeps lang=\"en\") and must draw at least half the Arabic text the same page draws at ?lang=ar. regions-dashboard/regions-list is the positive control (NOT MEASURED if it does not enter Arabic). Guards ProfileLoader's inject('appI18n'): a bundle that never provides appI18n silently ignores the host. --dir <tree> for a before/after, --only a/b,c/d to narrow. Through browser-slot.sh.",
|
||||
"check:i18n-never-translated": "a key PRESENT in all 11 catalogs but IDENTICAL in all of them is copied, not translated. check:i18n-parity asserts presence; presence and translatedness are different properties. Carries an allowlist for keys correctly identical everywhere (pure format strings, brand names, ISO designations) — each with a reason, so the list records decisions rather than silencing findings.",
|
||||
"check:i18n-treeshake": "each built bundle carries every translation area its code asks for: every area-prefixed key literal in <bundle>.js belongs to an area the build included (the build narrows library/i18n's catalogue glob per bundle, internal/i18n-areas.mjs). Headless.",
|
||||
"check:leaf-engine-per-provider": "every leaf in the manifest at ?provider=maplibre makes ZERO requests to any *.mapbox.com host and a map leaf still draws; at its default every map leaf draws with the engine it drew with before (read from the canvas that drew). --baseline <tree> compares against another tree (e.g. main). Browser gate, @browser-slot-max-hold 2700.",
|
||||
"check:leaves-mount": "DID THE ELEMENT ACTUALLY MOUNT, on all five gospel leaves. Exists because a ReferenceError thrown during setup dies INSIDE the custom-element boundary: the page loads, every asset returns 200, pageerror fires zero times, and the element sits there with an empty shadow root. A healthy-looking dead page. This asks the cheap unambiguous question the rich gates are too slow to ask on every leaf — is anything there — in five steps that each name their own failure: tag defined, host present, shadowRoot attached, shadow root non-empty, visible text rendered. It does NOT trust the absence of console errors; zero errors is not the same as working. --poison for the negative test.",
|
||||
"check:legend-export-reachable": "the Export affordance must be reachable at BOTH widths. Selects a leaf row first (the control is gated on selectedNode) and opens the mobile sheet first (it starts closed). Asserts a REAL BOX, not CSS visibility — on mobile the elements report visibility:visible at 0x0. PASSES at both widths since the mobile sheet was given :export-enabled and :tabs. SKIPs when nothing is served.",
|
||||
|
|
|
|||
|
|
@ -58,6 +58,27 @@ function discoverBundles() {
|
|||
return entries
|
||||
}
|
||||
|
||||
// Narrow library/i18n/index.js's catalogue glob to the areas one bundle uses (see plugins below).
|
||||
function i18nAreasPlugin(bundle) {
|
||||
let areas = null
|
||||
return {
|
||||
name: 'doxa-i18n-areas',
|
||||
enforce: 'pre',
|
||||
async buildStart() {
|
||||
const { areasForBundle } = await import('./internal/i18n-areas.mjs')
|
||||
areas = areasForBundle(__dirname, bundle).areas
|
||||
this.info?.(`i18n areas for ${bundle}: ${areas.join(', ') || '(none)'}`)
|
||||
},
|
||||
transform(code, id) {
|
||||
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 }
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export default defineConfig(({ command, mode }) => {
|
||||
// .env values (VITE_MAPBOX_TOKEN) are NOT on process.env inside this config —
|
||||
// load them explicitly so the dev plugin gallery can hand the token to each map.
|
||||
|
|
@ -114,6 +135,10 @@ export default defineConfig(({ command, mode }) => {
|
|||
// ship verbatim, so their block comments are stripped before the Vue compiler
|
||||
// runs (enforce: 'pre'). Dev keeps the comments.
|
||||
plugins: [
|
||||
// ★ ONLY THE TRANSLATION AREAS THIS BUNDLE USES (CRITIC-10). library/i18n/index.js globs every
|
||||
// locales/*/*.json; in a BUNDLE build the glob is narrowed to the areas internal/i18n-areas.mjs finds
|
||||
// in this bundle's source graph. Dev (no BUNDLE) keeps every area. check:i18n-treeshake verifies it.
|
||||
isBuild && target ? i18nAreasPlugin(target) : null,
|
||||
stripShadowCssCommentsPlugin(),
|
||||
// ─── STRUCTURAL shadow-DOM CSS fix (customElement mode) ──────────────────
|
||||
// WHY: every bundle here is a `defineCustomElement(...)` that renders inside a
|
||||
|
|
|
|||
Loading…
Reference in a new issue