doxa-maps/plugin-designs/poster-design/scripts/check-catalogue.mjs
Doxa Maps 1e53b531b0 Poster Design draws UPG100's zone posters, from a library copy of UPG100's zone code
UPG100's zone-poster code lived only inside its plugin, so Poster Design
(which draws from the library) could list UPG100's posters but never show
them. It is now COPIED, unchanged, into library/packet/upg100Zones/
(zone-sheet-print, zone-sheet, legend-tree, selector-evaluator,
zone-geometry, zones-for-list, clusters.json; only the library import paths
are shorter). UPG100's own files are untouched.

Poster Design adds a UPG100 zone posters family drawn from that copy with a
real zone (middle-east-core) and all the records, as UPG100's own wall
does: the letter, 3 x 3 and 3 x 4 zone posters under UPG100's own design
names (so the drawn card replaces the no-preview plugin card), and the
prayer packet's page 1 (the full letter zone poster). Keys in 11 locales.

check:upg100-zone-copy (in gates:pre) keeps the copy equal to UPG100's
originals beyond the import paths; once UPG100 switches to the library copy
and deletes its originals it becomes a one-path gate. Its self-test proves
a one-character edit, a changed clusters.json, a missing file, a half
switch and a re-definition each fail. check:poster-design-catalogue allows
a template to carry a plugin's design name only when it declares that
plugin (poisoned). Source only: poster-design is rebuilt when the heavy
slot reopens.
2026-10-03 04:26:50 +00:00

201 lines
13 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env node
/**
* check-catalogue.mjs (check:poster-design-catalogue): Poster Design's configuration catalogue
* (plugin-designs/poster-design/src/templates.js) says only true things about the library.
*
* 1. SHEET_FORMATS has exactly the formats poster-render.js FORMATS defines, at the same pixel size.
* The generator is served as a pinned asset, not bundled, so the catalogue cannot import the table;
* this is what keeps the two from drifting.
* 2. Every configuration name is unique and shaped like a name (lowercase words, dots, dashes, digits):
* a name is what a person hands an agent, so two cards must never share one.
* 3. The card entries are exactly card-render.js STOCK, one per stock.
* 4. Every catalogue key an entry names (labelKey, missing, the family keys) exists in the English
* posterDesign catalogue.
* 5. Every sample country (the default and each format's own) exists in the country data and can be drawn (has
* geometry, a sheet tier), unless the shared path WITHHOLDS it (countrySheet.js sheetWithheld): that card shows
* the sentence instead of a drawing, so it is never drawn here.
* 6. PLUGIN DESIGNS: every "posterDesign" block in plugin-designs/<plugin>/<profile>.meta.json is valid
* (src/pluginDesigns.js blockProblems: family, name, size, mobileFriendly, optional labelKey / instance), its
* labelKey exists in the English catalogue, and its instance is a preset of that profile.
* 7. Names are unique across templates.js AND every plugin's blocks.
* 8. THE NAMES LEDGER (src/catalogue-names.json): every name ever shipped. A ledger name that no longer exists
* (renamed or removed) FAILS: a name is what people hand an agent, so it must keep working (a retired size stays
* as a legacy entry, as templates.js does). A new name passes and is reported, to be added to the ledger.
* A catalogue that lists nothing FAILS (vacuous guard). --self-test: an invalid block, a name clash, a renamed
* ledger name, a block naming a missing preset, and a sample that exists but cannot be drawn (India with withholding
* off) must each FAIL, and the documented example block must PASS.
*/
import { readFileSync, readdirSync, existsSync } from 'node:fs'
import { resolve, dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '../../..')
const failures = []
const fail = (m) => failures.push(m)
const { createServer } = await import('vite')
const server = await createServer({
root: ROOT, configFile: false, logLevel: 'error', appType: 'custom',
server: { middlewareMode: true, hmr: false, ws: false, watch: null },
resolve: { alias: { '@map': join(ROOT, 'library') } },
})
let cat, STOCK, PD, sheetWithheld
try {
;({ sheetWithheld } = await server.ssrLoadModule('/library/packet/countrySheet.js'))
cat = await server.ssrLoadModule('/plugin-designs/poster-design/src/templates.js')
PD = await server.ssrLoadModule('/plugin-designs/poster-design/src/pluginDesigns.js')
;({ STOCK } = await server.ssrLoadModule('/library/packet/card-render.js'))
} finally { await server.close() }
const configs = cat.listConfigurations()
if (!configs.length) fail('vacuous: listConfigurations() returned nothing')
// 1. formats against the generator's own table
const src = readFileSync(join(ROOT, 'library/packet/poster-render.js'), 'utf8')
const block = (src.match(/const FORMATS = \{([\s\S]*?)\n\};?/) || [])[1]
if (!block) fail('poster-render.js: could not find "const FORMATS = {"; update this check')
else {
const found = {}
for (const m of block.matchAll(/^\s*'?([\w]+)'?\s*:\s*\{\s*w:\s*(\d+),\s*h:\s*(\d+)/gm)) found[m[1]] = [Number(m[2]), Number(m[3])]
const names = Object.keys(found)
if (names.length < 3) fail(`poster-render.js FORMATS: parsed only ${names.length} format(s) (${names.join(', ')}); the parser no longer matches`)
for (const k of names) {
const mine = cat.SHEET_FORMATS[k]
if (!mine) fail(`FORMATS has '${k}' and the catalogue does not`)
else if (mine.px[0] !== found[k][0] || mine.px[1] !== found[k][1]) fail(`'${k}': catalogue says ${mine.px.join('×')}, FORMATS says ${found[k].join('×')}`)
}
for (const k of Object.keys(cat.SHEET_FORMATS)) if (!found[k]) fail(`the catalogue has format '${k}', which FORMATS does not`)
}
// 2. names
for (const c of configs) {
if (!/^[a-z0-9]+(?:[.-][a-z0-9]+)*$/i.test(c.name)) fail(`'${c.name}' is not a configuration name (words joined by . or -)`)
}
// 3. stocks
const stockNames = configs.filter((c) => c.family === 'cards').map((c) => c.settings.stock).sort()
const want = Object.keys(STOCK).sort()
if (JSON.stringify(stockNames) !== JSON.stringify(want)) fail(`card configurations ${JSON.stringify(stockNames)} are not card-render STOCK ${JSON.stringify(want)}`)
// 4. keys exist in English
const en = JSON.parse(readFileSync(join(ROOT, 'library/i18n/locales/en/posterDesign.json'), 'utf8'))
// any catalogue key, in any area: "<area>.<path>" in library/i18n/locales/en/<area>.json
const enKey = (key) => {
const [area, ...parts] = String(key).split('.')
const file = join(ROOT, `library/i18n/locales/en/${area}.json`)
if (!parts.length || !existsSync(file)) return false
let o = JSON.parse(readFileSync(file, 'utf8'))
for (const p of parts) { if (!o || typeof o !== 'object' || !(p in o)) return false; o = o[p] }
return typeof o === 'string'
}
const has = (key) => {
const parts = String(key).split('.')
if (parts.shift() !== 'posterDesign') return false
let o = en
for (const p of parts) { if (!o || typeof o !== 'object' || !(p in o)) return false; o = o[p] }
return typeof o === 'string'
}
const keys = [...configs.flatMap((c) => [c.labelKey, c.missing].filter(Boolean)), ...Object.values(cat.FAMILY_KEYS)]
for (const k of keys) if (!has(k)) fail(`catalogue key '${k}' is not in en/posterDesign.json`)
for (const c of configs) if (!cat.FAMILY_KEYS[c.family]) fail(`'${c.name}' has family '${c.family}', which has no heading key`)
// 5. the samples
const data = JSON.parse(readFileSync(join(ROOT, 'library/data/country-posters.json'), 'utf8'))
const samples = [...new Set([cat.SAMPLE_COUNTRY_ID, ...configs.map((c) => c.sample).filter(Boolean)])]
// A UPG100 zone sample (a template with `plugin: 'my-upg-100-list'`) is one of the zones in the library copy's catalogue.
const ZONE_IDS = (() => { try { const z = JSON.parse(readFileSync(join(ROOT, 'library/packet/upg100Zones/clusters.json'), 'utf8')); return new Set(['lists', 'asia_lists', 'uncovered_lists'].flatMap((k) => (z[k] || []).map((x) => x.id))) } catch { return new Set() } })()
function sampleArm(ids, countries, withheld) {
const fs = []
for (const id of ids) {
if (ZONE_IDS.has(id)) continue // a real UPG100 zone (drawn from the library copy)
const s = countries.find((c) => c.id === id)
if (!s) fs.push(`5: sample '${id}' is not in library/data/country-posters.json`)
else if (withheld(s)) continue // its card shows the withheld sentence; it is never drawn here
else if (!s.hasGeometry || !s.sheet || s.unsupportedReason) fs.push(`5: sample '${s.id}' cannot be drawn (hasGeometry ${s.hasGeometry}, sheet ${s.sheet})`)
}
return fs
}
sampleArm(samples, data.countries || [], sheetWithheld).forEach(fail)
// 6-8: plugin designs, unique names, the ledger. Gathered once, judged by one function, so --self-test poisons the
// same function the real run uses.
function gatherBlocks() {
const out = []
const base = join(ROOT, 'plugin-designs')
for (const plugin of readdirSync(base, { withFileTypes: true }).filter((d) => d.isDirectory()).map((d) => d.name)) {
for (const f of readdirSync(join(base, plugin)).filter((x) => x.endsWith('.meta.json'))) {
let meta
try { meta = JSON.parse(readFileSync(join(base, plugin, f), 'utf8')) } catch { continue }
if (meta.posterDesign === undefined) continue
const profile = f.replace(/\.meta\.json$/, '')
const inst = readdirSync(join(base, plugin)).find((x) => x.endsWith('.instances.json'))
const presets = inst ? ((JSON.parse(readFileSync(join(base, plugin, inst), 'utf8')).instances) || []).map((i) => i.id) : []
const blocks = PD.blocksOf(meta.posterDesign)
if (!blocks.length) out.push({ where: `${plugin}/${f}`, block: meta.posterDesign, presets })
for (const block of blocks) out.push({ where: `${plugin}/${profile}.meta.json`, block, presets })
}
}
return out
}
function designArms({ names, blocks, ledger, drawnFor = new Map() }) {
const fs = []
for (const { where, block, presets } of blocks) {
const probs = PD.blockProblems(block)
for (const p of probs) fs.push(`6: ${where} posterDesign ${p}`)
if (probs.length) continue
if (block.labelKey && !enKey(block.labelKey)) fs.push(`6: ${where} labelKey '${block.labelKey}' is not in the English catalogue`)
if (block.instance && !presets.includes(block.instance)) fs.push(`6: ${where} instance '${block.instance}' is not one of its presets`)
}
// A template that DRAWS a plugin's design (`plugin: '<dir>'`) carries that plugin's own name, so the same name in that
// plugin's block is the same design, counted once. Anywhere else a repeated name is a clash.
const seen = new Set(names)
for (const n of names.filter((x, i) => names.indexOf(x) !== i)) fs.push(`7: two templates are named '${n}'`)
for (const b of blocks.filter((x) => !PD.blockProblems(x.block).length)) {
const n = b.block.name, plugin = String(b.where).split('/')[0]
if (seen.has(n) && drawnFor.get(n) !== plugin) fs.push(`7: two designs are named '${n}'`)
seen.add(n)
}
for (const n of ledger) if (!seen.has(n)) fs.push(`8: '${n}' is in the names ledger and no longer exists: a shipped name is never renamed or removed (keep it, as a legacy entry if retired)`)
return { fails: fs, fresh: [...seen].filter((n) => !ledger.includes(n)) }
}
const LEDGER_FILE = join(ROOT, 'plugin-designs/poster-design/src/catalogue-names.json')
const ledger = existsSync(LEDGER_FILE) ? JSON.parse(readFileSync(LEDGER_FILE, 'utf8')).names || [] : []
if (!ledger.length) fail('8: the names ledger (src/catalogue-names.json) is missing or empty')
const blocks = gatherBlocks()
const real = { names: configs.map((c) => c.name), blocks, ledger, drawnFor: new Map(configs.filter((c) => c.plugin).map((c) => [c.name, c.plugin])) }
if (process.argv.includes('--self-test')) {
const example = { family: 'regionPoster', name: 'region-poster.66x66', size: '66x66', mobileFriendly: true }
const B = (block, presets = []) => ({ where: 'fixture/fixture.meta.json', block, presets })
const cases = [
['an invalid block (mobileFriendly missing)', { ...real, blocks: [...blocks, B({ family: 'regionPoster', name: 'region-poster.x', size: '66x66' })] }, /^6: .*mobileFriendly/],
['a name clash with templates.js', { ...real, blocks: [...blocks, B({ ...example, name: real.names[0] })] }, /^7: /],
['a renamed ledger name', { ...real, names: real.names.map((n, i) => (i === 0 ? `${n}-renamed` : n)) }, /^8: /],
['another plugin reusing a name a template draws for UPG100', { ...real, blocks: [...blocks, B({ ...example, name: 'upg100-territory-poster.3x3' })] }, /^7: /],
['a block naming a preset that does not exist', { ...real, blocks: [...blocks, B({ ...example, instance: 'nowhere' }, ['africa'])] }, /^6: .*instance/],
]
let ok = true
for (const [name, input, rx] of cases) {
const { fails } = designArms(input); const hit = fails.find((f) => rx.test(f))
console.log(hit ? ` ✓ caught: ${name} -> ${hit}` : ` ✗ MISSED: ${name}`)
if (!hit) ok = false
}
// a sample that exists but cannot be drawn: India, once nothing withholds it
const und = sampleArm(samples, data.countries || [], () => false).find((f) => /cannot be drawn/.test(f))
console.log(und ? ` ✓ caught: a sample that exists but cannot be drawn (withholding off) -> ${und}` : ' ✗ MISSED: a sample that exists but cannot be drawn')
if (!und) ok = false
const good = designArms({ ...real, blocks: [...blocks, B(example)] })
console.log(good.fails.length ? ` ✗ the documented example block FAILS: ${good.fails[0]}` : ' ✓ the documented example block passes')
if (good.fails.length) ok = false
console.log(ok ? 'POSTER-DESIGN-CATALOGUE SELF-TEST: PASS' : 'POSTER-DESIGN-CATALOGUE SELF-TEST: FAIL')
process.exit(ok ? 0 : 1)
}
const judged = designArms(real)
judged.fails.forEach(fail)
if (failures.length) {
console.error(`POSTER-DESIGN-CATALOGUE: FAIL (${failures.length})\n - ${failures.join('\n - ')}`)
process.exitCode = 1
} else {
console.log(`POSTER-DESIGN-CATALOGUE: PASS — ${configs.length} configurations, ${Object.keys(cat.SHEET_FORMATS).length} sheet formats matching poster-render FORMATS, ${want.length} card stocks from STOCK, ${keys.length} catalogue keys present, samples ${samples.join(', ')} drawable or withheld; ${blocks.length} plugin design block(s) valid; ${ledger.length} ledger names all present${judged.fresh.length ? `; NEW (add to src/catalogue-names.json): ${judged.fresh.join(', ')}` : ''}`)
}