Update from source 11129455e
This commit is contained in:
parent
7bba68b535
commit
2470640ffd
4 changed files with 176 additions and 15 deletions
|
|
@ -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')
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 || [])
|
||||
|
|
|
|||
|
|
@ -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>`;
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Reference in a new issue