doxa-maps/library/scripts/posters/packet.mjs
doxa-maps-88655c70 301cc38fba
Some checks are pending
build-gate / build (push) Waiting to run
feat(upg-100): print a prayer packet from the map legend
Driver, as a first-time user: "I select one and it gives me print options at
the top right of the legend... that's the only way that map would be usable."

The affordance was already built and switched OFF. The shared legend emits
'export' with 'poster' | 'packet' for the selected node, the profile wired
@export, and SHOW_EXPORT_BUTTON was held false with the note "until the export
packet is finished: the button currently produces a broken packet". That
precondition is now met, so the switch is on.

ONE RENDERER, TWO CALLERS. The pages move to library/packet/packet-render.js,
imported by BOTH the build-time generator and the browser export, so the printed
packet and the generated one cannot drift. The card asked for exactly this:
"reuse the existing print actions rather than growing a fourth implementation."
The extraction is behaviour-preserving — the generated packet is byte-identical
to before apart from leading whitespace (the code is now inside a factory), and
check:packet-render still passes 8/8.

★ THE PACKET IS GENERATED ON CLICK, NEVER SHIPPED. One packet is ~3.5 MB (photos
dominate) and 28 zones would add ~100 MB to a repo that ALREADY had a publish
blocked at a 74 MB asset tree. Generating client-side costs zero repository
bytes; the photos come from URLs the records already carry. This is the Driver's
instruction: "instead of fetching the poster from the public folder I want the
client side code to generate the poster real time when you click on the button
so it does not take a lot of mb storage." Bundle cost: +21 KB (494 -> 516 KB),
which includes all 12 prayer points and Mark 5:1-20 in full.

Printing goes through the SHARED openPrintFrame — a hidden same-page iframe, not
window.open(). A popup is blockable and drops the reader out of the map.

Two traps the browser path had to handle, both already known to this codebase:
  - pg_slugs BYPASSES the shared selector evaluator (it intentionally throws on
    that type, and 12 of the 27 lists use it). Same special-case the map and the
    poster wall apply, so all three agree about who is in a list.
  - evaluate() returns a Set, not an array.

VERIFIED IN A BROWSER, not by reading source (new gate check:packet-browser):
live records -> shared renderer -> page.pdf(), giving 8 sheets -> 8 pages at
612x792pt, 95 people cards, 12 wheel segments, Mark 5 in full, 2 metacamp.org
attributions, 0 page errors. I18n verified with a partial Spanish block: the
translated headings rendered in Spanish while Mark 5 and the attributions fell
back to English, per key, never blank.

Card BUILD-UPG100-MAP-LEGEND-LEADS-WITH-THE-UPG-100-LIST part (d). Parts (a) and
(b) shipped earlier. Part (c) — removing the People Group tab and retabbing the
legend — stays OPEN: that card is explicitly driver_gated on "what is the
legend's tree once Region/Zone/People Group are removed", and the current tabs
are wired to real hierarchy data.
2026-09-24 02:43:49 +00:00

192 lines
11 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;
if (!API) {
console.error('[packet] No API base — set VITE_API_BASE_URL. 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]);
const POINTS = L.points || EN.points;
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'));
const R = createPacketRenderer({ strings: STRINGS, lang: LANG });
const { mapPage, listPages, pointsPage, wheelPage, css, mapCss, PER_PAGE, W, H } = R;
// ── DATA ────────────────────────────────────────────────────────────────────
const clusters = JSON.parse(readFileSync(join(DATA, 'clusters.json'), 'utf8'));
const { evaluate } = await import(join(DATA, 'selector-evaluator.js'));
const { normalizeRecords, extractRecords } = await import(join(DATA, 'normalize.js'));
async function loadRecords() {
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();
let i = 0;
const worker = async () => {
while (i < groups.length) {
const g = groups[i++];
if (!g.image_url) 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);
const withPhoto = groups.filter((g) => photos.has(g.slug)).length;
// 1 map + N list pages + points + wheel — asked of the renderer, not recomputed
// here, so this count cannot disagree with what was actually emitted.
const pageCount = 1 + Math.ceil(groups.length / R.PER_PAGE) + 2;
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;
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 = '';
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>`;
}
const { html, pageCount, withPhoto } = await buildPacket(z, groups, mapSvg);
const name = LANG === 'en' ? `${z.id}.html` : `${z.id}.${LANG}.html`;
writeFileSync(join(OUT, name), html);
made++;
console.log(` ✓ ${z.id.padEnd(34)} ${String(groups.length).padStart(4)} groups `
+ `${pageCount} pages ${withPhoto} photos ${(html.length / 1024 / 1024).toFixed(1)} MB`);
}
console.log(`\n${made} packet(s) → ${OUT}${ONLY ? ` (ONLY=${ONLY})` : ''}${LANG !== 'en' ? ` [${LANG}]` : ''}`);