Driver decisions (cards BUILD-OUTPUT-IS-ONE-FOLDER, BUILD-PLUGIN-STUDIO-IS-THE-OUTPUT-FRONT-PAGE).
- public/ holds only <tree>/ and a generated README.md. The build no longer writes public/index.html,
public/assets/ or public/__gallery/; `rm -rf public && bun run build` recreates exactly these two.
- The Plugin Studio is <tree>/index.html (tree-base "."), replacing the staging-forge chooser; nothing
is written above the tree. check:staging-lang, which tested that chooser, is retired with it.
- Assets are gathered in a temp directory and the fetched allowlist staged into <tree>/assets/ as
before. fonts/, images/, brand/ and legacy/ existed ONLY in public/assets: they are sources and move
to data/ (git mv). Poster previews keep their tracked sources in plugin-designs/*/assets/previews
(all 378 verified present).
- __gallery/ is archived out of public/ (a copy kept in the umbrella's _qa-cycle/archive) and out of
the build; generate-gallery renders only on request, into a directory you name, never public/.
- Native shells load the output folder: capacitor webDir public/<tree> (cap copy needs an index.html
there), Tauri frontendDist + window url relative to it. internal/sync-app-shells.mjs keeps both in
step with the tree's name on every build, so the coming rename stays one config line; the Capacitor
start page resolves its leaf tree-relative.
- dropin.include now lists only extras (README.md): the tree is always shipped and never named twice;
excludes are tree-relative ({tree}/...). clean.mjs removes the old root entries from existing
checkouts; the build lock and public/.gitignore's rules move out of public/.
- check:wp-embed read the BUILT manifest from gates:pre, before the build, so a from-scratch build
could never pass; it runs in gates:post and reads the tree name from config.
- Stale bundles refreshed by the clean build (key-cities, template-bundle, wall-of-people-groups);
the CLI doc's quoted scripts re-synced; capability exemplars regenerated; the '#' note leaves
check:stale-sweep-claim's command (it was already in //scripts).
Proof: rm -rf public && bun run build -> public/ = README.md + doxa-maps/; check:sri, check:drop,
check:parse, check:public-hygiene, check:quoted-scripts, check:capability-exemplars PASS; all 57 pages
(the front page + 56 leaves) mount under /doxa-maps/ on this tree. check:facet-cascade is red as on main.
309 lines
19 KiB
JavaScript
309 lines
19 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'));
|
|
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);
|
|
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;
|
|
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)');
|
|
}
|