doxa-maps/internal/i18n-areas.mjs
Doxa Maps 89471b2880 A bundle carries a translation area only where its code uses it, not wherever the area's name appears
i18n-areas kept an area whenever its name showed up as any string literal
in a bundle's source graph, to cover keys assembled at runtime. But
'gospel', 'religion', 'regions', 'detail', 'poster' and others are also
ordinary ids (a colour strategy, a data source, a field, a mode), so every
bundle importing those modules carried catalogues it never shows, in all
11 locales: 80 area inclusions across 26 bundles, and gospel's catalogue
alone pushed country-posters 9.5 KB over the ceiling.

A bare name now counts only where it really builds a key: a namespace
helper (useOptionalT, useOptionalI18n, qualify) or a translator reading a
whole area (tm('regions') in useRegionsI18n is the one in use). Key
prefixes ('area.' / `area.${x}`) count as before. A bundle that assembles a
key with a leading `${…}` keeps the old generous rule, since its area
cannot be read from the source.
2026-09-29 09:18:26 +00:00

117 lines
6.2 KiB
JavaScript

/**
* 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)
// ★ A BARE NAME COUNTS ONLY WHERE IT BUILDS A KEY. The first version kept an area wherever its name
// appeared as ANY string literal, to cover keys assembled at runtime. But several area names are also
// ordinary ids — 'gospel' is a colour strategy, a data source and a chart id; 'religion', 'regions',
// 'detail', 'poster' are fields and modes — so every bundle importing those modules carried catalogues
// it never shows: measured, 80 area inclusions across 26 bundles, and gospel's catalogue alone pushed
// country-posters 9.5 KB over the 1.5 MB ceiling. The ways a bare name really does build a key here are
// few and named: a namespace helper (useOptionalT / useOptionalI18n / qualify) or a translator reading a
// whole area (tm / t / te / tStatic / rt with the name alone). A bundle whose code assembles a key with a
// leading `${…}` keeps the old, generous rule, since its area cannot be read from the source.
const assemblesUnknownKeys = /\b(?:t|te|tm|tStatic|T)\(\s*`\$\{/.test(text)
for (const a of areas) {
const esc = a.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
if (new RegExp(`['"\`]${esc}\\.`).test(text)) { used.add(a); continue } // 'area.key'
if (new RegExp(`\\b(?:useOptionalT|useOptionalI18n|qualify|tm|t|te|tStatic|rt)\\(\\s*['"\`]${esc}['"\`]`).test(text)) { used.add(a); continue }
if (assemblesUnknownKeys && new RegExp(`['"\`]${esc}['"\`]`).test(text)) used.add(a)
}
return { areas: [...used].filter((a) => areas.includes(a)).sort(), files: files.map((f) => relative(root, f)) }
}