Page 2 of every packet is now the funnel: 12,379 people groups on earth, 7,341
unreached, 4,754 frontier, 2,107 on this prayer network's list, and N of those
in the reader's hand. A partner holding one list can see where it sits in the
whole, which is the argument the packet is making.
★ IT RENDERS NUMBERS IT DID NOT COMPUTE. Every count is read from
data/people-groups/vision-funnel.json, which build-vision-funnel.mjs generates
from the IMB CSV and the live network list and which already drives the Vision
Card. Re-deriving them in the packet would give one fact two sources; they would
drift, and two internally-consistent surfaces disagreeing is worse than one
wrong number because a reader cannot tell which to trust.
K AND THE SHARE ARE MEASURED AT RENDER TIME, NEVER TYPED, and this mattered:
the brief said "~30 lists of 50-100" and the catalogue says K=27 with counts
from 8 to 118 (west-africa 118, asia-india-rajasthan 116, uncovered-scatter 8).
Only about half sit in that band, so no copy claims it. The page states what the
data can defend -- "1 of 27 lists" and "N of the 2,107" -- and a footnote states
plainly that 21 of the 2,107 are in no list, because a partner adding up rows
would find the gap anyway. Reported to the Driver; K=27 pending his ruling on
whether the two residual buckets count.
A SHARE MUST NEVER ROUND TO ZERO. One decimal gave the single-group fixture
"0.0%", which reads as "this packet covers none of them". Verified exhaustively
over all 2,108 possible list sizes against 2,107: no size prints 0 and none is
malformed. 1 group -> 0.05%, 20 -> 0.95%, 21 -> 1%, 43 -> 2%, 118 -> 5.6%.
NUMERALS STAY WESTERN, as required. num() is toLocaleString(lang), and CLDR's
default numbering system for the bare ar/hi tags is latn -- verified 12,379 and
2,107 in both. Reusing num() rather than writing a second formatter is also what
keeps the funnel's digits identical to every other page of the packet.
PAGE COUNT NOW COUNTS THE ARTIFACT. packet.mjs computed
`1 + ceil(n/PER_PAGE) + 2` under a comment claiming the number was "asked of the
renderer, not recomputed here". It WAS recomputed, and the funnel page silently
made it wrong by one -- exactly the drift the comment warned of. It now counts
the emitted sheets.
THE GATE IS TAUGHT, NOT LOOSENED. check-packet-render went red on every fixture
("43 cards should lay out as 6 sheets, but the packet has 7") -- correct
behaviour on a structure it had not been told about. The wrong fix is to accept
N or N+1: that assertion exists to catch silently dropped people groups, and a
±1 tolerance is the slack a lost list page hides in. So the funnel is DETECTED
via .funnel-title and added as a known term, and a funnel page that renders its
title but no share figure is now its own failure -- a number printed but not
asserted on is decoration.
The page is OPTIONAL: buildPacket omits it when no funnel data was supplied, so
every existing caller keeps working and a host without the data still gets a
correct packet. Better a 4-section packet than one asserting figures it could
not read. A multi-list batch selection keeps the steps but drops "1 of 27",
which is false for a custom selection, while "N of the 2,107" stays exactly true.
Verified: check:packet-strings-parity PASS (31 string keys x 11 locales, my 10
new ones present and translated in all 10 non-English blocks, 0 declared-absent
additions). A partial locale with 3 of 10 funnel keys translated renders those 3
translated and the other 7 in English, nothing blank. Arabic packet generated:
dir="rtl", funnel is sheet 2 of 7, all four steps carry Western digits. All 16
generated packets satisfy the corrected sheet arithmetic, including the 11 older
laos packets that have no funnel page. Mutation-tested: dropping a list sheet is
caught by the independent page-header witness (20 DROPPED), a funnel page with no
share figure is caught by the new assertion, and removing the funnel page passes.
BUNDLE=my-upg-100-list vite build 319 modules clean.
NOT verified in a browser: check:packet-render launches Chromium, which the
Playwright policy reserves to rega. The simulations above parse the same
attributes from the same generated HTML.
335 lines
21 KiB
JavaScript
335 lines
21 KiB
JavaScript
// packet.mjs — the UPG-100 PRAYER PACKET generator.
|
|
//
|
|
// One printable packet per zone. Structure is the Driver's, from the ideation
|
|
// (_qa-cycle/01-ideation-research/prayer-points.md):
|
|
//
|
|
// page 1 the MAP — a lightweight vertical one-pager, NOT the poster format
|
|
// pages 2..n the PEOPLE GROUPS — 20 per page, photo + name + brief detail
|
|
// page n+1 the 12 PRAYER POINTS
|
|
// page n+2 the PRAYER WHEEL — the rhythm these twelve points follow
|
|
//
|
|
// Printed front-and-back: Laos (95 groups) is 8 pages = 4 sheets.
|
|
//
|
|
// ── WHY A SEPARATE SCRIPT, not a FORMAT inside generate.mjs ──────────────────
|
|
// generate.mjs is a 2,600-line POSTER renderer. The packet is a different artefact
|
|
// with different pages; forking it would duplicate the design. Instead this reads
|
|
// the SAME zone resolution (clusters.json + the selector evaluator) so a packet can
|
|
// never disagree with its poster about which groups belong to a zone.
|
|
//
|
|
// ── EVERY STRING IS IN packet-strings.json ──────────────────────────────────
|
|
// Driver: "it all needs to be generative so that I can generate it in multiple
|
|
// languages." So no human-readable text is written in this file. LANG=xx picks a
|
|
// block; missing keys fall back to English individually, so a partial translation
|
|
// renders what it has rather than blanking the page.
|
|
//
|
|
// ── PHOTOS ──────────────────────────────────────────────────────────────────
|
|
// MEASURED: 1,072 of 2,107 groups have one (51%); they average ~244KB on Joshua
|
|
// Project's CDN, ~0.8s each. Fetched in parallel at packet time and inlined as
|
|
// data URIs so the finished HTML is ONE self-contained file that prints anywhere.
|
|
// Groups without a photo get a typographic card, never a broken image box.
|
|
//
|
|
// Usage:
|
|
// node packet.mjs # every zone, English
|
|
// ONLY=laos node packet.mjs # one zone
|
|
// LANG=es ONLY=laos node packet.mjs # one zone, Spanish
|
|
// NOPHOTOS=1 ONLY=laos node packet.mjs # skip the fetch (fast layout iteration)
|
|
//
|
|
// Output: out/packets/<zoneKey>[.<lang>].html
|
|
|
|
import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'node:fs';
|
|
import { fileURLToPath } from 'node:url';
|
|
import { dirname, join } from 'node:path';
|
|
|
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
const DATA = join(__dirname, '../../../plugin-designs/my-upg-100-list/data');
|
|
const OUT = join(__dirname, 'out', 'packets');
|
|
mkdirSync(OUT, { recursive: true });
|
|
|
|
// ⚠ NOT process.env.LANG — that is the SHELL's POSIX locale ("en_US.UTF-8"), which
|
|
// is not a valid BCP-47 tag and throws in localeCompare/toLocaleString. Measured:
|
|
// it crashed the first run. PACKET_LANG is the only source.
|
|
const LANG = process.env.PACKET_LANG || 'en';
|
|
const ONLY = process.env.ONLY || '';
|
|
const NOPHOTOS = process.env.NOPHOTOS === '1';
|
|
const API = process.env.VITE_API_BASE_URL || process.env.MAP_APP_API_URL;
|
|
// FAIL CLOSED without an API — EXCEPT when records come from a committed fixture, which
|
|
// is the one case where no API is needed at all. Measured 2026-09-24: this guard runs
|
|
// BEFORE loadRecords() ever looks at PACKET_RECORDS, so `bun run packet:ci` exited 1 on a
|
|
// tree that had everything it needed. It only passed in hand-testing because I had been
|
|
// supplying a dummy host — the real invocation path is what exposed it.
|
|
if (!API && !process.env.PACKET_RECORDS) {
|
|
console.error('[packet] No API base — set VITE_API_BASE_URL, or PACKET_RECORDS for a fixture build.');
|
|
console.error(' Failing closed.');
|
|
process.exit(1);
|
|
}
|
|
|
|
// ── STRINGS: pick the language, fall back per-key to English ────────────────
|
|
const STRINGS = JSON.parse(readFileSync(join(__dirname, 'packet-strings.json'), 'utf8'));
|
|
const EN = STRINGS.en;
|
|
const L = STRINGS[LANG] || {};
|
|
// Per-KEY fallback (not whole-block): a half-finished translation should render
|
|
// its finished keys, not fall back wholesale to English.
|
|
const t = (k) => (L[k] !== undefined ? L[k] : EN[k]);
|
|
const tr = (k) => ((L.rhythms || {})[k] !== undefined ? L.rhythms[k] : EN.rhythms[k]);
|
|
// NO `POINTS` HERE. The renderer owns point resolution (packet-render.js), and it must
|
|
// merge per-point/per-field — a partial `points` array is truncating, not falling back.
|
|
// A second copy of that logic in this file was DEAD (assigned, never read) and would have
|
|
// re-introduced the bug the moment someone wired it up.
|
|
const fill = (s, vars) => String(s).replace(/\{(\w+)\}/g, (_, k) => (vars[k] ?? ''));
|
|
|
|
// ── THE RENDERER IS SHARED ──────────────────────────────────────────────────
|
|
// The pages themselves live in library/packet/packet-render.js so that the browser
|
|
// (the UPG-100 legend's "Prayer packet" export) and this build-time script render
|
|
// the SAME packet from the SAME code. Changing a page here changes it in both.
|
|
const { createPacketRenderer } = await import(
|
|
join(__dirname, '..', '..', 'packet', 'packet-render.js'));
|
|
// SCRIPT FONT, inlined. Some languages need a face the system stack does not
|
|
// carry — Hindi renders as tofu boxes in Georgia/Palatino/serif. The packet is a
|
|
// standalone file meant to print offline, so the font is base64'd INTO it rather
|
|
// than linked, exactly as the photos are. Absent, the renderer omits the rule and
|
|
// falls back to serif rather than failing.
|
|
const SCRIPT_FONT_FILE = {
|
|
hi: 'noto-serif-devanagari/noto-serif-devanagari-400-devanagari.woff2',
|
|
mr: 'noto-serif-devanagari/noto-serif-devanagari-400-devanagari.woff2',
|
|
ne: 'noto-serif-devanagari/noto-serif-devanagari-400-devanagari.woff2',
|
|
};
|
|
let fontData = null;
|
|
const fontRel = SCRIPT_FONT_FILE[LANG];
|
|
if (fontRel) {
|
|
const fp = join(__dirname, '..', '..', '..', 'data', 'fonts', fontRel); // fonts are sources in data/ (BUILD-OUTPUT-IS-ONE-FOLDER)
|
|
if (existsSync(fp)) {
|
|
fontData = readFileSync(fp).toString('base64');
|
|
} else {
|
|
// Loud, not silent: a missing font here means the packet prints as empty
|
|
// rectangles, which looks like a content bug rather than a missing asset.
|
|
console.warn(`[packet] ⚠ ${LANG} needs ${fontRel} and it is MISSING — `
|
|
+ 'the packet will render as tofu boxes. Vendor the font first.');
|
|
}
|
|
}
|
|
const R = createPacketRenderer({ strings: STRINGS, lang: LANG, fontData });
|
|
const { mapPage, listPages, pointsPage, wheelPage, css, mapCss, PER_PAGE, W, H } = R;
|
|
|
|
// ── DATA ────────────────────────────────────────────────────────────────────
|
|
const clusters = JSON.parse(readFileSync(join(DATA, 'clusters.json'), 'utf8'));
|
|
|
|
// ── THE PAGE-2 FUNNEL ───────────────────────────────────────────────────────
|
|
// The same context the browser path builds in upg-100-list.vue's packetFunnel(), so the Node
|
|
// generator and the in-page print cannot disagree about page 2 any more than they can about the
|
|
// pages around it.
|
|
//
|
|
// ★ READ, NEVER COUNTED. The four steps come verbatim from vision-funnel.json, generated by
|
|
// build-vision-funnel.mjs from the IMB CSV and the live network list, and already driving the
|
|
// Vision Card. K and the unassigned count ARE measured here — but from the catalogue that defines
|
|
// them, at build time, so no figure on the page is a typed constant.
|
|
// A missing file yields null, and buildPacket omits page 2 rather than printing numbers it could
|
|
// not read.
|
|
const FUNNEL = (() => {
|
|
try {
|
|
const raw = JSON.parse(readFileSync(join(DATA, '../../../data/people-groups/vision-funnel.json'), 'utf8'));
|
|
if (!raw || !Array.isArray(raw.steps) || !raw.steps.length) return null;
|
|
const lists = [...(clusters.lists || []), ...(clusters.asia_lists || []),
|
|
...(clusters.uncovered_lists || [])];
|
|
const listTotal = Number(raw.steps[raw.steps.length - 1].groups) || 0;
|
|
const assigned = lists.reduce((s, l) => s + (Number(l.count) || 0), 0);
|
|
return { ...raw, listCount: lists.length, listTotal, unassigned: Math.max(0, listTotal - assigned) };
|
|
} catch (_) { return null; }
|
|
})();
|
|
const { evaluate } = await import(join(DATA, 'selector-evaluator.js'));
|
|
const { normalizeRecords, extractRecords } = await import(join(DATA, 'normalize.js'));
|
|
|
|
async function loadRecords() {
|
|
// ★ A COMMITTED FIXTURE, so CI can build a packet at all. out/ is gitignored — both the
|
|
// records cache AND every generated packet — so on a clean checkout check:packets-all
|
|
// finds nothing and SKIPs. MEASURED 2026-09-24: CI runs the sweep on EVERY build via
|
|
// gates:post and it has SKIPped every single time, which means every packet assertion in
|
|
// this tree (the 12 points, the wheel labels, page 1's geography, the print layer, the
|
|
// build stamp) has never once been evaluated there. A SKIP that is always taken is
|
|
// indistinguishable from an unwired gate, and the wave-membership census cannot see the
|
|
// difference because membership measures INVOCATION, not EVALUATION.
|
|
// PACKET_RECORDS points at a small committed subset instead of the live API or the
|
|
// gitignored cache. It is deliberately NOT the default: real packets must use real data.
|
|
const fixture = process.env.PACKET_RECORDS;
|
|
if (fixture) {
|
|
if (!existsSync(fixture)) throw new Error(`PACKET_RECORDS set but missing: ${fixture}`);
|
|
return JSON.parse(readFileSync(fixture, 'utf8'));
|
|
}
|
|
const cache = join(__dirname, 'out', '_records.json');
|
|
if (process.env.OFFLINE && existsSync(cache)) return JSON.parse(readFileSync(cache, 'utf8'));
|
|
const fields = 'slug,name,country_code,population,religion,image_url,has_photo,picture_credit,latitude,longitude';
|
|
const r = await fetch(`${API}/api/people-groups/list?fields=${fields}&lang=${LANG}`,
|
|
{ headers: { accept: 'application/json' } });
|
|
if (!r.ok) throw new Error(`PG API ${r.status}`);
|
|
const recs = normalizeRecords(extractRecords(await r.json()));
|
|
try { writeFileSync(cache, JSON.stringify(recs)); } catch { /* best effort */ }
|
|
return recs;
|
|
}
|
|
|
|
function allZones() {
|
|
const out = [];
|
|
for (const k of ['lists', 'asia_lists', 'uncovered_lists']) {
|
|
for (const z of clusters[k] || []) out.push({ ...z, zoneKey: z.id });
|
|
}
|
|
return out;
|
|
}
|
|
|
|
// ── PHOTOS: parallel fetch, inlined as data URIs ────────────────────────────
|
|
// Bounded concurrency: 16 was measured at 20 photos in 5.9s. Unbounded would open
|
|
// ~118 sockets against someone else's CDN, which is rude and slower in practice.
|
|
async function fetchPhotos(groups, limit = 16) {
|
|
if (NOPHOTOS) return new Map();
|
|
const out = new Map();
|
|
// ★ PHOTOS_EVERY=N — keep only every Nth photo, so a MIXED packet can be rendered on purpose.
|
|
// Every packet ever produced has been all-photo (laos: 95 photos, 0 typographic) or
|
|
// all-typographic (every fixture: 0 photos, N typographic). The MIXED case — some groups with a
|
|
// photo, some without — has NEVER been rendered, and it is the realistic one: this loop already
|
|
// degrades per-photo on a failed fetch (the `continue` below), so any zone where a few images
|
|
// 404 produces exactly that layout in front of a reader.
|
|
// It is also an I18N SURFACE nobody has seen: the fallback card renders t('no_photo'), which is
|
|
// translated in all 11 catalogs (sin fotografía disponible / 沒有照片 / لا توجد صورة متاحة).
|
|
// Deterministic by INDEX rather than random, so the same command always yields the same packet —
|
|
// a fixture that differs run to run cannot be a gate.
|
|
const every = Number(process.env.PHOTOS_EVERY || 0);
|
|
let i = 0;
|
|
const worker = async () => {
|
|
while (i < groups.length) {
|
|
const idx = i
|
|
const g = groups[i++];
|
|
if (!g.image_url) continue;
|
|
// Skip every photo whose index is not a multiple of N — see PHOTOS_EVERY above.
|
|
if (every > 1 && idx % every !== 0) continue;
|
|
try {
|
|
const r = await fetch(g.image_url, { signal: AbortSignal.timeout(20000) });
|
|
if (!r.ok) continue;
|
|
const buf = Buffer.from(await r.arrayBuffer());
|
|
const mime = r.headers.get('content-type') || 'image/jpeg';
|
|
out.set(g.slug, `data:${mime};base64,${buf.toString('base64')}`);
|
|
} catch { /* a missing photo degrades to the typographic card */ }
|
|
}
|
|
};
|
|
await Promise.all(Array.from({ length: Math.min(limit, groups.length) }, worker));
|
|
return out;
|
|
}
|
|
|
|
// ── PAGE SHELL ──────────────────────────────────────────────────────────────
|
|
// Photo fetching is the only part that differs between the two callers (Node
|
|
// inlines base64 so the file is self-contained and printable offline; the browser
|
|
// hands the renderer plain URLs the page already has). Everything downstream is
|
|
// the shared renderer, so the two packets cannot drift.
|
|
async function buildPacket(zone, groups, mapSvg) {
|
|
const photos = await fetchPhotos(groups);
|
|
const html = R.buildPacket(zone, groups, photos, mapSvg, FUNNEL);
|
|
const withPhoto = groups.filter((g) => photos.has(g.slug)).length;
|
|
// ★ COUNT THE SHEETS IN THE EMITTED HTML, do not re-derive them from a formula. The old line
|
|
// computed `1 + ceil(n/PER_PAGE) + 2` while its own comment claimed the number was "asked of the
|
|
// renderer, not recomputed here" — it WAS recomputed, and adding the funnel page silently made it
|
|
// wrong by one, which is precisely the drift the comment warned about. Counting the artifact
|
|
// cannot disagree with the artifact.
|
|
const pageCount = (html.match(/class="sheet /g) || []).length;
|
|
return { html, pageCount, withPhoto };
|
|
}
|
|
|
|
// ── MAIN ────────────────────────────────────────────────────────────────────
|
|
const records = await loadRecords();
|
|
const bySlug = new Map(records.map((r) => [r.slug, r]));
|
|
const zones = allZones().filter((z) => !ONLY || z.id === ONLY);
|
|
if (!zones.length) { console.error(`[packet] no zone matches ONLY=${ONLY}`); process.exit(1); }
|
|
|
|
let made = 0;
|
|
const mapless = []; // zones whose page 1 rendered without its map
|
|
for (const z of zones) {
|
|
let slugs = [];
|
|
try {
|
|
// evaluate() returns a SET, not an array — spread it rather than assuming.
|
|
slugs = z.selector?.type === 'pg_slugs'
|
|
? (z.selector.values || [])
|
|
: (z.selector ? [...evaluate(z.selector, records)] : []);
|
|
} catch (e) { console.warn(`[packet] selector failed for ${z.id}: ${e.message}`); continue; }
|
|
const groups = slugs.map((s) => bySlug.get(s)).filter(Boolean)
|
|
.sort((a, b) => String(a.name).localeCompare(String(b.name), LANG));
|
|
if (!groups.length) { console.warn(`[packet] ${z.id}: no groups resolved — skipped`); continue; }
|
|
|
|
// The map one-pager reuses the poster's SVG when it has been generated; without
|
|
// it the packet still builds (cover page without the map) rather than failing.
|
|
const posterPath = join(__dirname, 'out', 'posters', `${z.id}.html`);
|
|
let mapSvg = '';
|
|
let mapMiss = null; // why page 1 has no map, if it has none
|
|
if (!existsSync(posterPath)) {
|
|
mapMiss = 'no out/posters/' + z.id + '.html';
|
|
}
|
|
if (existsSync(posterPath)) {
|
|
const poster = readFileSync(posterPath, 'utf8');
|
|
const m = poster.match(/<svg[\s\S]*?<\/svg>/);
|
|
// ⚠ The map SVG carries almost NO presentation attributes — generate.mjs styles
|
|
// it entirely from the poster document's <style> block (.ctry, .lbl, .lead, the
|
|
// :root palette...). Lifting the <svg> alone leaves those rules behind, and SVG
|
|
// `fill` then falls back to its initial value, which is BLACK. Measured: 349
|
|
// <text>, 203 <path class="ctry"> and 95 <circle> painted solid black, with no
|
|
// error and no broken image — the map was a black rectangle.
|
|
// So carry the poster's stylesheet across too, MINUS its document-level rules
|
|
// (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.
|
|
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>`;
|
|
// The poster EXISTS but carries no <svg> — a truncated or failed generate run. Same
|
|
// outcome as a missing file (page 1 is blank) and it was equally silent, so it is
|
|
// reported the same way rather than left as the one unexplained branch.
|
|
else mapMiss = `out/posters/${z.id}.html has no <svg>`;
|
|
}
|
|
|
|
const { html, pageCount, withPhoto } = await buildPacket(z, groups, mapSvg);
|
|
const name = LANG === 'en' ? `${z.id}.html` : `${z.id}.${LANG}.html`;
|
|
// ★ STAMP HOW THIS PACKET WAS BUILT, INTO THE PACKET. out/packets/ is gitignored, so the
|
|
// sweep gate (check:packets-all) grades whatever .html files happen to be sitting there —
|
|
// an ACCIDENTAL set, not a current one. MEASURED 2026-09-24: doxa-maps-plugin-studio, which
|
|
// shares main with this worktree, had two packets from a NOPHOTOS=1 probe days old and the
|
|
// sweep was RED there, on artifacts nobody intended to ship. A fast layout-iteration build
|
|
// and a shippable one were byte-indistinguishable: the packet carried lang and dir and
|
|
// nothing else about its own provenance.
|
|
// The GENERATOR stamps this rather than the renderer, because packet-render.js is shared
|
|
// with the browser export path and must not know about CLI flags.
|
|
// ⚠ NOT A TIMESTAMP. An mtime rule would fail a correct tree — measured in this very
|
|
// session, packet-strings.json was 303s newer than the packets while being byte-identical
|
|
// to HEAD, because a probe restored it from backup. Record the INTENT, which is a fact
|
|
// about the build, not the clock.
|
|
// `fixture` is a build fact like the others: a fixture packet is built from a committed
|
|
// records subset with image_url stripped, so it legitimately has no photos and its
|
|
// records are not today's live data. Without this the gate reports "every source record
|
|
// carries an image_url, so this is a skipped or failed photo fetch" — a true sentence
|
|
// about real records and a false one about this packet.
|
|
// `mixedphotos` is the same kind of fact: PHOTOS_EVERY=N deliberately keeps only every Nth
|
|
// photo, so SOME cards are typographic on purpose. Without the stamp the gate cannot tell a
|
|
// deliberately mixed packet from a partly-failed photo fetch — and those deserve opposite
|
|
// verdicts.
|
|
const provenance = [process.env.PACKET_RECORDS ? 'fixture' : null,
|
|
NOPHOTOS ? 'nophotos' : null,
|
|
Number(process.env.PHOTOS_EVERY || 0) > 1 ? 'mixedphotos' : null,
|
|
mapMiss ? 'nomap' : null]
|
|
.filter(Boolean).join(' ') || 'full';
|
|
const stamped = html.replace('<html ', `<html data-packet-build="${provenance}" `);
|
|
writeFileSync(join(OUT, name), stamped);
|
|
made++;
|
|
if (mapMiss) mapless.push(z.id);
|
|
// ⚠ DO NOT PRINT A BARE ✓ FOR A PACKET MISSING ITS FIRST PAGE. The map one-pager is the
|
|
// first line of the packet spec, and the fallback above is deliberate — but it was also
|
|
// SILENT: this printed "✓ laos 95 groups 8 pages" for a packet whose page 1 is empty,
|
|
// with the page count still 8 because the blank sheet is still a sheet. Measured
|
|
// 2026-09-24 in doxa-maps-plugin-studio, which has 0 generated posters: every packet
|
|
// produced there was degraded this way and the only clue was the packet itself.
|
|
// The empty-groups branch a few lines above already warns and skips; this branch has the
|
|
// same shape and had no warning, which is the whole defect.
|
|
console.log(` ${mapMiss ? '⚠' : '✓'} ${z.id.padEnd(34)} ${String(groups.length).padStart(4)} groups `
|
|
+ `${pageCount} pages ${withPhoto} photos ${(html.length / 1024 / 1024).toFixed(1)} MB`
|
|
+ (mapMiss ? ` NO MAP on page 1 (${mapMiss})` : ''));
|
|
}
|
|
console.log(`\n${made} packet(s) → ${OUT}${ONLY ? ` (ONLY=${ONLY})` : ''}${LANG !== 'en' ? ` [${LANG}]` : ''}`);
|
|
// Repeat it at the END too. A per-line ⚠ scrolls off in a 27-zone run; the summary is the
|
|
// part an operator actually reads, and this is the one fault that cannot be seen by
|
|
// counting pages.
|
|
if (mapless.length) {
|
|
console.warn(`\n⚠ ${mapless.length} of ${made} packet(s) have NO MAP on page 1: ${mapless.join(', ')}`);
|
|
console.warn(' The map one-pager is lifted from out/posters/<zone>.html, which `bun run posters`');
|
|
console.warn(' generates. Without it the packet still builds — page 1 is simply blank.');
|
|
console.warn(' Fix: bun run posters (then re-run this script)');
|
|
}
|