Update from source 11129455e
Some checks are pending
Deploy to GitHub Pages / build (push) Waiting to run
Deploy to GitHub Pages / deploy (push) Blocked by required conditions

This commit is contained in:
Doxa Maps 2026-09-28 05:20:31 +00:00
parent 7bba68b535
commit 2470640ffd
4 changed files with 176 additions and 15 deletions

View file

@ -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 <!-- HTML comments --> and /* CSS comments */ — "the title reads 'Unengaged of
// <Region>'", "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(/<!--[\s\S]*?-->/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=<id> 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=<id> to check a build')
}

View file

@ -19,7 +19,7 @@
//
// Output: out/posters/<zoneKey>.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 += `<text x="${b1x[0]}" y="${py}" class="hvh">Jesus’ Strategy to Feed the 5000</text>`;
headBlock += `<text x="${b1x[1]}" y="${py}" class="hvh">What is a Prayer List of 100 UPG?</text>`;
headBlock += `<text x="${b1x[1]}" y="${py}" class="hvh">${esc(POSTER_TEXT.sideBoxHeading || 'What is a Prayer List of 100 UPG?')}</text>`;
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. -->
<text x="${PW / 2}" y="57" text-anchor="middle" class="h1over">Unengaged People Groups</text>
<text x="${PW / 2}" y="104" text-anchor="middle" class="h1" font-size="${H1_SIZE}"><tspan class="h1qual">Unengaged of</tspan> ${esc(title)}</text>
<text x="${PW / 2}" y="57" text-anchor="middle" class="h1over">${esc(POSTER_TEXT.kicker || 'Unengaged People Groups')}</text>
<text x="${PW / 2}" y="104" text-anchor="middle" class="h1" font-size="${H1_SIZE}"><tspan class="h1qual">${esc(POSTER_TEXT.titleQualifier || 'Unengaged of')}</tspan> ${esc(title)}</text>
<!-- (The series line is emitted INSIDE headBlock, not here. Absolutely placing
it at a fixed y while the scripture flowed from headY is what made the
"(see Doxa.Life…)" line collide with the scripture rule.) -->
@ -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 || [])

View file

@ -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 = `<style>${mapCss(poster)}</style>
<div style="position:absolute;left:0;top:250px;width:${W}px;
transform:scale(0.62);transform-origin:top center">${m[0]}</div>`;

View file

@ -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 <locale> 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",