Some checks are pending
build-gate / build (push) Waiting to run
The packet as specified: page 1 the map one-pager, pages 2..n the people
groups 20-per-page with photos, then the 12 prayer points, then the prayer
wheel. Every string is externalised so a new language is a new block in
packet-strings.json, not a code change; fallback is PER KEY, so a partial
translation renders what it has and leaves the rest in English rather than
blanking. Verified with a partial es block before removing the stub.
Three bugs worth recording, each of which rendered silently wrong:
1. zoom, NOT transform:scale(), for print. transform is paint-time, so the
sheet still RESERVES its 1941px layout box and the paginator breaks it
across two pages, inking only the top of the first: 8 sheets came out as
16 PDF pages. Negative margins do not help — they collapse the outer
space, not the fragmentation box. zoom is layout-time, so pagination sees
the scaled box. Measured: transform 2 pages/sheet, transform+margins 2,
zoom 1.
2. The map SVG carries almost no presentation attributes — generate.mjs
styles it from the poster document's stylesheet. Lifting the <svg> alone
left those rules behind, and SVG fill falls back to its initial value,
which is BLACK: 670 elements painted solid black with no error, no broken
image, nothing any existing assertion could see. Fixed by carrying the
poster's stylesheet minus its document-level rules, so a palette change
in generate.mjs still follows the packet automatically.
3. LANG is the shell's POSIX locale ("en_US.UTF-8"), not a BCP-47 tag, and
throws in localeCompare. PACKET_LANG is the only source.
The verification lesson is the bigger one. The probe PRINTED "PDF pages: 16"
for several runs while reporting the packet verified, because the bad counter
never looked at that value — a number that is displayed but not asserted on
is decoration. Then the first fix for the black map asserted on the first
<rect>, the one element just repaired, and went green while 647 others were
still black. Both assertions now test the property rather than a proxy: one
sheet must be exactly one page, and the map as a whole must not be
predominantly black-filled. Both were confirmed to FAIL on the known-broken
input before being accepted.
411 lines
22 KiB
JavaScript
411 lines
22 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] ?? ''));
|
||
|
||
// ── SHEET GEOMETRY — the letter-portrait sheet, same as the poster format ───
|
||
const W = 1500, H = 1941; // CSS px; 8.5x11 at width 1500
|
||
const PAGE_W_IN = 8.5;
|
||
const SCALE = (PAGE_W_IN * 96 / W).toFixed(6); // print scale: one sheet = one page
|
||
const M = 60, HEAD = 90; // margins + running header
|
||
const COLS = 2, ROWS = 10, PER_PAGE = COLS * ROWS;
|
||
const GUT = 14;
|
||
const CW = (W - 2 * M - GUT * (COLS - 1)) / COLS;
|
||
const CH = (H - 2 * M - HEAD - GUT * (ROWS - 1)) / ROWS;
|
||
const PHOTO = Math.min(CH - 8, CW * 0.34);
|
||
|
||
const esc = (s) => String(s ?? '').replace(/&/g, '&').replace(/</g, '<')
|
||
.replace(/>/g, '>').replace(/"/g, '"');
|
||
// The API returns some fields as { value, label } dicts; the card wants the label.
|
||
const flat = (v) => (v && typeof v === 'object' ? (v.label ?? v.value ?? '') : (v ?? ''));
|
||
const num = (n) => (Number.isFinite(Number(n)) ? Number(n).toLocaleString(LANG) : '—');
|
||
|
||
// ── 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 ──────────────────────────────────────────────────────────────
|
||
const sheet = (inner, cls = '') =>
|
||
`<div class="sheet ${cls}">${inner}</div>`;
|
||
|
||
function css() {
|
||
return `
|
||
@page{size:letter portrait;margin:0}
|
||
html,body{margin:0;background:#fff;-webkit-print-color-adjust:exact;print-color-adjust:exact}
|
||
.sheet{position:relative;width:${W}px;height:${H}px;background:#f7f4ec;overflow:hidden;
|
||
page-break-after:always;break-after:page}
|
||
.sheet:last-child{page-break-after:auto;break-after:auto}
|
||
.hd{position:absolute;left:${M}px;top:46px;font:700 30px Georgia,'Palatino Linotype',serif;color:#1b1a17}
|
||
.hd-r{position:absolute;right:${M}px;top:54px;font:400 19px Georgia,serif;color:#8c8170}
|
||
.card{position:absolute;display:flex;gap:12px;align-items:center;
|
||
border-bottom:1px solid #e2ddd0;padding-bottom:6px}
|
||
.card img,.card .nophoto{width:${PHOTO.toFixed(0)}px;height:${PHOTO.toFixed(0)}px;flex:0 0 auto;border-radius:3px}
|
||
.card img{object-fit:cover}
|
||
.card .nophoto{border:1px solid #d6d0c0;background:#f2efe6;display:flex;align-items:center;
|
||
justify-content:center;font:italic 13px Georgia,serif;color:#a89f8c;text-align:center;padding:6px}
|
||
.nm{font:700 25px Georgia,serif;color:#1b1a17;line-height:1.1}
|
||
.mt{font:400 17px Georgia,serif;color:#6d766e}
|
||
/* FINE PRINT — Driver: "attribution minimal, fine print small". */
|
||
.fine{position:absolute;left:0;right:0;bottom:22px;text-align:center;
|
||
font:400 13px Georgia,serif;color:#a89f8c}
|
||
/* PRINT. The sheet is authored at ${W}px wide while @page is physical, so without
|
||
a rescale the browser maps ${W}px -> 15.6in and TILES one sheet over many pages.
|
||
|
||
⚠ Use zoom, NOT transform:scale(). They look identical on screen and behave
|
||
completely differently under pagination:
|
||
transform is paint-time — the element still RESERVES its full ${H}px layout
|
||
box, so the paginator walks ${H}px of content and breaks it across
|
||
two sheets, inking only the top of the first. Measured: 8 sheets
|
||
came out as 16 PDF pages. Negative margins do NOT fix this — they
|
||
collapse the outer space, not the fragmentation box.
|
||
zoom is layout-time — the box itself becomes ${(PAGE_W_IN*96).toFixed(0)}px
|
||
wide, so pagination sees the scaled size. Measured: 1 page/sheet.
|
||
Verified by _probe-pagecount.mjs (transform 2, transform+margins 2, zoom 1). */
|
||
@media print{
|
||
html,body{width:${(PAGE_W_IN * 96).toFixed(0)}px;margin:0;padding:0}
|
||
.sheet{width:${W}px;height:${H}px;zoom:${SCALE};
|
||
page-break-after:always;break-after:page}
|
||
.sheet:last-child{page-break-after:auto;break-after:auto}
|
||
}`;
|
||
}
|
||
|
||
// Lift the poster's stylesheet so the inlined map keeps its palette and map rules,
|
||
// dropping only the rules that would fight the packet's own page shell.
|
||
//
|
||
// Comments are stripped FIRST and deliberately: generate.mjs's CSS comments are long
|
||
// prose that contains braces and selector-like text, so any brace-counting or
|
||
// selector regex run over the raw source mis-parses them as rules. Strip, then split.
|
||
function mapCss(posterHtml) {
|
||
const block = posterHtml.match(/<style>([\s\S]*?)<\/style>/);
|
||
if (!block) return '';
|
||
const css = block[1].replace(/\/\*[\s\S]*?\*\//g, '');
|
||
|
||
// Drop @media (the poster's own print-scaling rules, which do not apply here).
|
||
// Depth-aware because @media bodies nest a further level of braces.
|
||
let out = '', depth = 0, i = 0;
|
||
while (i < css.length) {
|
||
if (css.startsWith('@media', i)) {
|
||
let d = 0, started = false;
|
||
while (i < css.length) {
|
||
if (css[i] === '{') { d++; started = true; }
|
||
else if (css[i] === '}') { d--; if (started && d === 0) { i++; break; } }
|
||
i++;
|
||
}
|
||
continue;
|
||
}
|
||
out += css[i++];
|
||
}
|
||
|
||
// Drop document-level rules: these style the POSTER page, not the map, and would
|
||
// override the packet's background, box-sizing and stage geometry.
|
||
const DOC = /(^|,)\s*(html|body|\*|\.stage)\s*$/;
|
||
return out.split('}')
|
||
.map((r) => {
|
||
const at = r.indexOf('{');
|
||
if (at < 0) return '';
|
||
const sel = r.slice(0, at).trim();
|
||
if (!sel) return '';
|
||
// :root must be KEPT — it carries the palette the map's var()s resolve against.
|
||
if (sel !== ':root' && sel.split(',').some((s) => DOC.test(s.trim()) || /^(html|body|\*|\.stage)$/.test(s.trim()))) return '';
|
||
return `${sel}{${r.slice(at + 1).trim()}}`;
|
||
})
|
||
.filter(Boolean).join('\n');
|
||
}
|
||
|
||
// ── PAGE 1: the map one-pager ───────────────────────────────────────────────
|
||
// Lightweight by design — the Driver asked for "a lightweight one pager that shows
|
||
// the geography with all the pins", explicitly NOT the poster. The poster remains
|
||
// its own artefact (generate.mjs); this is the packet's cover.
|
||
function mapPage(zone, groups, mapSvg) {
|
||
const count = groups.length;
|
||
return sheet(`
|
||
<div style="position:absolute;left:${M}px;top:${M}px;right:${M}px;text-align:center">
|
||
<div style="font:600 21px Georgia,serif;letter-spacing:3px;color:#8c8170">${esc(t('map_page_kicker'))}</div>
|
||
<div style="font:700 56px Georgia,serif;color:#1b1a17;margin-top:8px">${esc(zone.label || zone.id)}</div>
|
||
<div style="font:italic 24px Georgia,serif;color:#6d766e;margin-top:10px">
|
||
${esc(fill(t('map_page_sub'), { count: num(count) }))}</div>
|
||
</div>
|
||
${mapSvg}
|
||
<div class="fine">${esc(t('privacy_note'))}</div>`);
|
||
}
|
||
|
||
// ── PAGES 2..n: the people groups, 20 per sheet ─────────────────────────────
|
||
function listPages(zone, groups, photos) {
|
||
const pages = [];
|
||
for (let start = 0; start < groups.length; start += PER_PAGE) {
|
||
const slice = groups.slice(start, start + PER_PAGE);
|
||
const cards = slice.map((g, k) => {
|
||
const i = start + k;
|
||
const c = k % COLS, r = Math.floor(k / COLS);
|
||
const left = M + c * (CW + GUT), top = M + HEAD + r * (CH + GUT);
|
||
const src = photos.get(g.slug);
|
||
const img = src
|
||
? `<img src="${src}" alt="">`
|
||
: `<div class="nophoto">${esc(t('no_photo'))}</div>`;
|
||
return `<div class="card" style="left:${left.toFixed(0)}px;top:${top.toFixed(0)}px;
|
||
width:${CW.toFixed(0)}px;height:${CH.toFixed(0)}px">${img}
|
||
<div style="min-width:0">
|
||
<div class="nm">${i + 1}. ${esc(g.name)}</div>
|
||
<div class="mt" style="margin-top:4px">${esc(flat(g.religion))}</div>
|
||
<div class="mt">${num(g.population)} ${esc(t('people_suffix'))} · ${esc(flat(g.country_code))}</div>
|
||
</div></div>`;
|
||
}).join('');
|
||
pages.push(sheet(`
|
||
<div class="hd">${esc(zone.label || zone.id)}</div>
|
||
<div class="hd-r">${esc(fill(t('list_header'),
|
||
{ from: num(start + 1), to: num(Math.min(start + PER_PAGE, groups.length)), count: num(groups.length) }))}</div>
|
||
${cards}`));
|
||
}
|
||
return pages;
|
||
}
|
||
|
||
// ── PAGE n+1: the 12 prayer points ──────────────────────────────────────────
|
||
function pointsPage() {
|
||
const rows = POINTS.map((p) => {
|
||
const refs = (p.refs || []).map((r) => esc(r)).join(' · ');
|
||
const sub = p.sub ? ` <span style="font:italic 17px Georgia,serif;color:#8c8170">— ${esc(p.sub)}</span>` : '';
|
||
const passage = p.print_passage
|
||
// Point 4 prints Mark 5:1-20 IN FULL. Driver: "it actually lists out the full
|
||
// scripture, because prayer point number four is actually read the word."
|
||
? `<div style="font:400 14px Georgia,serif;color:#4a4a45;line-height:1.5;margin-top:5px;
|
||
padding-left:10px;border-left:2px solid #d9d3c4">${esc(t('point4_passage'))}
|
||
<div style="font:italic 12px Georgia,serif;color:#a89f8c;margin-top:3px">
|
||
${esc(t('point4_passage_credit'))}</div></div>`
|
||
: '';
|
||
return `<div style="margin-bottom:${p.print_passage ? 10 : 13}px">
|
||
<div style="font:700 19px Georgia,serif;color:#1b1a17">
|
||
${p.n}. ${esc(tr(p.rhythm))}${sub}</div>
|
||
<div style="font:400 17px Georgia,serif;color:#3b3226;line-height:1.35;margin-top:2px">${esc(p.text)}</div>
|
||
<div style="font:600 14px Georgia,serif;color:#7a3b2e;margin-top:2px">${refs}</div>
|
||
${passage}
|
||
</div>`;
|
||
}).join('');
|
||
return sheet(`
|
||
<div style="position:absolute;left:${M}px;top:44px;right:${M}px">
|
||
<div style="font:700 40px Georgia,serif;color:#1b1a17">${esc(t('points_title'))}</div>
|
||
<div style="font:italic 18px Georgia,serif;color:#6d766e;margin-top:6px">${esc(t('points_intro'))}</div>
|
||
</div>
|
||
<div style="position:absolute;left:${M}px;top:${M + 96}px;right:${M}px;bottom:52px">${rows}</div>
|
||
<div class="fine">${esc(t('points_attrib'))}</div>`);
|
||
}
|
||
|
||
// ── PAGE n+2: the prayer wheel ──────────────────────────────────────────────
|
||
// REDRAWN as SVG rather than embedding the source PDF page: a raster embed cannot
|
||
// be translated, and this packet must generate in any language. Twelve equal
|
||
// segments, the rhythm name set along each, 5 minutes each = one hour.
|
||
function wheelPage() {
|
||
const cx = W / 2, cy = 1090, R = 470;
|
||
const order = ['praise', 'wait', 'confess', 'read', 'ask', 'intercede',
|
||
'pray_word', 'thank', 'sing', 'meditate', 'listen', 'praise_end'];
|
||
// Hues sweep the wheel so neighbouring rhythms stay distinguishable in print.
|
||
const seg = order.map((key, i) => {
|
||
const a0 = (i * 30 - 90) * Math.PI / 180, a1 = ((i + 1) * 30 - 90) * Math.PI / 180;
|
||
const x0 = cx + R * Math.cos(a0), y0 = cy + R * Math.sin(a0);
|
||
const x1 = cx + R * Math.cos(a1), y1 = cy + R * Math.sin(a1);
|
||
const hue = Math.round(i * 30);
|
||
const mid = (a0 + a1) / 2;
|
||
const tx = cx + R * 0.62 * Math.cos(mid), ty = cy + R * 0.62 * Math.sin(mid);
|
||
let deg = mid * 180 / Math.PI;
|
||
if (deg > 90 || deg < -90) deg += 180; // keep every label upright
|
||
return `<path d="M ${cx} ${cy} L ${x0.toFixed(1)} ${y0.toFixed(1)} A ${R} ${R} 0 0 1 ${x1.toFixed(1)} ${y1.toFixed(1)} Z"
|
||
fill="hsl(${hue} 70% 72%)" stroke="#fff" stroke-width="3"/>
|
||
<text x="${tx.toFixed(1)}" y="${ty.toFixed(1)}" transform="rotate(${deg.toFixed(1)} ${tx.toFixed(1)} ${ty.toFixed(1)})"
|
||
text-anchor="middle" dominant-baseline="middle"
|
||
font-family="Georgia,serif" font-weight="700" font-size="21" fill="#2b2a26">${esc(tr(key))}</text>`;
|
||
}).join('');
|
||
const pips = order.map((_, i) => {
|
||
const a = ((i + 0.5) * 30 - 90) * Math.PI / 180;
|
||
const x = cx + (R + 46) * Math.cos(a), y = cy + (R + 46) * Math.sin(a);
|
||
return `<circle cx="${x.toFixed(1)}" cy="${y.toFixed(1)}" r="21" fill="#26251f"/>
|
||
<text x="${x.toFixed(1)}" y="${(y + 1).toFixed(1)}" text-anchor="middle" dominant-baseline="middle"
|
||
font-family="Georgia,serif" font-weight="700" font-size="20" fill="#fff">5</text>`;
|
||
}).join('');
|
||
return sheet(`
|
||
<div style="position:absolute;left:${M}px;top:48px;right:${M}px;text-align:center">
|
||
<div style="font:700 42px Georgia,serif;color:#1b1a17">${esc(t('wheel_title'))}</div>
|
||
<div style="font:italic 19px Georgia,serif;color:#6d766e;margin-top:10px;
|
||
max-width:1000px;margin-left:auto;margin-right:auto">${esc(t('wheel_intro'))}</div>
|
||
</div>
|
||
<svg width="${W}" height="${H}" style="position:absolute;left:0;top:0" xmlns="http://www.w3.org/2000/svg">
|
||
${seg}${pips}
|
||
<text x="${cx}" y="${cy + R + 130}" text-anchor="middle"
|
||
font-family="Georgia,serif" font-size="20" fill="#8c8170">5 ${esc(t('wheel_minutes'))} × 12</text>
|
||
</svg>
|
||
<div class="fine">${esc(t('wheel_attrib'))}</div>`);
|
||
}
|
||
|
||
// ── BUILD ONE PACKET ────────────────────────────────────────────────────────
|
||
async function buildPacket(zone, groups, mapSvg) {
|
||
const photos = await fetchPhotos(groups);
|
||
const pages = [
|
||
mapPage(zone, groups, mapSvg),
|
||
...listPages(zone, groups, photos),
|
||
pointsPage(),
|
||
wheelPage(),
|
||
];
|
||
const withPhoto = groups.filter((g) => photos.has(g.slug)).length;
|
||
const html = `<!DOCTYPE html><html lang="${esc(LANG)}"><head><meta charset="utf-8">
|
||
<title>${esc(zone.label || zone.id)}</title><style>${css()}</style></head>
|
||
<body>${pages.join('\n')}</body></html>`;
|
||
return { html, pageCount: pages.length, 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}]` : ''}`);
|