105 lines
4.9 KiB
JavaScript
105 lines
4.9 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)
|
|
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)) }
|
|
}
|