diff --git a/internal/check-posters-exportable.mjs b/internal/check-posters-exportable.mjs new file mode 100644 index 000000000..e65b156a0 --- /dev/null +++ b/internal/check-posters-exportable.mjs @@ -0,0 +1,69 @@ +#!/usr/bin/env node +// check-posters-exportable — every poster a reader can ask for ships in a printable form. +// +// ★ THE DEFECT (2026-09-29). With posterSources on, the tree shipped only the 28 zone sources. +// The 176 city-* and 174 country-* posters have no source, so their export said "poster not +// found (404)": 350 of 378 posters could not be printed. generate-tree now ships each poster +// ONCE in its lightest printable form, the .html source where one exists, else the .webp. +// This gate keeps it that way. +// +// With posterSources or bundlePosters on, every poster id (from data/posters, and every wall +// card in the tree's previews/) must have, in /assets/posters: +// .webp that really is a WebP (RIFF....WEBP), or +// .html that really is a poster source (its `class="stage"` element), +// so a placeholder or a host's fallback page cannot pass for one. +// With both off, posters are not shipped on purpose and export refuses honestly: SKIP +// (check:poster-degrades covers that case). +// +// Static: reads files, starts nothing. Exit 0 pass or skip, 1 fail. +import { existsSync, openSync, readSync, closeSync, readFileSync, readdirSync } from 'node:fs' +import { dirname, join, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' +import { TREE_NAME } from './_tree-name.mjs' + +const REPO = resolve(dirname(fileURLToPath(import.meta.url)), '..') +const cfg = JSON.parse(readFileSync(join(REPO, 'vite.doxa-maps-build-config.json'), 'utf8')) +if (cfg.posterSources !== true && cfg.bundlePosters !== true) { + console.log('POSTERS-EXPORTABLE: SKIP — posterSources and bundlePosters are off, so posters are not shipped (export refuses honestly)') + process.exit(0) +} + +const assets = join(REPO, 'public', TREE_NAME, 'assets') +const posters = join(assets, 'posters') +if (!existsSync(posters)) { + console.error(`POSTERS-EXPORTABLE: FAIL — ${posters} is missing; run \`bun run tree\``) + process.exit(1) +} + +const stem = (f) => f.replace(/\.(webp|html)$/, '') +const ids = new Set(readdirSync(join(REPO, 'data', 'posters')).filter((f) => /\.(webp|html)$/.test(f)).map(stem)) +const previews = join(assets, 'previews') +if (existsSync(previews)) for (const f of readdirSync(previews)) if (f.endsWith('.webp')) ids.add(stem(f)) + +const isWebp = (path) => { + const fd = openSync(path, 'r') + const b = Buffer.alloc(12) + try { readSync(fd, b, 0, 12, 0) } finally { closeSync(fd) } + return b.toString('latin1', 0, 4) === 'RIFF' && b.toString('latin1', 8, 12) === 'WEBP' +} +const isSource = (path) => readFileSync(path, 'utf8').includes('class="stage"') + +const missing = [] +const bad = [] +let webp = 0 +let html = 0 +for (const id of [...ids].sort()) { + const w = join(posters, `${id}.webp`) + const h = join(posters, `${id}.html`) + if (existsSync(w)) { if (isWebp(w)) { webp += 1; continue } bad.push(`${id}.webp is not a WebP image`) } + if (existsSync(h)) { if (isSource(h)) { html += 1; continue } bad.push(`${id}.html is not a poster source (no class="stage")`) } + if (!existsSync(w) && !existsSync(h)) missing.push(id) +} + +if (missing.length || bad.length) { + console.error(`POSTERS-EXPORTABLE: FAIL — ${missing.length} poster(s) with nothing printable, ${bad.length} not what they claim, of ${ids.size}`) + for (const id of missing.slice(0, 20)) console.error(` missing ${id} (neither .webp nor .html in ${TREE_NAME}/assets/posters)`) + for (const b of bad.slice(0, 20)) console.error(` bad ${b}`) + process.exit(1) +} +console.log(`POSTERS-EXPORTABLE: PASS — all ${ids.size} posters print: ${html} from their source (.html), ${webp} from a raster (.webp)`) diff --git a/internal/generate-tree.mjs b/internal/generate-tree.mjs index f41b04603..4b5b26261 100644 --- a/internal/generate-tree.mjs +++ b/internal/generate-tree.mjs @@ -108,10 +108,11 @@ function readBuildConfig() { // from previews/, so the tree stays visually complete without them; bundling both pushed the // tree to 74 MB and the catalog server refused the pack. Turn on only for a fully offline drop. const bundlePosters = cfg.bundlePosters === true - // Ship the poster SOURCES (.html, what the browser rasterises) without the - // 40.9 MB of rasters. bundlePosters is a single boolean over a directory holding - // both, so it could not express this — and "neither" is what left export and print - // preview 404ing on a default drop. Implied by bundlePosters:true. + // Ship every poster ONCE, in its lightest printable form: its SOURCE (.html, which the + // browser rasterises) if it has one, else its raster (.webp). bundlePosters is a single + // boolean over a directory holding both, so it could not express this — and "neither" is + // what left export and print preview 404ing on a default drop. Implied by bundlePosters:true + // (which ships both forms of every poster). const posterSources = cfg.posterSources === true || bundlePosters return { name, paths, embedBaseUrl, tokenUrl, token, assetsBaseUrl, apiBaseUrl, gatedProfiles, qaWidget, bundlePosters, posterSources } } catch { @@ -1833,9 +1834,16 @@ function main() { // which is the only combination the old boolean could not express. // The code default stays FALSE (real weight is a per-deployment decision); the dx-maps config // turns it ON, because the prayer packet's map page fetches these and printed blank without them. - // The copies are byte-identical to the tracked data/posters/*.html, so git stores each once. + // ★ SOURCES ALONE LEFT 350 OF 378 POSTERS UNPRINTABLE (2026-09-29). Only the 28 zone posters + // have a source; the 176 city-* need OSM street tiles to regenerate (not reproducible offline, + // see library/scripts/posters/publish-html.mjs) and the 174 country-* have none, so their + // export said "poster not found (404)". So posterSources ships each poster ONCE: the source + // where one exists (28, ~28 MB), the raster for the rest (350, ~37 MB). HTML is the heavy form + // (~1 MB per source vs ~0.1 MB per raster), so generating sources for the 350 would have + // multiplied the weight; and the rasters are the tracked data/posters/*.webp, byte-identical, + // so git stores each once and the published tree adds ~0 new pack bytes. const posterExts = DEST_CONFIG.bundlePosters ? null // everything - : (DEST_CONFIG.posterSources ? ['.html'] : []) // sources only / nothing + : (DEST_CONFIG.posterSources ? ['.html', '.webp'] : []) // lightest form each / nothing const assetDirs = (posterExts === null || posterExts.length) ? ['previews', 'posters', 'fonts', 'images'] : ['previews', 'fonts', 'images'] @@ -1846,8 +1854,12 @@ function main() { // Selective copy: only the extensions this deployment asked for. mkdirSync(join(treeAssets, dirName), { recursive: true }) let n = 0 - for (const f of readdirSync(src)) { + const files = readdirSync(src) + // A poster with a source never also ships its raster (lightest printable form). + const hasSource = new Set(files.filter((f) => f.endsWith('.html')).map((f) => f.slice(0, -'.html'.length))) + for (const f of files) { if (!posterExts.some((e) => f.endsWith(e))) continue + if (f.endsWith('.webp') && hasSource.has(f.slice(0, -'.webp'.length))) continue cpSync(join(src, f), join(treeAssets, dirName, f)) n += 1 } @@ -1860,7 +1872,7 @@ function main() { if (DEST_CONFIG.bundlePosters) { console.log('[generate-tree] posters/ bundled in full (bundlePosters:true) — rasters + sources') } else if (DEST_CONFIG.posterSources) { - console.log('[generate-tree] posters/*.html bundled (posterSources:true) — no rasters; export and print RASTERISE in the browser') + console.log('[generate-tree] posters/ bundled once each (posterSources:true) — the .html source where one exists (rasterised in the browser), else the .webp') } else { console.log('[generate-tree] posters/ NOT bundled — the wall renders from previews; export and print need posterSources:true, bundlePosters:true, or an absolute assetsBaseUrl') } diff --git a/package.json b/package.json index 18cda9ebf..ed9757bd7 100644 --- a/package.json +++ b/package.json @@ -233,6 +233,7 @@ "check:poster-degrades": "node internal/check-poster-degrades-honestly.mjs", "check:poster-layout-optin": "node internal/check-poster-layout-optin.mjs", "check:poster-print-fallback": "node internal/check-poster-print-fallback.mjs", + "check:posters-exportable": "node internal/check-posters-exportable.mjs", "check:poster-prints": "node internal/check-poster-prints.mjs", "check:poster-raster": "node internal/check-poster-raster.mjs", "check:poster-wall-localised": "node internal/check-poster-wall-localised.mjs", @@ -316,7 +317,7 @@ "dev": "bun x vite", "fix:sri": "node internal/fix-sri.mjs", "gallery": "node internal/generate-gallery.mjs", - "gates:post": "node internal/run-parallel.mjs --label=post -- check:capability-derived check:drop check:parse verify check:sri check:public-hygiene check:label-joins check:facet-cascade check:facet-one-field check:heartbeat check:kpi-unit check:composite-reg check:descriptions check:no-abs-paths check:no-codename check:dep-names check:composite-srcs check:embed-targets check:nested-token check:i18n-parity check:tabs-localized check:packets-all check:packet-strings-parity check:zone-census check:zoning-declared check:share-policy check:library-index check:csv-shape check:capability-citations check:catalog-identity check:no-orphan-gates check:quoted-scripts check:done-evidence check:script-flags check:backlog-status check:capability-exemplars check:packet-fixture check:packet-browser check:provider-switch check:locale-reachable check:url-resolver check:manifest-covers check:one-palette check:pin-dim-slider check:legend-source check:backlog-refs check:clustering-modes check:composite-embed-knobs check:composite-locale-leaks check:linked-vocabulary check:wp-embed check:dashboard-geocoder-provider check:leaf-engine-per-provider check:map-props-unit check:composites-mount check:composite-interop check:no-free-identifiers check:map-fly-reduced-motion check:mst-clustering check:list-prop-unit check:profiles-mount check:cdn-pins check:no-eval check:popup-escaping check:i18n-treeshake check:pinned-umd check:one-palette:self-test check:no-cross-plugin-imports check:no-cross-plugin-imports:self-test check:map-attribution check:studio-rail check:approx-pin-note check:embedder-keys check:provider-for check:legend-logical-insets check:legend-logical-insets:self-test check:optional-t-equivalence check:text-highlight check:religion-label-not-value check:i18n-stopped-scope check:no-silent-engine check:regions-uses-library check:shadow-css-parses check:square-map-controls check:preset-config-merge check:instance-config-keys check:frozen-labels check:plural-forms check:region-fill-repaint check:poster-print-fallback check:country-names check:reduced-motion check:no-dead-library check:universal-props", + "gates:post": "node internal/run-parallel.mjs --label=post -- check:capability-derived check:drop check:parse verify check:sri check:public-hygiene check:label-joins check:facet-cascade check:facet-one-field check:heartbeat check:kpi-unit check:composite-reg check:descriptions check:no-abs-paths check:no-codename check:dep-names check:composite-srcs check:embed-targets check:nested-token check:i18n-parity check:tabs-localized check:packets-all check:packet-strings-parity check:zone-census check:zoning-declared check:share-policy check:library-index check:csv-shape check:capability-citations check:catalog-identity check:no-orphan-gates check:quoted-scripts check:done-evidence check:script-flags check:backlog-status check:capability-exemplars check:packet-fixture check:packet-browser check:provider-switch check:locale-reachable check:url-resolver check:manifest-covers check:one-palette check:pin-dim-slider check:legend-source check:backlog-refs check:clustering-modes check:composite-embed-knobs check:composite-locale-leaks check:linked-vocabulary check:wp-embed check:dashboard-geocoder-provider check:leaf-engine-per-provider check:map-props-unit check:composites-mount check:composite-interop check:no-free-identifiers check:map-fly-reduced-motion check:mst-clustering check:list-prop-unit check:profiles-mount check:cdn-pins check:no-eval check:popup-escaping check:i18n-treeshake check:pinned-umd check:one-palette:self-test check:no-cross-plugin-imports check:no-cross-plugin-imports:self-test check:map-attribution check:studio-rail check:approx-pin-note check:embedder-keys check:provider-for check:legend-logical-insets check:legend-logical-insets:self-test check:optional-t-equivalence check:text-highlight check:religion-label-not-value check:i18n-stopped-scope check:no-silent-engine check:regions-uses-library check:shadow-css-parses check:square-map-controls check:preset-config-merge check:instance-config-keys check:frozen-labels check:plural-forms check:region-fill-repaint check:poster-print-fallback check:posters-exportable check:country-names check:reduced-motion check:no-dead-library check:universal-props", "gates:pre": "node internal/run-parallel.mjs --label=pre -- check:template-literal check:mjs-parse check:sfc-parse check:tab-radius check:synced-css check:doc-scripts check:setup-order check:package-json-unique-keys check:no-repo-pb-hooks check:no-credentials-omit check:no-credentials-omit:self-test check:shadow-reduced-motion check:poster-action-print check:targets check:targets:self-test check:browser-gates-guarded check:pin-selection check:facet-shape check:hardcoded-english:self-test check:list-selection check:selector-fields check:upg-zone-joins check:gospel-set-charts check:gospel-set-fit check:describe-group check:one-scroll-container check:packet-charts check:composite-token-url check:language-tree-layout check:i18n-keys-exist check:packet-variants check:profile-table check:element-bootstrap check:embed-url-absolute check:frame-target check:language-tree-model check:map-menu-anchor check:selection-kinds check:tiered-points check:whiteboard-projection check:mercator-slice check:ring-vocabulary check:map-props-unit check:mobile-gating check:provide-shadowing check:legend-top-offset check:cdn-cache check:plural-forms check:select-zone-race check:script-registry check:map-control-overlap check:poster-attribution check:gospel-whiteboard check:packet-map-missing-loud check:poster-layout-optin check:ported-globals-identical check:ring-bands check:packet-a1-structure check:zone-title-tier-a check:evangelical-share check:map-labels check:packet-offline check:help-reachable check:gated-not-in-tree check:window-globals-released check:legend-data-contract check:no-new-driver-references", "gen:iso3-to-alpha2": "node library/scripts/generate-iso3-to-alpha2.mjs", "internet:adm1": "node plugin-designs/cities-internet/scripts/build-adm1-internet.mjs",