doxa-maps/library/scripts/posters/publish-html.mjs
doxa-maps-88655c70 3a468c2bc3
Some checks are pending
build-gate / build (push) Waiting to run
The poster print preview rasterises too, and a deployment can ship sources without rasters
posters/*.webp is not shipped by default (42MB; bundling pushed the tree to 80MB,
git-packed to 57MiB, and the catalog rejected the push). The DOWNLOAD path has
handled that since d154a76 — PosterWall fetches posters/<id>.html and rasterises
it in the browser. The PRINT path did not: it handed an <img> a url it never
checked, so on a default drop the SAME poster downloaded fine and PRINTED as
"Poster not available yet." That is the bad first impression
IDEA-POSTER-EXPORT-404-ON-DROPPED-FOLDER-TRADEOFF called out by name.

Three things were missing; all three are here:

1. printPoster now climbs the same ladder as the download path — probe the webp,
   and only on a miss fetch the .html, rasterise, and verify PIXELS before opening
   the dialog (an SVG in an <img> is an isolated document: a lost stylesheet prints
   a black square with no error). Blob urls are held until unmount, not revoked at
   print time — the preview <img> is still reading from them.

2. publish-html.mjs wrote into public/assets/posters, which is a GENERATED MIRROR
   that every `bun run tree` rmSync's. Its output never survived, which is a large
   part of why the fallback still had no input months after it shipped. Retargeted
   at data/posters, the tracked source the mirror is made from.

3. bundlePosters is one boolean over a directory holding 40.9MB of rasters AND the
   28 sources, so it could only ever ship both or neither — and "neither" is what
   made both full-size paths 404. posterSources:true ships the sources without the
   rasters, the combination that was previously inexpressible. DEFAULT UNCHANGED:
   28 sources are 27.9MB raw (6.9MB packed, measured with pack-objects), real
   weight in a published tree that needs a deployment decision, not a silent one.

VERIFIED AT THE RENDERED LAYER, not by reading source. check:poster-print-fallback
clicks the real control with every posters/*.webp ABORTED at the network layer —
the default-drop condition forced rather than hoped for — and asserts the HUD
enters "generating the poster" and settles clean. Mutation-tested: removing the
fallback produces 4 failures including "no posters/*.html was fetched". The other
branch is verified too — with a webp present it is used directly, zero html
fetches, no rasterise — so the extra probe is paid only on a miss.

★ THE GATE WAS WRONG THREE TIMES FIRST, each time blaming the product for a
harness fault, which is why it now asserts the HUD instead of an internal:
  - it matched /print/i, but the control reads "UPG prayer poster" — the product
    names the ARTEFACT, not the action;
  - it patched Document.prototype in the PARENT realm while openPrintFrame writes
    into the iframe's own;
  - openPrintFrame calls doc.open() AFTER append, and open() RESETS the document,
    discarding a write patched at append time.
Through all three the .webp/.html request counters looked corroborating, because
the wall loads both by itself. The HUD is what a user sees, survives a refactor of
the print internals, and cannot silently miss.

The 28 poster sources are committed because the fallback has no input without
them. city-* posters are deliberately NOT covered: they fetch OSM street tiles at
generation time, so they are not reproducible offline and their rasters must stay.
2026-09-24 08:07:22 +00:00

110 lines
6 KiB
JavaScript

// publish-html.mjs — put the poster HTML where the app can fetch it, so the
// browser can rasterise a poster instead of the repo shipping 42 MB of .webp.
//
// ★ WHY THIS SCRIPT EXISTS. PosterWall falls back to rendering posters/<id>.html
// in the browser when no pre-rendered image is present (library/packet/
// poster-raster.js). That fallback was shipped and verified against a locally
// served file — but NOTHING PUBLISHED THE HTML. out/ is gitignored, public/ had
// zero .html, so deleting the .webp would have taken poster download from working
// to 404. The rasteriser worked; the feature would not have. This closes that gap.
//
// SCOPE — ZONE POSTERS ONLY, and that is a real distinction, not laziness:
// zone posters token-free, drawn from bundled geoBoundaries. Reproducible
// offline by anyone with the repo -> safe to stop shipping as
// images, because the HTML regenerates the picture.
// city posters need CITIES=1 BASEMAP=1 and fetch OSM STREET TILES at generation
// time. NOT reproducible offline. Deleting those .webp would make
// them unrecoverable without network, so they stay as images.
// Treating "42 MB of posters" as one homogeneous pile would have deleted the
// irreproducible half.
//
// ⚠ RUNNING THIS DOES NOT, BY ITSELF, SHRINK THE REPO. Measured with real git
// packs, for the 24 zone posters that render:
// webp 5.0 MB raw -> ~5.0 MB packed (already compressed; git cannot pack it)
// HTML 21.4 MB raw -> 5.78 MB packed (text, and highly repetitive across posters)
// Swapping one for the other is about +0.8 MB PACKED — a net loss, not the 42 MB
// win the headline suggests. Do not ship that swap and call it a saving.
//
// WHERE THE REAL 22 MB IS: 176 city-* posters, which this script deliberately does
// not touch because they need OSM tiles to regenerate. The zone posters were never
// the expensive half.
//
// So what is this FOR? Two things that are true regardless of size:
// 1. It is the missing half of the browser-rasterise fallback. That fallback
// fetches posters/<id>.html and nothing published it, so a zone whose image is
// absent 404s today. Run this and the fallback has an input.
// 2. It makes the HTML the SOURCE a reader can rasterise at any resolution,
// instead of one fixed raster — useful for a host that would rather serve text
// and let the browser do the work.
// Use it when you are dropping the images deliberately, not as a size optimisation.
//
// node publish-html.mjs # all zone posters found in out/
// ONLY=laos node publish-html.mjs # one
import { readFileSync, writeFileSync, mkdirSync, existsSync, readdirSync } from 'node:fs';
import { fileURLToPath, pathToFileURL } from 'node:url';
import { dirname, join, resolve } from 'node:path';
const __dirname = dirname(fileURLToPath(import.meta.url));
const SRC = join(__dirname, 'out', 'posters');
const REPO = resolve(__dirname, '..', '..', '..');
// ⚠ data/posters, NOT public/assets/posters. public/assets/<name> is a GENERATED
// MIRROR: generate-tree.mjs rmSync's it and re-copies data/<name> over it on every
// `bun run tree`, so anything written there is deleted by the next build. This
// script wrote there and its output therefore never survived — which is a large
// part of why the browser-rasterise fallback still had no input months after it
// shipped. data/ is the tracked source the mirror is made from.
const DEST = join(REPO, 'data', 'posters');
const ONLY = process.env.ONLY || '';
if (!existsSync(SRC)) {
console.error(`[publish-html] no ${SRC} — run \`node generate.mjs\` first.`);
process.exit(1);
}
// ★ THE ZONE IDS THE APP CAN ACTUALLY REACH — ask the LEGEND, not clusters.json.
// Enumerating the raw cluster keys is what this used to do, and it is wrong:
// clusters.json carries 30 list definitions but the legend exposes 28 leaves.
// deaf-old-world and deaf-new-world-asia-pacific are SUPERSEDED by the unified
// `deaf` row (affinity bloc A017) — verified against live data, 63 + 60 = 123 =
// A017 exactly, with zero groups on either side of the difference. They are kept
// in clusters.json deliberately (three isolation-proof scripts assert
// deaf_lists.length === 2), but no user can select them, so a poster for them is
// unreachable. Enumerating them made this script warn that two zones were
// "MISSING" when nothing was missing at all.
const clusters = JSON.parse(readFileSync(
join(REPO, 'plugin-designs', 'my-upg-100-list', 'data', 'clusters.json'), 'utf8'));
const { buildLegendNodes, leafZones } = await import(
pathToFileURL(join(REPO, 'plugin-designs', 'my-upg-100-list', 'data', 'legend-tree.js')).href);
const zoneIds = new Set(leafZones(buildLegendNodes(clusters, { records: null })).map((z) => z.id));
mkdirSync(DEST, { recursive: true });
let published = 0;
let bytes = 0;
const skipped = [];
for (const file of readdirSync(SRC)) {
if (!file.endsWith('.html')) continue;
const id = file.slice(0, -5);
if (ONLY && id !== ONLY) continue;
if (!zoneIds.has(id)) { skipped.push(id); continue; }
const html = readFileSync(join(SRC, file), 'utf8');
writeFileSync(join(DEST, file), html);
published += 1;
bytes += Buffer.byteLength(html);
}
console.log(`[publish-html] ${published} poster HTML -> data/posters/`);
console.log(`[publish-html] ${(bytes / 1048576).toFixed(1)} MB raw `
+ `(gzips to roughly a third over the wire)`);
if (skipped.length) {
// Loud, not silent: a reader should know what was left behind and why.
console.log(`[publish-html] skipped ${skipped.length} non-zone poster(s) `
+ `(city-* posters belong to Key Cities and need OSM tiles to regenerate)`);
}
const missing = [...zoneIds].filter((z) => !existsSync(join(SRC, `${z}.html`)));
if (missing.length && !ONLY) {
console.warn(`[publish-html] ⚠ ${missing.length} zone(s) have NO poster HTML: `
+ `${missing.slice(0, 6).join(', ')}${missing.length > 6 ? '…' : ''}`);
console.warn('[publish-html] their download will 404 until generate.mjs covers them.');
}