doxa-maps/plugin-designs/research-map-plus-grid/scripts/check-ui-strings-have-keys.mjs
Doxa Maps 802d5ed412
Some checks are pending
Deploy to GitHub Pages / build (push) Waiting to run
Deploy to GitHub Pages / deploy (push) Blocked by required conditions
Update from source dee9f9c9
2026-09-26 13:26:59 +00:00

165 lines
8.1 KiB
JavaScript

#!/usr/bin/env node
/**
* check-ui-strings-have-keys.mjs — user-visible text must come from the catalogue, and the ground
* we have won must not be given back.
*
* ★ WHY THIS EXISTS, AND WHY NO EXISTING CHECK COULD SEE IT.
* check:composite-locale-leaks asks "does the catalogue translate this string, and is the page
* showing English anyway?" — a LOOKUP, and a good one. But its criterion is that the catalogue
* already HOLDS the string, so a string nobody ever added is invisible to it by construction. And
* it runs in a browser, so it only ever sees what happens to be rendered on one screen.
*
* That blind spot was measured, not guessed: after both composites passed the leak gate with 0
* findings, a source audit of the same two trees found 31 more user-visible strings — 24 with no
* catalogue entry at all and 7 that HAD a translation in eleven languages and were still typed into
* the template. Most were aria-labels, titles and tooltips: text only a screen-reader user or a
* hovering mouse ever meets. Our localization verification had been "what can I see", and the blind
* spot was exactly the readers who most need the words.
*
* TWO CLASSES, because they are different defects with different fixes:
* NO KEY wording must be written and translated — a decision, sometimes the agent's.
* HAS A KEY the translation already ships in eleven locales and the reader is shown English
* anyway. The cheapest defect in the catalogue: the work was done and wasted.
*
* ★ WARN-ONLY AGAINST A BASELINE, ON PURPOSE. Failing on the whole backlog would make this
* unlandable and it would be switched off within a day. It fails on what is NEW — a string added
* today with no key, or a newly hardcoded one — so the count can only fall. When a batch lands,
* refresh the baseline with --update and the gate holds the new line.
*
* QaCorner is excluded by name: it is an opt-in DEVELOPER widget, not reader-facing, and studio is
* making it host-neutral or removing it. Eleven locales for a corner only we open is not worth the
* translations, and that is a judgement worth writing down rather than leaving as a silent gap.
*
* USAGE node .../check-ui-strings-have-keys.mjs check against the baseline
* node .../check-ui-strings-have-keys.mjs --update rewrite the baseline from today's tree
* Exit: 0 clean or only improvements · 1 a NEW uncatalogued or newly hardcoded string.
*/
import { readFileSync, writeFileSync, readdirSync, statSync, existsSync } from 'node:fs'
import { resolve, dirname, join, relative } from 'node:path'
import { fileURLToPath } from 'node:url'
import { parse as parseSFC } from '@vue/compiler-sfc'
const TAG = 'UI-STRINGS-HAVE-KEYS'
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..', '..', '..')
const BASELINE = join(ROOT, 'plugin-designs', 'research-map-plus-grid', 'scripts', 'ui-strings-baseline.json')
const ROOTS = ['plugin-designs', 'library']
// Not reader-facing: an opt-in developer widget, being made host-neutral or removed.
const EXCLUDE = [/\/qa\/QaCorner\.vue$/]
// ── the catalogue: English value -> the key(s) that hold it ─────────────────────────────────────
const flat = (o, p, out) => {
for (const [k, v] of Object.entries(o || {})) {
const K = p ? `${p}.${k}` : k
if (v && typeof v === 'object') flat(v, K, out); else out[K] = v
}
return out
}
const en = {}
const enDir = join(ROOT, 'library/i18n/locales/en')
for (const f of readdirSync(enDir).filter((f) => f.endsWith('.json'))) {
flat(JSON.parse(readFileSync(join(enDir, f), 'utf8')), f.replace(/\.json$/, ''), en)
}
const known = new Map()
for (const [k, v] of Object.entries(en)) {
const t = String(v).trim()
if (!t) continue
if (!known.has(t)) known.set(t, [])
known.get(t).push(k)
}
const vues = (d, out = []) => {
let es; try { es = readdirSync(d) } catch { return out }
for (const e of es) {
if (e === 'node_modules' || e.startsWith('.')) continue
const p = join(d, e)
if (statSync(p).isDirectory()) vues(p, out); else if (e.endsWith('.vue')) out.push(p)
}
return out
}
const VISIBLE_ATTRS = new Set(['placeholder', 'title', 'aria-label', 'alt', 'label', 'aria-placeholder', 'aria-description'])
/** Worth translating? Real words, not a glyph, a number, a class name or a CSS value. */
const worthIt = (t) => {
const s = String(t || '').trim()
if (s.length < 3 || s.length > 120) return false
if (!/[A-Za-z]{2}/.test(s)) return false
if (!/[A-Za-z]/.test(s[0])) return false
if (/^[a-z][a-z0-9-]*$/.test(s)) return false
if (/[{}<>;]|:\s*\d|px$|rem$|%$/.test(s)) return false
if (/^(true|false|null|undefined)$/i.test(s)) return false
return true
}
function walk(node, hit) {
if (!node) return
if (node.type === 2 && node.content) hit(node.content)
if (node.type === 1) {
for (const p of node.props || []) {
if (p.type === 6 && VISIBLE_ATTRS.has(p.name) && p.value && p.value.content) hit(p.value.content)
}
}
for (const c of node.children || []) walk(c, hit)
if (node.branches) for (const b of node.branches) walk(b, hit)
}
const findings = { noKey: {}, hasKey: {} }
let scanned = 0
for (const r of ROOTS) {
for (const file of vues(join(ROOT, r))) {
const rel = relative(ROOT, file)
if (EXCLUDE.some((re) => re.test(rel))) continue
let d
try { ({ descriptor: d } = parseSFC(readFileSync(file, 'utf8'), { filename: file })) } catch { continue }
if (!d.template || !d.template.ast) continue
scanned++
const seen = new Set()
walk(d.template.ast, (text) => {
const t = String(text).replace(/\s+/g, ' ').trim()
if (!worthIt(t) || seen.has(t)) return
seen.add(t)
const bucket = known.has(t) ? findings.hasKey : findings.noKey
;(bucket[rel] ||= []).push(t)
})
}
}
for (const b of [findings.noKey, findings.hasKey]) for (const k of Object.keys(b)) b[k].sort()
if (process.argv.includes('--update')) {
writeFileSync(BASELINE, JSON.stringify(findings, null, 2) + '\n')
const n = (b) => Object.values(b).reduce((a, l) => a + l.length, 0)
console.log(`${TAG}: baseline rewritten — ${n(findings.noKey)} without a key, ${n(findings.hasKey)} hardcoded-with-a-key, across ${scanned} components.`)
process.exit(0)
}
if (!existsSync(BASELINE)) {
console.log(`${TAG}: FAIL — no baseline at ${relative(ROOT, BASELINE)}. Create it with --update, and read it before you do.`)
process.exit(1)
}
const base = JSON.parse(readFileSync(BASELINE, 'utf8'))
const inBase = (cls, file, text) => (base[cls]?.[file] || []).includes(text)
const fresh = { noKey: [], hasKey: [] }
const fixed = { noKey: [], hasKey: [] }
for (const cls of ['noKey', 'hasKey']) {
for (const [file, list] of Object.entries(findings[cls])) {
for (const t of list) if (!inBase(cls, file, t)) fresh[cls].push([file, t])
}
for (const [file, list] of Object.entries(base[cls] || {})) {
for (const t of list) if (!(findings[cls][file] || []).includes(t)) fixed[cls].push([file, t])
}
}
const count = (b) => Object.values(b).reduce((a, l) => a + l.length, 0)
console.log(`${TAG}: ${scanned} component(s); ${count(findings.noKey)} string(s) with no catalogue entry, ${count(findings.hasKey)} with a key but hardcoded.`)
if (fixed.noKey.length || fixed.hasKey.length) {
console.log(` ✓ ${fixed.noKey.length + fixed.hasKey.length} baseline string(s) fixed since it was set — refresh with --update to hold the new line.`)
}
if (fresh.noKey.length || fresh.hasKey.length) {
console.log(`\n${TAG}: FAIL — ${fresh.noKey.length + fresh.hasKey.length} NEW string(s) since the baseline:`)
for (const [f, t] of fresh.noKey) console.log(` ✗ no key ${f} ${JSON.stringify(t)}`)
for (const [f, t] of fresh.hasKey) console.log(` ✗ has a key ${f} ${JSON.stringify(t)} — the translation already ships; read it instead of typing it`)
console.log('\n A string with NO key needs wording and eleven translations, which is sometimes the')
console.log(' the agent\'s call. A string that HAS a key needs one edit: the work is already done.')
process.exit(1)
}
console.log(`${TAG}: PASS — no new English has appeared in a template since the baseline.`)