From a4b79f687545697c1b61c0995adcb9a01c6a9a3d Mon Sep 17 00:00:00 2001 From: Doxa Maps Date: Sat, 26 Sep 2026 06:47:18 +0000 Subject: [PATCH] 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. --- internal/check-i18n-treeshake.mjs | 37 +++++++++++ internal/i18n-areas.mjs | 105 ++++++++++++++++++++++++++++++ package.json | 4 +- vite.config.js | 25 +++++++ 4 files changed, 170 insertions(+), 1 deletion(-) create mode 100644 internal/check-i18n-treeshake.mjs create mode 100644 internal/i18n-areas.mjs diff --git a/internal/check-i18n-treeshake.mjs b/internal/check-i18n-treeshake.mjs new file mode 100644 index 000000000..485a8ae90 --- /dev/null +++ b/internal/check-i18n-treeshake.mjs @@ -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 .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.`) diff --git a/internal/i18n-areas.mjs b/internal/i18n-areas.mjs new file mode 100644 index 000000000..260d36b5e --- /dev/null +++ b/internal/i18n-areas.mjs @@ -0,0 +1,105 @@ +/** + * i18n-areas.mjs — which translation AREAS (library/i18n/locales//.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//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)) } +} diff --git a/package.json b/package.json index 722fbc6b2..738392c2f 100644 --- a/package.json +++ b/package.json @@ -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 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 .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 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.", diff --git a/vite.config.js b/vite.config.js index 0220c1a4f..42c7eae9e 100644 --- a/vite.config.js +++ b/vite.config.js @@ -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