Merge branch 'main' of https://git.nuxtbase.app/doxa-life/doxa-maps into bundler-1/work
This commit is contained in:
commit
93c52cf498
2 changed files with 184 additions and 0 deletions
183
internal/check-leaf-english-leaks.mjs
Normal file
183
internal/check-leaf-english-leaks.mjs
Normal file
|
|
@ -0,0 +1,183 @@
|
|||
#!/usr/bin/env node
|
||||
/**
|
||||
* check-leaf-english-leaks — a translated key must not render in English, on ANY leaf.
|
||||
*
|
||||
* THE METHOD is rega's, by way of dx-shepherd, and is already proven in
|
||||
* check-gospel-english-leaks.mjs. A string is a LEAK when the catalogue has a DIFFERENT value
|
||||
* for the locale and the page still shows the English one. That is a render or wiring fault,
|
||||
* and it is a different thing from an untranslated string, which is a catalogue gap.
|
||||
* Only the first is this check's business.
|
||||
*
|
||||
* ★ WHY A SECOND FILE RATHER THAN A FOURTH COPY OF THE LOGIC. The gospel gate hardcodes its four
|
||||
* leaves, its element tag (`gospel-resources-map`) and its single catalogue file (gospel.json).
|
||||
* Pointed at another bundle it finds no shadow root and silently compares nothing. This one takes
|
||||
* the leaf, the tag and the locales as arguments and compares against EVERY catalogue area,
|
||||
* because a COMPOSITE has no t() calls of its own — its chrome comes from the children it nests,
|
||||
* so restricting the comparison to one area would miss most of what it renders.
|
||||
*
|
||||
* THREE CORRECTIONS CARRIED OVER, each of which cost a false reading in the gospel gate before it
|
||||
* was right. They are the reason this is a copy of a method rather than a fresh idea:
|
||||
* 1. READ ONLY THE CHROME. Reading the whole shadow root reported 113 leaks, nearly all false:
|
||||
* brand names and short catalogue entries match inside explanatory English prose. Prose
|
||||
* blocks declare themselves with dir="ltr"; the chrome is everything outside them.
|
||||
* 2. WHOLE LABELS, NOT SUBSTRINGS. Substring matching reported keys never rendered at all —
|
||||
* "Language" matched inside the DATA ("Sign Language"), "Region" inside a region name. The
|
||||
* unit is an element's OWN trimmed text.
|
||||
* 3. A PAGE THAT RENDERED NOTHING IS NOT A CLEAN PAGE. Zero labels is reported, never passed.
|
||||
*
|
||||
* ⚠ AND ONE ADDED HERE: a locale with NOTHING to leak cannot prove anything. If no catalogue
|
||||
* value for a locale differs from English among the labels actually on the page, the comparison
|
||||
* is vacuous and says so, rather than counting as a clean result.
|
||||
*
|
||||
* Usage:
|
||||
* node internal/check-leaf-english-leaks.mjs <bundle>/<leaf>:<tag> [more...]
|
||||
* LOCALES=ar,zh,ru,hi BASE_URL=http://127.0.0.1:4173
|
||||
*/
|
||||
import { chromium } from 'playwright'
|
||||
import { readFileSync, readdirSync, existsSync } from 'node:fs'
|
||||
import { resolve, dirname, join } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { servesThisTree } from './_serves-this-tree.mjs'
|
||||
|
||||
const HERE = dirname(fileURLToPath(import.meta.url))
|
||||
const REPO = resolve(HERE, '..')
|
||||
const BASE = process.env.BASE_URL || 'http://127.0.0.1:4173'
|
||||
const LOCALES = (process.env.LOCALES || 'ar,zh,ru,hi').split(',').map((s) => s.trim()).filter(Boolean)
|
||||
|
||||
// <bundle>/<leaf>:<custom-element-tag>
|
||||
const TARGETS = (process.argv.slice(2).length ? process.argv.slice(2) : [
|
||||
'dashboard-plus-grid/dashboard-plus-grid:dashboard-plus-grid',
|
||||
'research-map-plus-grid/research-map-plus-grid:research-map-plus-grid',
|
||||
]).map((s) => {
|
||||
const [path, tag] = s.split(':')
|
||||
return { path, tag: tag || path.split('/').pop() }
|
||||
})
|
||||
|
||||
const LOC_DIR = resolve(REPO, 'library/i18n/locales')
|
||||
const flat = (o, p = []) => Object.entries(o).flatMap(([k, v]) =>
|
||||
k.startsWith('_') ? [] : (v && typeof v === 'object' ? flat(v, [...p, k]) : [[[...p, k].join('.'), String(v)]]))
|
||||
// EVERY area, not one file — a composite's chrome comes from the children it nests.
|
||||
const catalogue = (lc) => {
|
||||
const dir = join(LOC_DIR, lc)
|
||||
if (!existsSync(dir)) return {}
|
||||
const out = {}
|
||||
for (const f of readdirSync(dir).filter((x) => x.endsWith('.json'))) {
|
||||
try { Object.assign(out, Object.fromEntries(flat(JSON.parse(readFileSync(join(dir, f), 'utf8')))
|
||||
.map(([k, v]) => [f.replace(/\.json$/, '') + '.' + k, v]))) } catch { /* unreadable area */ }
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
let reachable = false
|
||||
try { const r = await fetch(`${BASE}/doxa-maps/manifest.json`, { signal: AbortSignal.timeout(4000) }); reachable = r.ok } catch { /* precondition */ }
|
||||
if (!reachable) { console.log(`LEAF-ENGLISH-LEAKS: SKIP — nothing served at ${BASE}.`); process.exit(0) }
|
||||
const treeCheck = await servesThisTree(BASE, REPO)
|
||||
if (!treeCheck.ok) { console.error(`LEAF-ENGLISH-LEAKS: NOT MEASURED — ${treeCheck.why}`); process.exit(2) }
|
||||
console.log('LEAF-ENGLISH-LEAKS')
|
||||
console.log(` tree identity: OK (served manifest matches this checkout)`)
|
||||
|
||||
const en = catalogue('en')
|
||||
const browser = await chromium.launch({ args: ['--use-gl=swiftshader', '--enable-unsafe-swiftshader'] })
|
||||
const rows = []
|
||||
const fails = []
|
||||
|
||||
for (const t of TARGETS) {
|
||||
for (const lc of LOCALES) {
|
||||
const page = await browser.newPage({ viewport: { width: 1440, height: 1000 } })
|
||||
const errs = []
|
||||
page.on('pageerror', (e) => errs.push(String(e).slice(0, 110)))
|
||||
let labels = []
|
||||
try {
|
||||
const res = await page.goto(`${BASE}/doxa-maps/${t.path}/index.html?lang=${lc}`,
|
||||
{ waitUntil: 'load', timeout: 60000 })
|
||||
if (!res || !res.ok()) { rows.push({ t: t.path, lc, skipped: `HTTP ${res ? res.status() : '?'}` }); await page.close(); continue }
|
||||
await page.waitForTimeout(16000)
|
||||
labels = await page.evaluate((tag) => {
|
||||
const host = document.querySelector(tag)
|
||||
const sr = host && host.shadowRoot
|
||||
if (!sr) return []
|
||||
const out = new Set()
|
||||
const walk = (node, excluded) => {
|
||||
for (const child of node.childNodes) {
|
||||
if (child.nodeType !== 1) continue
|
||||
if (child.tagName === 'STYLE' || child.tagName === 'SCRIPT') continue
|
||||
// ⚠ DATA CELLS ARE NOT CHROME, and this cost a false finding before it was here.
|
||||
// The first run reported 3 "leaks" x 4 locales on dashboard-plus-grid: "Unengaged"
|
||||
// and "Other". Both ARE translated in every catalogue, so they looked like textbook
|
||||
// wiring faults — until the hits turned out to sit in .rd-td inside .rd-grid, i.e.
|
||||
// TABLE DATA. people-groups-grid.vue:150 records the field as
|
||||
// {value:'unengaged', label:'Unengaged'}, 2 distinct values, 100% populated, and
|
||||
// dashboard.config.js:132 counts 2,081 Unengaged / 27 Engaged. They are API VALUES
|
||||
// that happen to equal a catalogue string.
|
||||
// That is the population rega's method excludes by name ("DATA, the people-group and
|
||||
// place names that come from the publishers"). Whole-label matching stops a substring
|
||||
// impersonating a label; it cannot stop a DATA CELL rendering the same text. So the
|
||||
// table body is excluded by ROLE, the way prose already is.
|
||||
const cls = (child.className && typeof child.className === 'string') ? child.className : ''
|
||||
const isData = /\b(rd-td|rd-row|rd-grid|rd-cell|pg-cell|cell-value)\b/.test(cls)
|
||||
|| child.getAttribute?.('role') === 'cell' || child.tagName === 'TD'
|
||||
// ⚠ A FACET VALUE IS DATA WEARING A LABEL'S CLOTHES — the second false finding, and
|
||||
// subtler than the table cells. `regions.Other` was reported leaking in all four
|
||||
// locales from span.fr-lbl in the facet rail. But that template is
|
||||
// `<span class="fr-lbl">{{ r.label }}</span>` with `{{ r.count }}` beside it, and the
|
||||
// component's own comment calls it "the 'Other' bucket": the string comes from the
|
||||
// ROW DATA, not from t('regions.Other'). Whether that bucket ought to be localised is
|
||||
// a real product question, but it is NOT "a translated key rendering in English".
|
||||
// EXCLUDED BY THE COUNT, NOT BY THE CLASS: a rail row that reports a tally is
|
||||
// enumerating data. Excluding .fr-lbl wholesale would also blind this to the rail's
|
||||
// own section headings, which ARE chrome and SHOULD be caught.
|
||||
const isFacetValue = /\bfr-(lbl|cnt|bar|sw)\b/.test(cls)
|
||||
&& !!child.parentElement?.querySelector?.('.fr-cnt')
|
||||
const skip = excluded || isData || isFacetValue
|
||||
|| (child.getAttribute && child.getAttribute('dir') === 'ltr')
|
||||
const own = [...child.childNodes].filter((n) => n.nodeType === 3)
|
||||
.map((n) => n.textContent.trim()).filter(Boolean).join(' ').trim()
|
||||
if (own && !skip) out.add(own)
|
||||
// Cross nested shadow roots too: a composite's chrome lives inside its children.
|
||||
if (child.shadowRoot) walk(child.shadowRoot, skip)
|
||||
walk(child, skip)
|
||||
}
|
||||
}
|
||||
walk(sr, false)
|
||||
return [...out]
|
||||
}, t.tag)
|
||||
} catch (e) {
|
||||
rows.push({ t: t.path, lc, skipped: String(e.message || e).slice(0, 60) })
|
||||
await page.close(); continue
|
||||
}
|
||||
await page.close()
|
||||
|
||||
if (!labels.length) { fails.push(`${t.path} ${lc}: rendered no labels at all — nothing was measured`); rows.push({ t: t.path, lc, labels: 0 }); continue }
|
||||
const loc = catalogue(lc)
|
||||
const labelSet = new Set(labels)
|
||||
const leaks = []
|
||||
let comparable = 0
|
||||
for (const [key, e] of Object.entries(en)) {
|
||||
const tr = loc[key]
|
||||
if (tr === undefined || tr === e) continue // no translated value -> nothing to leak
|
||||
if (!labelSet.has(e) && !labelSet.has(tr)) continue // this key is not on the page at all
|
||||
comparable++
|
||||
if (labelSet.has(e) && !labelSet.has(tr)) leaks.push(`${key} reads English "${e.slice(0, 38)}"`)
|
||||
}
|
||||
rows.push({ t: t.path, lc, labels: labels.length, comparable, leaks: leaks.length, errs: errs.length })
|
||||
// ⚠ A LOCALE WITH NOTHING COMPARABLE PROVES NOTHING. Saying "0 leaks" over an empty
|
||||
// comparison set is the same failure as passing a page that rendered nothing.
|
||||
if (!comparable) { fails.push(`${t.path} ${lc}: 0 comparable keys on the page — the comparison is vacuous, not clean`); continue }
|
||||
for (const l of leaks) fails.push(`${t.path} ${lc}: ${l}`)
|
||||
if (errs.length) fails.push(`${t.path} ${lc}: ${errs.length} page error(s) — ${errs[0]}`)
|
||||
}
|
||||
}
|
||||
await browser.close()
|
||||
|
||||
for (const r of rows) {
|
||||
if (r.skipped) { console.log(` ${r.t} @ ${r.lc} SKIP — ${r.skipped}`); continue }
|
||||
console.log(` ${r.t} @ ${r.lc} labels ${r.labels} · comparable ${r.comparable ?? 0} · `
|
||||
+ `${r.leaks ? `LEAKS ${r.leaks}` : 'no leaks'} · ${r.errs ?? 0} error(s)`)
|
||||
}
|
||||
if (fails.length) {
|
||||
console.error(`\nLEAF-ENGLISH-LEAKS: FAIL — ${fails.length} problem(s)`)
|
||||
for (const f of fails.slice(0, 20)) console.error(` - ${f}`)
|
||||
process.exit(1)
|
||||
}
|
||||
console.log(`\nLEAF-ENGLISH-LEAKS: PASS — ${rows.filter((r) => !r.skipped).length} leaf x locale `
|
||||
+ `combination(s) in ${LOCALES.join('/')}; every translated key that reaches the page renders translated.`)
|
||||
|
|
@ -84,6 +84,7 @@
|
|||
"check:i18n-parity": "node internal/check-i18n-parity.mjs",
|
||||
"check:kpi-unit": "node internal/check-kpi-unit-once.mjs",
|
||||
"check:label-joins": "node internal/check-label-joins.mjs",
|
||||
"check:leaf-english-leaks": "node internal/check-leaf-english-leaks.mjs",
|
||||
"check:leaves-mount": "node internal/check-gospel-leaves-mount.mjs",
|
||||
"check:leaves-mount:poison": "node internal/check-gospel-leaves-mount.mjs --poison",
|
||||
"check:legend-export-reachable": "node internal/check-legend-export-reachable.mjs",
|
||||
|
|
|
|||
Loading…
Reference in a new issue