From 21e2200029bb50360e1cd9dc8b63d4800bf8603b Mon Sep 17 00:00:00 2001 From: Doxa Maps Date: Fri, 2 Oct 2026 08:29:16 +0000 Subject: [PATCH 1/3] Poster sections: one type registry per page; the element takes a columns attribute - ONE REGISTRY PER PAGE. Every bundle inlines its own copy of posterTypes.js, and is defined by whichever bundle loads first, so with a module-level Map that element saw only its own bundle's types: type="upg100-list" rendered "none" when the country-posters bundle had defined it. The registry now lives on the page (globalThis.__DXM_POSTER_TYPES__, created once): every copy registers into it and reads from it; a name registered twice keeps the first (a dev warning; the built-in country is silent); every registration is announced on window (dxm-poster-type-registered), and PosterSectionView looks again when its type arrives after it asked. - COLUMNS (carried from the Poster Sections plugin PR so the element's library lands once): columns="1".."5" maps to profile-config.columns and to PosterWall's existing columns prop; absent or out of range is today's default, and a reader's saved per-row choice still wins. - check:poster-type-registry (gates:pre; self-test in gates:post): two independent copies of the module side by side, as two bundles on one page; a type registered through one is found through the other; first wins; announced. The self-test's per-bundle registry fails. --- internal/check-poster-type-registry.mjs | 81 +++++++++++++++++++ .../components/poster/PosterSectionView.vue | 19 +++-- library/components/poster/posterTypes.js | 58 ++++++++++--- library/elements/posterSectionConfig.js | 5 +- library/elements/posterSectionElement.js | 3 +- package.json | 7 +- 6 files changed, 152 insertions(+), 21 deletions(-) create mode 100644 internal/check-poster-type-registry.mjs diff --git a/internal/check-poster-type-registry.mjs b/internal/check-poster-type-registry.mjs new file mode 100644 index 000000000..fde8823eb --- /dev/null +++ b/internal/check-poster-type-registry.mjs @@ -0,0 +1,81 @@ +#!/usr/bin/env node +/** + * check-poster-type-registry.mjs — the poster type registry is ONE per page, whichever bundle's copy asks. + * + * Every bundle inlines its own copy of library/components/poster/posterTypes.js, and is + * defined by whichever bundle loads first. A module-level registry made that element see only its own bundle's + * types: a type another bundle registered rendered "none". So the registry is page-global (globalThis). + * + * ASSERTS, with TWO independent copies of the module loaded side by side (as two bundles are on one page): + * A. a type registered through copy B is found through copy A (the copy that defined the element); + * B. the built-in 'country' is listed by both copies, once; + * C. a name registered twice keeps the FIRST registration (the second returns false); + * D. every registration is announced on window ('dxm-poster-type-registered', { name }), so a section that asked + * before its type arrived can render when it does; + * E. PosterSectionView.vue re-resolves its type on that event. + * `--self-test`: the per-bundle (module-level Map) registry must FAIL arm A. + */ +import { readFileSync, writeFileSync, mkdtempSync, rmSync, mkdirSync } from 'node:fs' +import { join, dirname } from 'node:path' +import { tmpdir } from 'node:os' +import { fileURLToPath, pathToFileURL } from 'node:url' + +const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..') +const SRC = join(ROOT, 'library/components/poster/posterTypes.js') +const VIEW = join(ROOT, 'library/components/poster/PosterSectionView.vue') + +// A stand-in window that records the announcements. +const events = [] +globalThis.window = { dispatchEvent: (e) => { events.push(e); return true } } +globalThis.CustomEvent = class { constructor(type, init) { this.type = type; this.detail = init && init.detail } } + +async function copies(source) { + // Two copies = two module instances (as two IIFE bundles each inline one). Written to a scratch dir with a stub + // for the lazily imported country adapter, so nothing else loads. + const dir = mkdtempSync(join(tmpdir(), 'dxm-pt-')) + mkdirSync(join(dir, 'types')) + writeFileSync(join(dir, 'types', 'countryPosterType.js'), 'export default { items: () => [], title: () => "", actions: () => ({}) }\n') + const code = source.replace(/import\.meta\.env\s*&&\s*import\.meta\.env\.DEV/g, 'false') + writeFileSync(join(dir, 'a.mjs'), code); writeFileSync(join(dir, 'b.mjs'), code) + const a = await import(pathToFileURL(join(dir, 'a.mjs')).href) + const b = await import(pathToFileURL(join(dir, 'b.mjs')).href) + return { a, b, done: () => rmSync(dir, { recursive: true, force: true }) } +} + +async function audit(source, viewSrc) { + const fails = [] + for (const k of Object.getOwnPropertyNames(globalThis)) if (k === '__DXM_POSTER_TYPES__') delete globalThis[k] + events.length = 0 + const { a, b, done } = await copies(source) + try { + const fixture = { items: () => [{ id: 'x' }], title: () => 'X', actions: () => ({}) } + b.registerPosterType('gate-b-type', fixture) + if (!(await a.posterType('gate-b-type'))) fails.push('A: a type registered through one bundle\'s copy is NOT found through another\'s (a per-bundle registry)') + const na = a.posterTypeNames(), nb = b.posterTypeNames() + if (na.filter((n) => n === 'country').length !== 1 || nb.filter((n) => n === 'country').length !== 1) fails.push(`B: 'country' is not listed exactly once by each copy (${na.join(',')} | ${nb.join(',')})`) + const second = a.registerPosterType('gate-b-type', { items: () => [], title: () => 'Y', actions: () => ({}) }) + const kept = await b.posterType('gate-b-type') + if (second !== false || !kept || kept.title() !== 'X') fails.push('C: a name registered twice did not keep the FIRST registration') + if (!events.some((e) => e.type === 'dxm-poster-type-registered' && e.detail && e.detail.name === 'gate-b-type')) fails.push('D: a registration is not announced on window (dxm-poster-type-registered)') + } finally { done() } + if (!/dxm-poster-type-registered|TYPE_REGISTERED_EVENT/.test(viewSrc)) fails.push('E: PosterSectionView.vue does not re-resolve its type when one is registered later') + return fails +} + +if (process.argv.includes('--self-test')) { + // The per-bundle registry this gate exists to stop: a module-level Map. + const perBundle = `const LOADERS = new Map([['country', () => import('./types/countryPosterType.js')]]) +const CACHE = new Map() +export function registerPosterType(name, a) { LOADERS.set(name, typeof a === 'function' ? a : () => Promise.resolve({ default: a })); CACHE.delete(name); return true } +export function posterType(name) { if (!LOADERS.has(name)) return Promise.resolve(null); if (!CACHE.has(name)) CACHE.set(name, LOADERS.get(name)().then((m) => m.default || m)); return CACHE.get(name) } +export const posterTypeNames = () => [...LOADERS.keys()] +` + const f = await audit(perBundle, readFileSync(VIEW, 'utf8')) + const ok = f.some((x) => x.startsWith('A:')) + console.log(ok ? `✓ caught: a per-bundle registry -> ${f.find((x) => x.startsWith('A:'))}` : '✗ MISSED: the per-bundle registry passed arm A') + process.exitCode = ok ? 0 : 1 +} else { + const f = await audit(readFileSync(SRC, 'utf8'), readFileSync(VIEW, 'utf8')) + if (f.length) { for (const x of f) console.log('✗ ' + x); console.log(`FAIL check:poster-type-registry (${f.length})`); process.exitCode = 1 } + else console.log('PASS check:poster-type-registry — one registry per page: a type registered through one bundle\'s copy is found through another\'s; the first registration wins; registrations are announced and the section re-resolves') +} diff --git a/library/components/poster/PosterSectionView.vue b/library/components/poster/PosterSectionView.vue index 605dc54e6..50dccfd5a 100644 --- a/library/components/poster/PosterSectionView.vue +++ b/library/components/poster/PosterSectionView.vue @@ -1,13 +1,13 @@ - + - + - + - + \n\n\n", - "scriptMapbox": "\n\n\n\n\n\n" + "script": "\n\n\n\n\n\n", + "scriptMapbox": "\n\n\n\n\n\n" } }, { @@ -272,7 +272,7 @@ "scriptUrl": "./country-posters/country-posters.js", "embed": { "iframe": "", - "script": "\n\n\n" + "script": "\n\n\n" } }, { @@ -288,8 +288,8 @@ "scriptUrl": "./country-posters/country-posters.js", "embed": { "iframe": "", - "script": "\n\n\n\n\n\n", - "scriptMapbox": "\n\n\n\n\n\n" + "script": "\n\n\n\n\n\n", + "scriptMapbox": "\n\n\n\n\n\n" } }, { @@ -305,7 +305,7 @@ "scriptUrl": "./country-posters/country-posters.js", "embed": { "iframe": "", - "script": "\n\n\n" + "script": "\n\n\n" } } ] diff --git a/public/dx-maps/manifest.json b/public/dx-maps/manifest.json index b1a849f8b..838850577 100644 --- a/public/dx-maps/manifest.json +++ b/public/dx-maps/manifest.json @@ -232,7 +232,7 @@ "bundle": "country-posters", "scriptUrl": "./country-posters/country-posters.js", "output": "dx-maps/country-posters/country-posters.js", - "integrity": "sha384-1ACXp2YAr9pX3jDtRb/olI9Spx3SjPZx2colDVW7j1/G4cphSH/b+ubp+xAm5cHI", + "integrity": "sha384-nCX3ETy1ovIZEFYwSiuCoDdzKji7NHYsKEKJ2m2xPvdqztn7D28ECLC4LurgeHmG", "elements": [ "country-posters-map" ], @@ -255,8 +255,8 @@ "scriptUrl": "./country-posters/country-posters.js", "embed": { "iframe": "", - "script": "\n\n\n\n\n\n", - "scriptMapbox": "\n\n\n\n\n\n" + "script": "\n\n\n\n\n\n", + "scriptMapbox": "\n\n\n\n\n\n" } }, { @@ -272,7 +272,7 @@ "scriptUrl": "./country-posters/country-posters.js", "embed": { "iframe": "", - "script": "\n\n\n" + "script": "\n\n\n" } }, { @@ -288,8 +288,8 @@ "scriptUrl": "./country-posters/country-posters.js", "embed": { "iframe": "", - "script": "\n\n\n\n\n\n", - "scriptMapbox": "\n\n\n\n\n\n" + "script": "\n\n\n\n\n\n", + "scriptMapbox": "\n\n\n\n\n\n" } }, { @@ -305,7 +305,7 @@ "scriptUrl": "./country-posters/country-posters.js", "embed": { "iframe": "", - "script": "\n\n\n" + "script": "\n\n\n" } } ] From 0d09a72bf2b7d5ae22a0037e955b2da0272e5ee2 Mon Sep 17 00:00:00 2001 From: Doxa Maps Date: Fri, 2 Oct 2026 09:26:40 +0000 Subject: [PATCH 3/3] check:preview-flag-dev-only: the built tree may not even NAME the preview flag Tightened before the India tab lands. A built bundle that READS globalThis.DXM_PREVIEW_UNRELEASED would ship the withheld path itself, behind a global that any embedding page or console can set before the element loads, so withheld content would print from production. - A: no file under public/ names the flag at all (was: no assignment). - B: every source line that reads OR writes it carries import.meta.env.DEV (was: writes only), so each read compiles away in a build. - Self-test: a built setter, a built read, an unguarded source setter and an unguarded source read must each fail; the DEV-guarded forms and comments pass. --- internal/check-preview-flag-dev-only.mjs | 35 +++++++++++++----------- package.json | 2 +- 2 files changed, 20 insertions(+), 17 deletions(-) diff --git a/internal/check-preview-flag-dev-only.mjs b/internal/check-preview-flag-dev-only.mjs index 1363c9f5d..5e5b4dc84 100644 --- a/internal/check-preview-flag-dev-only.mjs +++ b/internal/check-preview-flag-dev-only.mjs @@ -8,18 +8,19 @@ * landing and to DX Life's /dx-maps, so one assignment there would print India publicly. * * ASSERTS: - * A. no file under public/ ASSIGNS the flag (any spelling a minifier writes: = true, =!0, =1, …); reading it - * (=== true) is fine; - * B. every SOURCE line that assigns it (library/, plugin-designs/, outside scripts/ and tests) is behind - * import.meta.env.DEV on the same line. - * `--self-test` plants each defect in memory and exits 0 only if both are caught. + * A. NO file under public/ MENTIONS the flag at all. An assignment there would turn the preview on, and a READ + * there means the withheld path itself shipped, behind a global any embedding page or console can set (that + * IS shipping it). Every read is compiled away by its DEV guard, so the built tree never names the flag; + * B. every SOURCE line that mentions it, read OR write (library/, plugin-designs/, outside scripts/ and tests; + * comments aside), carries import.meta.env.DEV on the same line. + * `--self-test` plants each defect in memory and exits 0 only if all are caught. */ import { readFileSync, readdirSync, statSync } from 'node:fs' import { join, dirname, relative } from 'node:path' import { fileURLToPath } from 'node:url' const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..') -const ASSIGN = /DXM_PREVIEW_UNRELEASED\s*=(?!=)/ +const MENTION = /DXM_PREVIEW_UNRELEASED/ const TEXT = /\.(m?js|html|json|css|txt)$/ function* walk(dir) { @@ -37,9 +38,9 @@ function* walk(dir) { function auditBuilt(files) { const fails = [] for (const [rel, text] of files) { - if (!ASSIGN.test(text)) continue - const line = text.split('\n').findIndex((l) => ASSIGN.test(l)) + 1 - fails.push(`A ${rel}:${line} ASSIGNS DXM_PREVIEW_UNRELEASED in the built tree (it would show withheld content publicly)`) + if (!MENTION.test(text)) continue + const line = text.split('\n').findIndex((l) => MENTION.test(l)) + 1 + fails.push(`A ${rel}:${line} names DXM_PREVIEW_UNRELEASED in the built tree (a setter would show withheld content; a read means the withheld path shipped)`) } return fails } @@ -47,7 +48,7 @@ function auditSource(files) { const fails = [] for (const [rel, text] of files) { text.split('\n').forEach((l, i) => { - if (ASSIGN.test(l) && !/import\.meta\.env\.DEV/.test(l) && !/^\s*(\/\/|\*)/.test(l)) fails.push(`B ${rel}:${i + 1} assigns DXM_PREVIEW_UNRELEASED outside an import.meta.env.DEV guard`) + if (MENTION.test(l) && !/import\.meta\.env\.DEV/.test(l) && !/^\s*(\/\/|\*|\/\*)/.test(l)) fails.push(`B ${rel}:${i + 1} reads or writes DXM_PREVIEW_UNRELEASED outside an import.meta.env.DEV guard`) }) } return fails @@ -59,14 +60,16 @@ const source = () => [...walk(join(ROOT, 'library')), ...walk(join(ROOT, 'plugin .map((p) => [relative(ROOT, p), readFileSync(p, 'utf8')]) if (process.argv.includes('--self-test')) { - const a = auditBuilt([['public/dx-maps/x/x.js', 'var a=1;globalThis.DXM_PREVIEW_UNRELEASED=!0;'], ['public/dx-maps/x/ok.js', 'if(globalThis.DXM_PREVIEW_UNRELEASED===true)f()']]) - const b = auditSource([['library/x.js', 'globalThis.DXM_PREVIEW_UNRELEASED = true'], ['library/ok.js', 'if (import.meta.env.DEV) globalThis.DXM_PREVIEW_UNRELEASED = true']]) - const okA = a.length === 1 && /x\/x\.js/.test(a[0]), okB = b.length === 1 && /library\/x\.js/.test(b[0]) - console.log(okA ? '✓ caught: a built bundle that assigns the flag (minified =!0); a read (===) is not flagged' : '✗ MISSED: the built-tree assignment') - console.log(okB ? '✓ caught: a source assignment outside import.meta.env.DEV; the guarded one passes' : '✗ MISSED: the unguarded source assignment') + const a = auditBuilt([['public/dx-maps/x/set.js', 'var a=1;globalThis.DXM_PREVIEW_UNRELEASED=!0;'], ['public/dx-maps/x/read.js', 'if(globalThis.DXM_PREVIEW_UNRELEASED===!0)showIndia()'], ['public/dx-maps/x/ok.js', 'showOthers()']]) + const b = auditSource([['library/set.js', 'globalThis.DXM_PREVIEW_UNRELEASED = true'], ['library/read.js', "export const previewUnreleased = () => typeof globalThis !== 'undefined' && globalThis.DXM_PREVIEW_UNRELEASED === true"], + ['library/ok.js', 'if (import.meta.env.DEV) globalThis.DXM_PREVIEW_UNRELEASED = true'], ['library/okread.js', 'export const p = () => import.meta.env.DEV && globalThis.DXM_PREVIEW_UNRELEASED === true'], ['library/c.js', '// DXM_PREVIEW_UNRELEASED is named in a comment']]) + const okA = a.length === 2 && a.some((x) => /set\.js/.test(x)) && a.some((x) => /read\.js/.test(x)) + const okB = b.length === 2 && b.some((x) => /library\/set\.js/.test(x)) && b.some((x) => /library\/read\.js/.test(x)) + console.log(okA ? '✓ caught: a built bundle that SETS the flag (=!0) and one that READS it (the withheld path shipped); a clean one passes' : '✗ MISSED: a built-tree setter or reader') + console.log(okB ? '✓ caught: an unguarded source SETTER and an unguarded source READ (the plain previewUnreleased()); the DEV-guarded ones and comments pass' : '✗ MISSED: an unguarded source setter or read') process.exitCode = okA && okB ? 0 : 1 } else { const fails = [...auditBuilt(built()), ...auditSource(source())] if (fails.length) { for (const f of fails) console.log('✗ ' + f); console.log(`FAIL check:preview-flag-dev-only (${fails.length})`); process.exitCode = 1 } - else console.log('PASS check:preview-flag-dev-only — no built file assigns DXM_PREVIEW_UNRELEASED; every source assignment is behind import.meta.env.DEV') + else console.log('PASS check:preview-flag-dev-only — no built file names DXM_PREVIEW_UNRELEASED; every source read and write is behind import.meta.env.DEV') } diff --git a/package.json b/package.json index 885d6e7ed..f8cf79c86 100644 --- a/package.json +++ b/package.json @@ -472,7 +472,7 @@ "check:coverage-consistent": "the gospel-resources surfaces must agree: no resource on an ISO RESERVED code (und/mul/mis/zxx), every resourced code NAMED, table reach recounted off the pins, no invented URL, tiers reconcile. Static, sub-second. Self-test: `bun run check:coverage-consistent:poison`, all 12 clauses (A-L) must fail. Clause L is the one that is not about consistency: it fails when the link-verification record goes stale, because 'link checked' is a claim about the present tense. Written after the same drift defect was found by hand three times.", "check:csv-shape": "the export must OPEN as a table: header on line 0, every row matching. Run the `check:csv-shape:self-test` script for the NEGATIVE test (forces the provenance comment back to line 0 IN MEMORY and requires this gate to REJECT it) — the header used to claim 'NEGATIVE-TESTED' with no way to re-run it.", "check:poster-sizes": "the shared poster-size ladder (library/constants/posterSizes.js) is right: picker order = ladder, dot-free keys, portrait only, the sheets glue up to the finished size, photo slots on exactly 66x66 + 60x96, paper prints and the rest go to a print-shop PDF, every alias resolves, paperFor() picks Letter or A4 by region. Run the `check:poster-sizes:self-test` script for the NEGATIVE test", - "check:preview-flag-dev-only": "the flag that previews withheld content (globalThis.DXM_PREVIEW_UNRELEASED, e.g. the whole-India poster) never reaches a built bundle: no file under public/ assigns it in any spelling a minifier writes, and every source assignment is behind import.meta.env.DEV (set only by the Vite dev server, the pilot's hot-reload preview). The built tree goes to the research landing and DX Life's /dx-maps, where the flag would show India publicly. Run the `check:preview-flag-dev-only:self-test` script for the NEGATIVE test (2 planted defects).", + "check:preview-flag-dev-only": "the flag that previews withheld content (globalThis.DXM_PREVIEW_UNRELEASED, e.g. the whole-India poster) never reaches a built bundle: NO file under public/ names it at all (a setter would turn the preview on; a read would mean the withheld path itself shipped), and every source line that reads OR writes it carries import.meta.env.DEV (set only by the Vite dev server, the pilot's hot-reload preview). The built tree goes to the research landing and DX Life's /dx-maps, where the flag would show India publicly. Run the `check:preview-flag-dev-only:self-test` script for the NEGATIVE test (a built setter, a built read, an unguarded source setter and an unguarded source read).", "check:pin-hint-floor": "the pin-hint pill sits at the map's bottom on a page that scrolls (the fold is not an obstacle there) and is still lifted above the fold on a full-screen page; the legend sheet and the licence credit still lift it; PinHint.vue takes its floor from pinHintFloor.js. Run the `check:pin-hint-floor:self-test` script for the NEGATIVE test", "check:no-fleet-names": "tracked source (not data/, public/ or the translation catalogues) names no build-team member, attributes no request to a user and carries no dated QA-round tag; an allowance is a named LINE, never a whole file. Run the `check:no-fleet-names:self-test` script for the NEGATIVE test", "check:dashboard-filter-a11y": "the dashboard's cross-filter is operable and audible: every filter chip is a