From 2470640ffdd534a4c0f890964a0949d2488c36ef Mon Sep 17 00:00:00 2001 From: Doxa Maps Date: Mon, 28 Sep 2026 05:20:31 +0000 Subject: [PATCH] Update from source 11129455e --- internal/check-series-isolation.mjs | 63 ++++++++++++++++++++++++++-- library/scripts/posters/generate.mjs | 57 ++++++++++++++++++++----- library/scripts/posters/packet.mjs | 25 +++++++++++ library/scripts/posters/series.json | 46 +++++++++++++++++++- 4 files changed, 176 insertions(+), 15 deletions(-) diff --git a/internal/check-series-isolation.mjs b/internal/check-series-isolation.mjs index db69b5be..a0302663 100644 --- a/internal/check-series-isolation.mjs +++ b/internal/check-series-isolation.mjs @@ -50,7 +50,7 @@ const ok = (m) => console.log(` · ${m}`) * and reports a clean tree. That search was run, and it did report clean. A quantity has to be * looked for in BOTH forms. */ -const VOCAB = { +const HAND_VOCAB = { upg100: [ 'UNENGAGED PEOPLE GROUPS', 'UPG-100', @@ -68,6 +68,50 @@ const VOCAB = { ], } +/** + * ★ THE VOCABULARY IS DERIVED FROM series.json, NOT HAND-MAINTAINED — and this is the fix for a + * blind spot that let a wrong render pass. + * + * THE FAILURE: the hand list above held the phrases I had already FOUND. It could not see framing + * I never enumerated, so when a SERIES=country poster still printed "Unengaged of West Africa", + * "Series: List of 100 UPG's · map 11 of 28" and a side box calling it "one of a 30-map set", this + * gate said PASS — and I reported that PASS to three agents AS PROOF the render was clean. rega + * found it by reading the rendered page. A hand list makes a gate a LOWER BOUND on correctness + * while it looks like a guarantee. + * + * SO: every string a series declares (its overrides, its NETWORKS blocks, its poster header) is + * automatically that series' vocabulary. Add a string to series.json and it is policed from that + * moment, with nobody remembering to update a list here. The hand list is KEPT and merged, because + * it holds phrases no series declares as its own — "Project 7000", "How this list was found" — + * which live in packet-strings.json and would otherwise go unpoliced. + * + * Short and generic strings are skipped: a 12-character floor keeps a word like "Country Maps" + * from matching innocent prose, and anything a DIFFERENT series also declares is dropped, because + * shared wording is by definition not a fingerprint. + */ +function deriveVocab(series) { + const ids = Object.keys(series).filter((k) => !k.startsWith('_')) + const own = {} + for (const id of ids) { + const e = series[id] + own[id] = new Set([ + ...strings(e.strings || {}), + ...strings(e.networks || []), + ...strings(e.poster || {}), + ].map((s) => s.trim()).filter((s) => s.length >= 12)) + } + // Drop anything two series share — it cannot identify either of them. + for (const a of ids) { + for (const b of ids) { + if (a === b) continue + for (const s of own[b]) own[a].delete(s) + } + } + const out = {} + for (const id of ids) out[id] = [...new Set([...(HAND_VOCAB[id] || []), ...own[id]])] + return out +} + /** Every string inside a value, however nested. */ function* strings(v) { if (typeof v === 'string') yield v @@ -115,6 +159,7 @@ function checkDefaultUnchanged(series) { } function checkNoCrossContamination(series, ids) { + const VOCAB = deriveVocab(series) for (const id of ids) { const mine = seriesText(series[id]) for (const [other, phrases] of Object.entries(VOCAB)) { @@ -129,8 +174,20 @@ function checkNoCrossContamination(series, ids) { } /** A RENDERED file is the only proof that the wiring reached the page. */ -function checkRendered(file, id) { +function checkRendered(file, id, series) { + const VOCAB = deriveVocab(series) + // ★ READ WHAT A READER SEES, NOT WHAT THE FILE CONTAINS. generate.mjs ships its own design notes + // into the SVG as and /* CSS comments */ — "the title reads 'Unengaged of + // '", "the overline above it is the plain category, 'Unengaged People Groups'". Those are + // developer prose about the template, not text on the sheet, and a correct country poster + // failed on five of them. A detector that does not know the comment syntax of the language it + // reads attacks the documentation of the very thing it protects. + // Stripped rather than allowlisted: an allowlist would have to grow every time someone writes a + // comment quoting a string, which is exactly the hand-maintained-list failure this gate just + // stopped making elsewhere. const html = readFileSync(file, 'utf8') + .replace(//g, ' ') + .replace(/\/\*[\s\S]*?\*\//g, ' ') for (const [other, phrases] of Object.entries(VOCAB)) { if (other === id) continue for (const p of phrases) { @@ -188,7 +245,7 @@ if (selfTestMode) { const f = resolve(fileArg) if (!existsSync(f)) fail(`rendered file not found: ${f}`) else if (!seriesArg) fail('a rendered file needs --series= to say what it should be') - else checkRendered(f, seriesArg) + else checkRendered(f, seriesArg, series) } else { console.log(' · no rendered file given (declarations only) — pass one with --series= to check a build') } diff --git a/library/scripts/posters/generate.mjs b/library/scripts/posters/generate.mjs index 74c7cc94..feb324df 100644 --- a/library/scripts/posters/generate.mjs +++ b/library/scripts/posters/generate.mjs @@ -19,7 +19,7 @@ // // Output: out/posters/.html + out/manifest.json -import { readFileSync, writeFileSync, mkdirSync } from 'node:fs'; +import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'node:fs'; import { fileURLToPath } from 'node:url'; import { dirname, join, resolve } from 'node:path'; // OSM street tiles for city sheets — no token, no account. See osm-basemap.mjs. @@ -50,7 +50,12 @@ mkdirSync(POSTERS, { recursive: true }); // its own history (forgetting it once rendered a stale copy and Nepal vanished from a poster). // Adding a second, silently-competing source for the same path would rebuild that bug. const SERIES_DEF_ALL = readJSON_early(join(__dirname, 'series.json')); -const SERIES_ID = process.env.SERIES || 'upg100'; +// ★ COUNTRIES=1 IS AN ALIAS FOR SERIES=country, not a second switch (dx-shepherd, 2026-09-28). +// The country-posters app has been run as COUNTRIES=1 since before series.json existed, so the +// alias keeps every one of those commands working AND now gives them the matching packet. Having +// TWO parameters for ONE concept is precisely what let rega's SERIES=country render take the new +// packet path while its header stayed UPG-100 — one switch, or the halves drift again. +const SERIES_ID = process.env.SERIES || (process.env.COUNTRIES === '1' ? 'country' : 'upg100'); if (!SERIES_DEF_ALL[SERIES_ID] || SERIES_ID.startsWith('_')) { const known = Object.keys(SERIES_DEF_ALL).filter((k) => !k.startsWith('_')).join(', '); console.error(`SERIES="${SERIES_ID}" is not in series.json. Known: ${known}`); @@ -378,7 +383,10 @@ const CITIES = process.env.CITIES === '1'; // OSM street tiles on city sheets. Opt-in (BASEMAP=1) so a run can be reproduced // without network access, and so the tile cache is only exercised deliberately. const BASEMAP = process.env.BASEMAP === '1'; -const COUNTRIES = process.env.COUNTRIES === '1'; +// ★ DERIVED FROM THE SERIES, so SERIES=country selects the country ZONE SOURCE too and not just +// the wording. COUNTRIES=1 still works: it resolves to SERIES_ID 'country' above, whose +// countryMode is true, so this is the same boolean by a different route. +const COUNTRIES = SERIES_DEF.countryMode === true; // SERIES NAME — the operator is still settling this ("it might make more sense to // call it 'List of 100 UPG's'"). One constant, so the rename is a one-line change // and never has to be hunted across the sheet. @@ -388,10 +396,15 @@ const COUNTRIES = process.env.COUNTRIES === '1'; // → "Country Maps"); SERIES_NAME still overrides either default. // design note 2026-08-01: "we'll call this series the Country Map series… inside of the // quotes say Country Maps. So that series should be a parameterized field." +// ★ NOW FROM series.json (SERIES_DEF.poster.seriesName), so the Series line, the facts-table row +// and the side box all read one source. CITIES keeps its own name: there is no cities entry in +// series.json yet, and inventing one here would be a second place to look. SERIES_NAME still +// overrides everything, which is the behaviour the original comment promised. +const POSTER_TEXT = SERIES_DEF.poster || {}; const SERIES_NAME = process.env.SERIES_NAME || (process.env.CITIES === '1' ? 'Key City Maps' - : process.env.COUNTRIES === '1' ? 'Country Maps' - : "List of 100 UPG's"); + : POSTER_TEXT.seriesName + || "List of 100 UPG's"); // ── PIN BADGE RADIUS (template parameter) ─────────────────────────────────── // The numbered disc on the map. It is the single knob that decides how far the // de-collision pass has to shove pins away from their true coordinates: MIN_SEP @@ -2094,7 +2107,7 @@ async function renderPoster(zone, pins, zoneFC) { const b1x = [GRID_L, GRID_L + B1_CW + B1_GUT]; const b1Top = py; headBlock += `Jesus’ Strategy to Feed the 5000`; - headBlock += `What is a Prayer List of 100 UPG?`; + headBlock += `${esc(POSTER_TEXT.sideBoxHeading || 'What is a Prayer List of 100 UPG?')}`; const bodyTop = py + 20; // COPY IS THE OWN (05-poster-vision/source-material/01). It KEEPS THE @@ -2112,7 +2125,13 @@ async function renderPoster(zone, pins, zoneFC) { let cy1 = bodyTop; cy1 = paraInto(push, - `This map is one of a ${approxLists}-map set called the ${SERIES_NAME} — a large, overwhelming target in the thousands, organised into clear groupings and displayed on maps and prayer lists.`, + // {approxLists} and {seriesName} are filled here rather than in series.json, so the sentence + // still states the MEASURED list count (approxLists is derived from the zones actually built) + // and can never carry a typed number that drifts from the sheet it is printed on. + (POSTER_TEXT.sideBoxBody + || 'This map is one of a {approxLists}-map set called the {seriesName} — a large, overwhelming target in the thousands, organised into clear groupings and displayed on maps and prayer lists.') + .replace('{approxLists}', String(approxLists)) + .replace('{seriesName}', SERIES_NAME), { x: b1x[1], y: cy1, w: B1_TW, fs: B1_FS, lh: B1_LH, cls: 'hsrc2' }); cy1 += 5; cy1 = paraInto(push, @@ -2300,7 +2319,7 @@ async function renderPoster(zone, pins, zoneFC) { // at 56px this one ran off BOTH edges. Solve for the largest size that fits // between the sheet margins. const H1_MAX = 56, H1_MIN = 26; - const h1Text = `Unengaged of ${title}`; + const h1Text = `${POSTER_TEXT.titleQualifier || 'Unengaged of'} ${title}`; const h1Room = PW - 2 * (SHEET_EDGE + 24); let H1_SIZE = H1_MAX; // GEORGIA BOLD MEASURES ~1.35x THE LATO-TUNED ESTIMATE. textW's advance table @@ -2423,8 +2442,8 @@ async function renderPoster(zone, pins, zoneFC) { At y=46 its cap-height started ~32 — only 6px clear of the 26px border, so it read as pinned to the edge. The band runs 26..(title cap-top ~78), so its optical middle is ~57. --> - Unengaged People Groups - Unengaged of ${esc(title)} + ${esc(POSTER_TEXT.kicker || 'Unengaged People Groups')} + ${esc(POSTER_TEXT.titleQualifier || 'Unengaged of')} ${esc(title)} @@ -2790,7 +2809,23 @@ if (CITIES) { console.log(`[generate] CITY MODE — ${zones.length} named ASCII population centres` + (unresolved ? ` (${unresolved} group names did not resolve to a slug)` : '')); } else if (COUNTRIES) { - const cpath = resolve(__dirname, '..', '..', '..', 'plugin-designs', 'country-posters', 'data', 'countries.json'); + // ⚠ THE PATH THIS USED TO READ NO LONGER EXISTS, and COUNTRY MODE HAS BEEN BROKEN BECAUSE OF IT. + // emit-countries.mjs writes library/data/country-posters.json (its OUT_DIR is library/data), and + // ddde518a8 moved the data there while this reader kept pointing at + // plugin-designs/country-posters/data/countries.json — a path with no file on main. Any + // COUNTRIES=1 run has been dying on ENOENT since; I only found it because dx-shepherd's + // one-switch ruling made SERIES=country depend on this branch. + // Both paths are tried, new one first, so a checkout carrying either layout works. + const cpath = [ + resolve(__dirname, '..', '..', 'data', 'country-posters.json'), + resolve(__dirname, '..', '..', '..', 'plugin-designs', 'country-posters', 'data', 'countries.json'), + ].find((p) => existsSync(p)); + if (!cpath) { + console.error('[generate] COUNTRY MODE needs the country list, and neither location has it:'); + console.error(' library/data/country-posters.json (written by: node emit-countries.mjs)'); + console.error(' plugin-designs/country-posters/data/countries.json (the pre-ddde518a8 path)'); + process.exit(1); + } const cdata = JSON.parse(readFileSync(cpath, 'utf8')); const want = process.env.SHEET || '3x3'; // which tier to build zones = (cdata.countries || []) diff --git a/library/scripts/posters/packet.mjs b/library/scripts/posters/packet.mjs index f715428e..cb5e614d 100644 --- a/library/scripts/posters/packet.mjs +++ b/library/scripts/posters/packet.mjs @@ -437,6 +437,31 @@ for (const z of zones) { // (html/body/.stage/@media print) which would fight the packet's own shell. // Taking the stylesheet rather than duplicating hex codes means a palette or // map-style change in generate.mjs follows the packet automatically. + // ★ THE POSTER'S OWN SERIES, READ OUT OF THE ARTWORK, AND IT MUST AGREE WITH OURS. + // ⚠ THE BUG THIS EXISTS FOR IS SILENT AND I WALKED INTO IT. The three below-map blocks are + // DRAWN INTO THIS SVG by generate.mjs, so they reach the packet through the IMAGE — no string + // file mentions them and no grep of packet-strings.json can see them. Build a country packet + // against a poster left over from a UPG-100 run and page 1 prints "Thirty disciples" with no + // error, the right file size, and a generator reporting success. It looks exactly like the + // SERIES parameter does not work. + // Documentation cannot hold this: "regenerate the poster first" is a fact about the build + // living in a human's head, and the same bet already failed for "regenerating the tree alone + // ships a stale .js" — written down, and it still happened. So the build ASSERTS it. + // The marker is the NETWORKS text itself, which is the one thing that survives every stage of + // the pipeline into the artefact. Suggested by regions-plugin, 2026-09-28. + const posterSeries = Object.entries(SERIES_ALL) + .filter(([id]) => !id.startsWith('_')) + .find(([, def]) => (def.networks || []).some(([head]) => m && m[0].includes(head))); + if (m && posterSeries && posterSeries[0] !== SERIES_ID) { + console.error(`\nSERIES MISMATCH — refusing to build a packet around the wrong poster.`); + console.error(` packet series : ${SERIES_ID}`); + console.error(` poster series : ${posterSeries[0]} (out/posters/${z.id}.html)`); + console.error(` The poster's below-map text is drawn INTO its SVG, and this packet lifts that`); + console.error(` SVG whole, so the packet would print ${posterSeries[0]} wording on page 1.`); + console.error(` Regenerate the poster first:`); + console.error(` SERIES=${SERIES_ID} ONLY=${z.id} node generate.mjs`); + process.exit(1); + } if (m) mapSvg = `
${m[0]}
`; diff --git a/library/scripts/posters/series.json b/library/scripts/posters/series.json index e53ec509..522430cd 100644 --- a/library/scripts/posters/series.json +++ b/library/scripts/posters/series.json @@ -20,8 +20,24 @@ "dataDir": "repo-relative folder holding clusters.json and the selector/normalize/legend modules", "funnel": "false DROPS the narrowing-funnel page entirely (it is a UPG-100 argument)", "strings": "overrides merged OVER packet-strings.json's block, per key", - "networks": "the three below-map blocks generate.mjs prints; ENGLISH ONLY -- see note" + "networks": "the three below-map blocks generate.mjs prints; ENGLISH ONLY -- see note", + "poster": "the page-1 poster HEADER wording; ENGLISH ONLY, same reason as networks", + "countryMode": "true selects the COUNTRIES zone source (one sheet per country)" }, + "poster_keys": { + "kicker": "the small line above the title (generate.mjs:2426)", + "titleQualifier": "the coloured words before the region name, e.g. 'Unengaged of' (:2427)", + "seriesName": "the name in quotes on the Series line and in the side box (:1877, :1907, :2115)", + "sideBoxHeading": "the right-hand explainer's heading (:2097)", + "sideBoxBody": "that explainer's first paragraph (:2115)" + }, + "one_switch": [ + "SERIES is the ONE switch (dx-shepherd, 2026-09-28). SERIES=country implies the country zone", + "source, the country series name and the country header strings. COUNTRIES=1 is kept as an", + "ALIAS that sets SERIES=country, so runs that predate this file keep working AND now get the", + "matching packet. Two parameters for one concept is what let rega's country render take the new", + "packet path while its header stayed UPG-100." + ], "english_only_note": [ "`networks` is English-only ON PURPOSE, not an oversight. generate.mjs performs no localization", "at all: measured zero occurrences of PACKET_LANG, packet-strings or i18n in the file, and its", @@ -42,8 +58,16 @@ "upg100": { "label": "UPG-100 — unengaged people groups, ~30 zoned lists", "dataDir": "plugin-designs/my-upg-100-list/data", + "countryMode": false, "funnel": true, "strings": {}, + "poster": { + "kicker": "Unengaged People Groups", + "titleQualifier": "Unengaged of", + "seriesName": "List of 100 UPG's", + "sideBoxHeading": "What is a Prayer List of 100 UPG?", + "sideBoxBody": "This map is one of a {approxLists}-map set called the {seriesName} — a large, overwhelming target in the thousands, organised into clear groupings and displayed on maps and prayer lists." + }, "networks": [ ["1 · Thirty disciples.", "Each follower of Jesus takes their own map and prayer list, and sets apart a place to pray — some have hung a people-group map over their bed."], @@ -57,7 +81,27 @@ "country": { "label": "Country — one poster per country, ~170 of them", "dataDir": "plugin-designs/my-upg-100-list/data", + "countryMode": true, "funnel": false, + "poster": { + "kicker": "Unreached People Groups", + "titleQualifier": "Pray for", + "seriesName": "Country Maps", + "sideBoxHeading": "What is a Country Prayer Map?", + "sideBoxBody": "This map is one of a {approxLists}-map set called the {seriesName} — one sheet for each country, naming the unreached people groups who live there so a reader can pray for them by name." + }, + "_poster_why": [ + "THE FOURTH LEAK SITE, found by rega reading the rendered page rather than a checker's verdict:", + "a SERIES=country poster still printed 'UNENGAGED PEOPLE GROUPS', 'Unengaged of West Africa',", + "'Series: List of 100 UPG's · map 11 of 28' and a side box calling it 'one of a 30-map set", + "called the List of 100 UPG's'. My check:series-isolation passed because its vocabulary was a", + "HAND LIST of the phrases I had already found -- a PASS from it was only ever a lower bound.", + "seriesName 'Country Maps' is the agent's own wording, recorded in a generate.mjs design note", + "dated 2026-08-01: 'we'll call this series the Country Map series... inside of the quotes say", + "Country Maps. So that series should be a parameterized field.' The kicker says UNREACHED rather", + "than UNENGAGED because a country sheet lists the unreached groups living there, which is a", + "wider set than the unengaged -- see _draft_awaiting_driver; this wording is part of that draft." + ], "_funnel_why": [ "The funnel narrows every people group on earth down to THIS UPG-100 list (Project 2000/7000/", "4700/700). That argument does not describe a country poster, and a page of true numbers about",