doxa-maps/internal/check-element-namespace.mjs
Doxa Maps c7c2c12cd0 Maps register under their own tags: <dxm-simple-map> and <dxm-research-map>
A host page can define a tag name before our bundle loads, and customElements.define is first come, first
served. DX Life's own scripts define <doxa-map>, <doxa-simple-map> and <doxa-research-map>, and our bundles
defined the same names behind a silent guard, so on those pages our define was skipped and the page drew the
host's element under our name (Prayer Progress on the Adopt page, and the Research page).
- The doxa-simple-map bundle defines <dxm-simple-map>; doxa-research-map defines <dxm-research-map>. Each is
  its bundle's first define, so the manifest, the leaf pages and the gallery mount them with no other change.
- The older names are defined only when the host asks: data-legacy-tags on the bundle's <script>, or
  window.DXM_LEGACY_TAGS = true before it loads. Off by default.
- library/elements/ownTags.js: a class is branded with its bundle; a re-load is silent; a tag held by a
  foreign class is reported (console.error), never adopted. whenTagDefined refuses a dxm- tag held by a
  foreign class rather than mounting it.
- research-map-plus-grid mounts <dxm-research-map>; the gallery and studio count the new tags as heavy
  (WebGL) elements; the studio's and the composite's composes lists name them.
- check:element-namespace (gates:pre; self-test in gates:post): no host name by default, each bundle's own
  tag first, the manifest mounts them, one bundle per tag (people-groups-grid is the one known exception),
  ownTags and whenTagDefined run against a stub registry.
2026-10-02 03:19:53 +00:00

188 lines
13 KiB
JavaScript

#!/usr/bin/env node
/**
* check-element-namespace.mjs — DX Maps never takes a host's tag name by default, and never adopts a foreign one.
*
* THE DEFECT. customElements.define is first come, first served. DX Life's own /js scripts define <doxa-map>,
* <doxa-simple-map> and <doxa-research-map>, and the doxa-simple-map / doxa-research-map bundles defined the
* same names behind a silent guard (`if (!customElements.get(tag)) define(...)`). On a DX Life page the host's
* script won, our define was skipped without a word, and the gallery drew the host's element under our name.
*
* WHAT IT ASSERTS:
* A. no bundle entry (plugin-designs/<b>/index.js) defines a HOST name by default: a host name is defined only
* inside an `if (LEGACY_TAGS) { … }` block, and LEGACY_TAGS comes from legacyTagsRequested();
* B. each of the two bundles' FIRST literal define is its namespaced tag (generate-tree reads it as the
* profile's element);
* C. the built manifest names those tags as the profiles' elements, and no profile element is a host name;
* D. a tag is defined by ONE bundle (KNOWN_SHARED is the one recorded exception, carded; it may only shrink);
* E. library/elements/ownTags.js, run against a stub registry: a free tag is free; the same bundle's re-load is
* silent; a FOREIGN class is reported and never adopted; the opt-in reads the attribute and the flag;
* F. library/utils/embedScript.js whenTagDefined REJECTS a dxm- tag held by a foreign class.
* `--self-test` plants each defect in memory (no file is written) and exits 0 only if every one is caught.
*/
import { readFileSync, readdirSync, existsSync } from 'node:fs'
import { join, dirname } from 'node:path'
import { fileURLToPath } from 'node:url'
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..')
const HOST_TAGS = ['doxa-map', 'doxa-simple-map', 'doxa-research-map']
const OWN = { 'doxa-simple-map': 'dxm-simple-map', 'doxa-research-map': 'dxm-research-map' }
// Defined by two bundles as two classes; whichever loads first serves both. Carded (studio backlog): big-picture's
// embedded grid gets its own tag. It may only leave this list.
const KNOWN_SHARED = { 'people-groups-grid': ['big-picture', 'people-groups-grid'] }
const DEFINE_RE = /customElements\.define\(\s*['"]([^'"]+)['"]/g
const read = (p) => readFileSync(join(ROOT, p), 'utf8')
const entries = () => readdirSync(join(ROOT, 'plugin-designs'), { withFileTypes: true })
.filter((d) => d.isDirectory() && existsSync(join(ROOT, 'plugin-designs', d.name, 'index.js'))).map((d) => d.name)
// The body of every `if (LEGACY_TAGS) { … }` block, by brace matching.
function legacyBlocks(src) {
const out = []
for (const m of src.matchAll(/if\s*\(\s*LEGACY_TAGS\s*\)\s*\{/g)) {
let depth = 1, i = m.index + m[0].length
for (; i < src.length && depth; i++) { if (src[i] === '{') depth++; else if (src[i] === '}') depth--; }
out.push([m.index, i])
}
return out
}
function checkEntries(srcOf) {
const fails = [], definedBy = {}
for (const b of Object.keys(srcOf)) {
const src = srcOf[b].replace(/\/\*[\s\S]*?\*\//g, (c) => ' '.repeat(c.length)).replace(/\/\/[^\n]*/g, (c) => ' '.repeat(c.length))
const literal = [...src.matchAll(DEFINE_RE)].map((m) => m[1])
for (const t of new Set(literal)) (definedBy[t] ||= []).push(b)
const blocks = legacyBlocks(src)
const inBlock = (i) => blocks.some(([a, z]) => i > a && i < z)
// A: a host name, as a literal define or as a string anywhere near a define, only inside the opt-in block.
for (const m of src.matchAll(/customElements\.define\(/g)) {
if (inBlock(m.index)) continue
const arg = src.slice(m.index + m[0].length).match(/^\s*(['"])([^'"]+)\1/)
if (!arg) fails.push(`A ${b}/index.js: a define whose tag is not a literal sits OUTSIDE \`if (LEGACY_TAGS)\` (only the opt-in block may define a computed name)`)
else if (HOST_TAGS.includes(arg[2])) fails.push(`A ${b}/index.js defines the host name <${arg[2]}> by default (only inside \`if (LEGACY_TAGS)\`)`)
}
if (blocks.length && !/const\s+LEGACY_TAGS\s*=\s*legacyTagsRequested\(\)/.test(src)) fails.push(`A ${b}/index.js: LEGACY_TAGS is not read from legacyTagsRequested() at evaluation time`)
// B
if (OWN[b] && literal[0] !== OWN[b]) fails.push(`B ${b}/index.js: the first define is <${literal[0]}>, not its own <${OWN[b]}>`)
}
// D
for (const [t, bs] of Object.entries(definedBy)) {
if (bs.length < 2) continue
const known = KNOWN_SHARED[t]
if (!known || bs.slice().sort().join() !== known.slice().sort().join()) fails.push(`D <${t}> is defined by ${bs.length} bundles: ${bs.join(', ')} (whichever loads first serves all of them)`)
}
for (const t of Object.keys(KNOWN_SHARED)) if (!(definedBy[t] || []).length || definedBy[t].length < 2) fails.push(`D KNOWN_SHARED <${t}> is no longer shared: remove it from the list`)
return fails
}
function checkManifest(manifest) {
const fails = []
// Every `element` (profiles and instances) and every bundle's `elements`, each with the bundle it belongs to.
const uses = []
const walk = (o, bundle) => {
if (Array.isArray(o)) return o.forEach((x) => walk(x, bundle))
if (!o || typeof o !== 'object') return
const b = typeof o.bundle === 'string' ? o.bundle : bundle
if (typeof o.element === 'string') uses.push({ bundle: b, element: o.element, at: o.id || o.profile || b })
if (Array.isArray(o.elements)) for (const e of o.elements) uses.push({ bundle: b, element: e, at: b + ' elements' })
for (const v of Object.values(o)) walk(v, b)
}
walk(manifest, null)
for (const u of uses) if (HOST_TAGS.includes(u.element)) fails.push(`C manifest: ${u.at} mounts the host name <${u.element}>`)
for (const [b, tag] of Object.entries(OWN)) {
const mine = uses.filter((u) => u.bundle === b)
if (!mine.length) fails.push(`C manifest: no entry for bundle ${b}`)
else if (mine.some((u) => u.element !== tag)) fails.push(`C manifest: bundle ${b} mounts <${[...new Set(mine.map((u) => u.element))].join('>, <')}>, not <${tag}>`)
}
return fails
}
// A stub registry: enough of customElements for ownTags.js and whenTagDefined.
function stubRegistry() {
const map = new Map()
return { get: (t) => map.get(t), define: (t, c) => { if (map.has(t)) throw new Error('NotSupportedError: ' + t); map.set(t, c) }, whenDefined: () => new Promise(() => {}) }
}
const asModule = (src) => import('data:text/javascript;base64,' + Buffer.from(src).toString('base64'))
async function checkRuntime(ownSrc, embedSrc) {
const fails = []
const errors = []
const saved = { ce: globalThis.customElements, err: console.error, win: globalThis.window, doc: globalThis.document }
try {
globalThis.customElements = stubRegistry()
console.error = (...a) => errors.push(a.join(' '))
const o = await asModule(ownSrc)
class Foreign {}
class Ours {}
o.brand(Ours, 'doxa-simple-map')
if (o.ownTagFree('dxm-simple-map', 'doxa-simple-map') !== true) fails.push('E ownTagFree: a free tag is not free')
customElements.define('dxm-simple-map', Ours)
const before = errors.length
if (o.ownTagFree('dxm-simple-map', 'doxa-simple-map') !== false || errors.length !== before) fails.push('E ownTagFree: a re-load of the same bundle is not silent')
customElements.define('doxa-map', Foreign)
if (o.ownTagFree('doxa-map', 'doxa-simple-map') !== false) fails.push('E ownTagFree: a FOREIGN class is adopted (the tag reads as free)')
if (errors.length !== before + 1) fails.push('E ownTagFree: a foreign class is not reported')
globalThis.window = {}; globalThis.document = { currentScript: { hasAttribute: (a) => a === 'data-legacy-tags' } }
if (o.legacyTagsRequested() !== true) fails.push('E legacyTagsRequested: data-legacy-tags on the <script> is not read')
globalThis.document = { currentScript: { hasAttribute: () => false } }
if (o.legacyTagsRequested() !== false) fails.push('E legacyTagsRequested: on with no attribute and no flag (the default must be OFF)')
globalThis.window = { DXM_LEGACY_TAGS: true }
if (o.legacyTagsRequested() !== true) fails.push('E legacyTagsRequested: window.DXM_LEGACY_TAGS is not read')
// F
const e = await asModule(embedSrc.replace(/^import[^\n]*\n/gm, ''))
customElements.define('dxm-research-map', Foreign)
const r = await e.whenTagDefined('dxm-research-map', 50).then(() => 'resolved', () => 'rejected')
if (r !== 'rejected') fails.push('F whenTagDefined: a dxm- tag held by a FOREIGN class resolves (it would be mounted)')
const r2 = await e.whenTagDefined('dxm-simple-map', 50).then(() => 'resolved', () => 'rejected')
if (r2 !== 'resolved') fails.push('F whenTagDefined: our own branded dxm- tag is refused')
} catch (err) {
fails.push('E/F could not run: ' + (err && err.message))
} finally {
globalThis.customElements = saved.ce; console.error = saved.err; globalThis.window = saved.win; globalThis.document = saved.doc
}
return fails
}
async function run({ srcOf, manifest, ownSrc, embedSrc }) {
return [...checkEntries(srcOf), ...(manifest ? checkManifest(manifest) : ['C NOT MEASURED: public/dx-maps/manifest.json is missing']), ...(await checkRuntime(ownSrc, embedSrc))]
}
const live = () => {
const srcOf = Object.fromEntries(entries().map((b) => [b, read(`plugin-designs/${b}/index.js`)]))
const mp = join(ROOT, 'public/dx-maps/manifest.json')
return { srcOf, manifest: existsSync(mp) ? JSON.parse(readFileSync(mp, 'utf8')) : null, ownSrc: read('library/elements/ownTags.js'), embedSrc: read('library/utils/embedScript.js') }
}
if (process.argv.includes('--self-test')) {
const base = live()
const sm = base.srcOf['doxa-simple-map']
const own = 'if (ownTagFree(\'dxm-simple-map\', BUNDLE)) customElements.define(\'dxm-simple-map\', DxmSimpleMapElement)'
const poisons = [
['a default-on <doxa-map> define', (s) => { s.srcOf = { ...s.srcOf, 'doxa-simple-map': sm.replace(own, own + "\ncustomElements.define('doxa-map', DxmSimpleMapElement)") } }, /^A .*host name <doxa-map>/],
['the legacy loop moved out of the opt-in block', (s) => { s.srcOf = { ...s.srcOf, 'doxa-simple-map': sm.replace('if (LEGACY_TAGS) {', 'if (true) {') } }, /^A .*not a literal sits OUTSIDE/],
['the own tag no longer first', (s) => { s.srcOf = { ...s.srcOf, 'doxa-simple-map': sm.replace(own, "customElements.define('dxm-other', X)\n" + own) } }, /^B /],
['a manifest that mounts <doxa-map>', (s) => { s.manifest = JSON.parse(JSON.stringify(s.manifest).replace(/"dxm-simple-map"/g, '"doxa-map"')) }, /^C /],
['a second bundle defining <dxm-research-map>', (s) => { s.srcOf = { ...s.srcOf, 'stack-demo': (s.srcOf['stack-demo'] || '') + "\ncustomElements.define('dxm-research-map', X)" } }, /^D <dxm-research-map>/],
['ownTagFree adopting a foreign class', (s) => { s.ownSrc = s.ownSrc.replace("if (held.__dxmBundle === bundle) return false", "return true") }, /^E ownTagFree: a FOREIGN class is adopted/],
['legacy names on by default', (s) => { s.ownSrc = s.ownSrc.replace("return Boolean(s && typeof s.hasAttribute === 'function' && s.hasAttribute('data-legacy-tags'))", 'return true') }, /^E legacyTagsRequested: on with no attribute/],
['whenTagDefined without the foreign check', (s) => { s.embedSrc = s.embedSrc.replace("if (!/^dxm-/.test(String(tag))) return false", 'return false') }, /^F whenTagDefined: a dxm- tag held by a FOREIGN/],
]
let missed = 0
for (const [name, plant, expect] of poisons) {
const s = { ...base }
plant(s)
const changed = s.srcOf !== base.srcOf || s.manifest !== base.manifest || s.ownSrc !== base.ownSrc || s.embedSrc !== base.embedSrc
const same = JSON.stringify([s.srcOf, s.ownSrc, s.embedSrc, s.manifest]) === JSON.stringify([base.srcOf, base.ownSrc, base.embedSrc, base.manifest])
if (!changed || same) { console.log(`NOT MEASURED: the poison anchor for "${name}" moved`); process.exitCode = 75; continue }
const f = await run(s)
const hit = f.find((x) => expect.test(x))
console.log(hit ? `✓ caught: ${name} -> ${hit}` : `✗ MISSED: ${name}${f.length ? ' (other: ' + f[0] + ')' : ''}`)
if (!hit) missed++
}
if (missed) process.exitCode = 1
console.log(missed ? `✗ check:element-namespace self-test: ${missed} poison(s) missed` : process.exitCode === 75 ? 'NOT MEASURED' : '✓ check:element-namespace self-test: every poison caught')
} else {
const f = await run(live())
if (f.length) { for (const x of f) console.log('✗ ' + x); console.log(`FAIL check:element-namespace (${f.length})`); process.exitCode = f.some((x) => /NOT MEASURED/.test(x)) && f.length === 1 ? 75 : 1 }
else console.log(`PASS check:element-namespace — no host tag by default (${HOST_TAGS.join(', ')}); dxm-simple-map + dxm-research-map own their bundles and the manifest; foreign definitions are reported, never adopted`)
}