doxa-maps/plugin-designs/poster-sections/scripts/check-poster-sections.mjs
Doxa Maps ed0a1a57b1 Poster Sections: country presets only until another poster type can load on the page
A preset page loads only poster-sections.js, so a preset of a poster type
registered by another plugin's script (upg100-list) could only say No
posters. The generator now gives no presets to a type whose file is not on
the tree, and check:poster-sections fails on any such preset: 9 presets
remain (7 regions, all regions, configure).

The tree generator writes a page per preset but never deletes one, so the
dropped presets' pages (the 10 upg100 ones and countries-other) are removed,
and a new arm holds the built preset pages to exactly the presets.
2026-10-02 10:56:43 +00:00

133 lines
9.4 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:poster-sections — the Poster Sections panel's choices reach the element unchanged.
// For every choice the panel offers (type, one region or All regions, each language, collapsed or open, 1-5 per
// row), the tags src/embed.js builds, read back through the ELEMENT's own attribute reader (posterSectionConfig),
// give exactly those choices; "All regions" is one section per region, each once; the snippet's script and tags
// are the same tags the page renders.
// PRESETS: poster-sections.instances.json has, per poster type, exactly "<prefix>-all" + one preset per region its
// adapter's scopes() returns, no more, no fewer. So a region the data no longer has (a stale "other") or a new one
// cannot drift from the presets. A type whose file is NOT on the tree must have no presets at all (its pages would
// only say "No posters"): 'upg100-list' is listed once its type file is on the tree.
// PAGES: the built preset pages (public/<tree>/poster-sections/poster-sections/<id>/) are exactly the presets: the tree
// generator writes a page per preset but never deletes one, so a dropped preset would leave a stale page behind.
// --self-test: a builder that drops `columns`, one that drops `collapsed`, one that repeats a region under All, and a
// presets file with a region the type does not have must each FAIL.
import { readFileSync, readdirSync, statSync, existsSync } from 'node:fs'
import { join, dirname } from 'node:path'
import { fileURLToPath } from 'node:url'
// the site's languages = the catalogue's locale folders (library/i18n/index.js needs Vite's import.meta.glob)
const LOC = join(dirname(fileURLToPath(import.meta.url)), '../../../library/i18n/locales')
const SUPPORTED_LOCALES = readdirSync(LOC).filter((d) => statSync(join(LOC, d)).isDirectory()).sort()
import { posterSectionConfig } from '../../../library/elements/posterSectionConfig.js'
import * as real from '../src/embed.js'
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '../../..')
const PRESETS = JSON.parse(readFileSync(join(ROOT, 'plugin-designs/poster-sections/poster-sections.instances.json'), 'utf8'))
// poster type → [preset id prefix, the file that defines the type's adapter (its default export has scopes())]
const TYPES = { country: ['countries', 'library/components/poster/types/countryPosterType.js'], 'upg100-list': ['upg100', 'plugin-designs/my-upg-100-list/data/upg100-poster-type.js'] }
// Each type's scopes(), loaded through Vite (the adapters import through the @map alias).
async function loadScopes() {
const { createServer } = await import('vite')
const server = await createServer({ root: ROOT, configFile: false, logLevel: 'error', appType: 'custom', server: { middlewareMode: true, hmr: false, ws: false }, resolve: { alias: { '@map': join(ROOT, 'library') } } })
const out = {}
try {
for (const [type, [, file]] of Object.entries(TYPES)) {
if (!existsSync(join(ROOT, file))) { out[type] = null; continue }
const a = (await server.ssrLoadModule(join(ROOT, file))).default
out[type] = a.scopes()
}
} finally { await server.close() }
return out
}
function presetArm(presets, scopesByType) {
const fails = []
for (const [type, [prefix]] of Object.entries(TYPES)) {
const scopes = scopesByType[type]
const ids = presets.instances.filter((i) => i.props && i.props.type === type).map((i) => i.id)
if (scopes == null) { // the type's file is not on this tree: a preset of it could only say "No posters"
if (ids.length) fails.push(`presets for "${type}" exist (${ids.join(', ')}) but its type is not on this tree, so their pages would show no posters`)
continue
}
const want = [`${prefix}-all`, ...scopes.map((s) => `${prefix}-${s}`)].sort()
const have = [...ids].sort()
const extra = have.filter((x) => !want.includes(x)), missing = want.filter((x) => !have.includes(x))
if (extra.length || missing.length) fails.push(`presets for "${type}" differ from its scopes(): extra ${extra.join(', ') || '-'}; missing ${missing.join(', ') || '-'}`)
for (const i of presets.instances.filter((x) => x.props && x.props.type === type && x.id !== `${prefix}-all`)) {
if (i.id !== `${prefix}-${i.props.scope}`) fails.push(`preset "${i.id}" shows scope "${i.props.scope}"`)
}
}
if (!presets.instances.some((i) => i.id === 'configure' && i.props && i.props.configure === true)) fails.push('no "configure" preset')
return fails
}
// The built preset pages, when a tree is built: every folder under poster-sections/poster-sections/ that holds a page.
function builtPages() {
const pub = join(ROOT, 'public')
if (!existsSync(pub)) return null
for (const tree of readdirSync(pub)) {
const dir = join(pub, tree, 'poster-sections', 'poster-sections')
if (existsSync(dir)) return readdirSync(dir).filter((d) => existsSync(join(dir, d, 'index.html')))
}
return null
}
function pagesArm(presets, pages) {
if (pages == null) return []
const ids = presets.instances.map((i) => i.id)
const stale = pages.filter((p) => !ids.includes(p)), missing = ids.filter((i) => !pages.includes(i))
return stale.length || missing.length ? [`built preset pages differ from the presets: stale ${stale.join(', ') || '-'}; missing ${missing.join(', ') || '-'} (delete a dropped preset's page; rebuild the tree for a new one)`] : []
}
const SCOPES = ['africa', 'asia', 'europe', 'latin-america-and-caribbean', 'middle-east', 'north-america-and-non-spanish-caribbean', 'oceania', 'other']
const fake = (attrs) => ({ getAttribute: (n) => (n in attrs ? attrs[n] : null), hasAttribute: (n) => n in attrs })
function arms(embed) {
const fails = []
for (const scope of ['', ...SCOPES]) for (const locale of SUPPORTED_LOCALES) for (const collapsed of [false, true]) for (const columns of [1, 2, 3, 4, 5]) {
const choices = { type: 'country', scope, scopes: SCOPES, locale, collapsed, columns, assetsBase: 'https://example.org/dx-maps/assets' }
const tags = embed.sectionAttrs(choices)
const want = scope ? [scope] : SCOPES
if (tags.map((a) => a.scope).join() !== want.join()) { fails.push(`scope "${scope || 'All'}": sections ${tags.map((a) => a.scope).join()}`); continue }
for (const a of tags) {
const c = posterSectionConfig(fake(a))
const bad = [c.type !== 'country' && 'type', c.locale !== locale && 'locale', c.collapsed !== collapsed && 'collapsed',
c.columns !== columns && 'columns', c.assetsBase !== choices.assetsBase && 'assets-base', c.collapsible !== true && 'collapsible'].filter(Boolean)
if (bad.length) { fails.push(`${a.scope}/${locale}/${collapsed ? 'shut' : 'open'}/${columns}: the element reads back a different ${bad.join(', ')}`); break }
}
if (fails.length > 3) return fails
}
const snip = embed.embedSnippet({ type: 'country', scope: '', scopes: SCOPES, locale: 'ar', collapsed: true, columns: 3 }, 'https://example.org/dx-maps/poster-sections/poster-sections.js')
const lines = snip.split('\n')
if (lines[0] !== '<script src="https://example.org/dx-maps/poster-sections/poster-sections.js"></script>') fails.push(`snippet: first line is not the bundle script: ${lines[0]}`)
if (lines.length !== SCOPES.length + 1 || !lines.slice(1).every((l, i) => l === `<dxm-poster-section type="country" scope="${SCOPES[i]}" locale="ar" collapsed columns="3"></dxm-poster-section>`)) fails.push(`snippet: the tags are not one per region with the chosen attributes:\n${snip}`)
return fails
}
const SCOPES_BY_TYPE = await loadScopes()
if (process.argv.includes('--self-test')) {
const mutants = {
'drops columns': { ...real, sectionAttrs: (c) => real.sectionAttrs(c).map(({ columns, ...a }) => a) },
'drops collapsed': { ...real, sectionAttrs: (c) => real.sectionAttrs(c).map(({ collapsed, ...a }) => a) },
'repeats a region under All': { ...real, sectionAttrs: (c) => { const r = real.sectionAttrs(c); return c.scope ? r : [...r, r[0]] } },
}
let ok = true
const stale = { ...PRESETS, instances: [...PRESETS.instances, { id: 'countries-atlantis', props: { type: 'country', scope: 'atlantis' } }] }
const pg = pagesArm(PRESETS, [...PRESETS.instances.map((i) => i.id), 'countries-atlantis'])
console.log(` mutant "a built page for a preset that no longer exists": ${pg.length ? 'FAILS (good)' : 'PASSES (bad)'}`)
if (!pg.length) ok = false
const pf = presetArm(stale, SCOPES_BY_TYPE)
console.log(` mutant "a preset for a region the type does not have": ${pf.length ? 'FAILS (good)' : 'PASSES (bad)'}`)
if (!pf.length) ok = false
for (const [name, m] of Object.entries(mutants)) {
const f = arms(m)
console.log(` mutant "${name}": ${f.length ? 'FAILS (good)' : 'PASSES (bad)'}`)
if (!f.length) ok = false
}
console.log(ok ? 'POSTER-SECTIONS SELF-TEST: PASS — every mutant fails the check.' : 'POSTER-SECTIONS SELF-TEST: FAIL')
process.exit(ok ? 0 : 1)
}
const fails = [...arms(real), ...presetArm(PRESETS, SCOPES_BY_TYPE), ...pagesArm(PRESETS, builtPages())]
const measured = Object.entries(SCOPES_BY_TYPE).filter(([, v]) => v).map(([k, v]) => `${k} ${v.length}`).join(', ')
if (fails.length) { console.log(`POSTER-SECTIONS: FAIL (${fails.length})`); fails.forEach((f) => console.log(` - ${f}`)); process.exit(1) }
console.log(`POSTER-SECTIONS: PASS — every panel choice (${SCOPES.length + 1} region choices × ${SUPPORTED_LOCALES.length} languages × open/collapsed × 5 per row) reads back through the element unchanged; All regions = one section per region; the snippet is the script + those tags; presets = each type's scopes() + all (${measured}).`)