#!/usr/bin/env node /** * readme-shots.mjs — capture the README screenshot family from the BUILT tree. * * Serves public/ on a local port (same static server shape as smoke.mjs), then * drives headless chromium to screenshot each capability + deploy surface. * A live Mapbox token is fetched from https://doxa.life/api/maps/token and passed * as ?tk= so maps fully paint (network is up). Token-free surfaces (vision-card, * template) render regardless. * * Output: documentation/readme-images/*.png */ import { chromium } from './lib/playwright-guarded.mjs' import { createServer } from 'node:http' import { readFile, stat, mkdir, writeFile } from 'node:fs/promises' import { createHash } from 'node:crypto' import { existsSync, readFileSync } from 'node:fs' import { join, extname, resolve, dirname } from 'node:path' import { fileURLToPath } from 'node:url' import { TREE_NAME, TREE_NAME_RE } from './_tree-name.mjs' const HERE = dirname(fileURLToPath(import.meta.url)) const ROOT = resolve(HERE, '..', 'public') const OUT = resolve(HERE, '..', 'documentation', 'readme-images') await mkdir(OUT, { recursive: true }) const TYPES = { '.html':'text/html', '.js':'text/javascript', '.mjs':'text/javascript', '.json':'application/json', '.css':'text/css', '.webp':'image/webp', '.png':'image/png', '.svg':'image/svg+xml', '.geojson':'application/json', '.csv':'text/csv', '.woff2':'font/woff2' } // In-memory pages (e.g. the iframe host demo). Served without touching public/, so no agentic // scratch lands in the studio tree and nothing is lost to the next build's clean. const virtualPages = new Map() const server = createServer(async (req, res) => { try { const reqPath = decodeURIComponent(req.url.split('?')[0]) if (virtualPages.has(reqPath)) { res.writeHead(200, { 'content-type': 'text/html' }) return res.end(virtualPages.get(reqPath)) } let p = join(ROOT, reqPath) if ((await stat(p)).isDirectory()) p = join(p, 'index.html') res.writeHead(200, { 'content-type': TYPES[extname(p)] || 'application/octet-stream' }) res.end(await readFile(p)) } catch { res.writeHead(404).end('not found') } }) await new Promise(r => server.listen(0, '127.0.0.1', r)) const BASE = `http://127.0.0.1:${server.address().port}` // ★ SHOOT WITH THE TOKEN THE BUNDLES ACTUALLY SHIP, not one fetched over the network. // // This used to fetch https://doxa.life/api/maps/token unconditionally. That token belongs to the // Mapbox account that is URL-restricted to doxa.life, which is why the shot harness had // to spoof a Referer to make tiles load at all. Meanwhile the BUILT BUNDLES carry a different // token — baked in at build time from vite.doxa-maps-build-config.json — which is not // URL-restricted. Measured 2026-09-23 with no Referer at all: mapbox-streets-v8, // country-boundaries-v1 and bathymetry-v2 all return 200 on the baked token and 403 on the fetched // one. So the harness was doing extra work to use the weaker of the two. // // Two reasons to prefer the baked token beyond it simply working. A README image is evidence about // the SHIPPED artifact, so it should render with the credential the artifact ships with; rendering // with a different token means the picture no longer proves what a reader downloads. And reading // the build config is local and deterministic — the network path degrades silently, setting TK to // '' when doxa.life is slow, after which every shot is taken token-limited and still written to // disk. The fetch is kept only as a fallback for a tree whose build config has no baked token. // ★ TOKENLESS BY DEFAULT (2026-09-26). The engine default is now 'auto': no token means // MapLibre, which is what every keyless partner embed shows. So the README pictures are taken with // NO token, showing the look a reader gets by default. SHOTS_WITH_TOKEN=1 restores the Mapbox shots // (the token ladder below). const WITH_TOKEN = process.env.SHOTS_WITH_TOKEN === '1' let TK = '' let TK_SRC = '' if (WITH_TOKEN) try { const cfg = JSON.parse(await readFile(new URL('../vite.doxa-maps-build-config.json', import.meta.url), 'utf8')) if (typeof cfg.token === 'string' && cfg.token.startsWith('pk.')) { TK = cfg.token; TK_SRC = 'build config (the token the bundles ship)' } } catch {} if (WITH_TOKEN && !TK) { try { const r = await fetch('https://doxa.life/api/maps/token', { signal: AbortSignal.timeout(8000) }) TK = r.ok ? ((await r.json()).token || '') : '' if (TK) TK_SRC = 'doxa.life/api/maps/token (fallback — needs the Referer header below)' } catch {} } console.log(TK ? `token: ${TK.length} chars from ${TK_SRC}` : (WITH_TOKEN ? 'token: NONE — maps token-limited' : 'token: none, by design (engine auto -> MapLibre); SHOTS_WITH_TOKEN=1 for Mapbox')) // ★ SwiftShader — WITHOUT THESE ARGS EVERY MAP SHOT IS TAKEN WITH NO GL CONTEXT. // This script was written under the belief "this VPS has no WebGL", which was a LAUNCH-FLAG // property, not a machine property. A bare chromium.launch() yields no WebGL, so mapbox-gl // never paints and the README documents blank maps. Same flags as internal/verify-map-paint.mjs. const browser = await chromium.launch({ args: [ '--use-gl=swiftshader', '--enable-unsafe-swiftshader', '--ignore-gpu-blocklist', '--no-sandbox', ] }) // ★ A SHOT THAT FAILS MUST NOT SILENTLY PRODUCE A README IMAGE. Collected and asserted at the end. const failures = [] // documentation/readme-images/shot-bundles.json: shot name -> the sha256 of the bundle it was shot // from. Merged, so an ONLY= run updates just the shots it took. const SHOT_BUNDLES = resolve(HERE, '..', 'documentation', 'readme-images', 'shot-bundles.json') const shotBundles = existsSync(SHOT_BUNDLES) ? JSON.parse(readFileSync(SHOT_BUNDLES, 'utf8')) : {} const ONLY = process.env.ONLY ? process.env.ONLY.split(',') : null // mapbox canvas selector to wait on (leaf pages mount a custom element with a canvas) const CANVAS = 'canvas' // Readiness floor. Declared BEFORE shot() because it is a default parameter value; leaving it // below the function relied on shot() not being CALLED until later, which is true today but is a // trap for anyone who adds a call higher up. // A login wall renders ~67 elements; a real plugin leaf renders hundreds. Per-shot overrides via // `minElements` (cap-1 is legitimately 34) and `countFrames` (iframe hosts) handle the outliers. const MIN_ELEMENTS = Number(process.env.MIN_ELEMENTS || 40) // path is relative to the tree root (public/). tk appended unless tokenFree. async function shot(name, path, { vp = { width: 1440, height: 900 }, wait = 6000, tokenFree = false, waitFor = null, before = null, fullPage = false, clip = null, dsf = 2, minElements = MIN_ELEMENTS, countFrames = false, // Opt in to the doxa.life Referer (see the note in the body). Defaults ON because most shots are // MAP LEAVES, which fetch Mapbox tiles directly and render blank without it. // // ★ THE RULE, so the next author does not rediscover it one shot at a time: pass FALSE for any // surface that nests content in an `) // Viewport trimmed to the host page's actual content height (32px pad + heading/lede + a 520px // frame + 32px pad). At 1440x900 the shot carried ~330px of dead background below the card. // mapboxReferer:false — this is the ONE shot that must not carry it. The header is context-wide // (route interception does not work for Referer; see the note in shot()), so it would stamp the // local 127.0.0.1 requests too and the embedded frame stops loading: measured 187 elements -> 15 // in a full run, and 180 -> 8 reproduced in isolation. This surface needs its LOCAL frame, and it // loads no Mapbox tiles of its own — the map inside the iframe is its own document. await shot('deploy1-iframe', '/__readme-iframe-host.html', { vp: { width: 1000, height: 700 }, wait: 11000, countFrames: true, mapboxReferer: false }) // ---- THE 7 PRODUCTION MAPS: one shot each, for the README's opening gallery ---- // Each is the plugin's own standalone leaf page — the same element the Research Gallery nests. // (UPG-100 List and Prayer Progress reuse cap4-upg100-mfe and cap2-prayer.) await shot('prod-big-picture', `/${TREE_NAME}/dashboard-plus-grid/dashboard-plus-grid/index.html`, { wait: 10000, minElements: 300 }) await shot('prod-simple-research', `/${TREE_NAME}/research-map-plus-grid/research-map-plus-grid/index.html`, { wait: 10000, minElements: 200 }) await shot('prod-regions', `/${TREE_NAME}/regions-dashboard/regions-mfe/index.html`, { wait: 10000, minElements: 100 }) await shot('prod-cities-internet', `/${TREE_NAME}/cities-internet/connectivity-map/index.html`, { wait: 10000, minElements: 60 }) await shot('prod-gospel-resources', `/${TREE_NAME}/gospel-resources/resources-map/index.html`, { wait: 10000, minElements: 100 }) // ---- THE PRODUCTION MAPS ON A PHONE (390x844) — for the README's phone row ---- // Big Picture is desktop-only by ruling (on phones the Research Gallery disables it and Simple Research is // the dashboard), so it has no phone shot. UPG-100's phone shot is deploy2-mobile-upg100. await shot('prod-simple-research-phone', `/${TREE_NAME}/research-map-plus-grid/research-map-plus-grid/index.html`, { vp: { width: 390, height: 844 }, dsf: 2, wait: 10000, minElements: 60 }) await shot('prod-research-gallery-phone', `/${TREE_NAME}/doxa-maps-spa/doxa-maps-spa/index.html`, { vp: { width: 390, height: 844 }, dsf: 2, wait: 10000, minElements: 60 }) await shot('prod-regions-phone', `/${TREE_NAME}/regions-dashboard/regions-mfe/index.html`, { vp: { width: 390, height: 844 }, dsf: 2, wait: 10000, minElements: 60 }) await shot('prod-prayer-phone', `/${TREE_NAME}/doxa-simple-map/doxa-simple-map/prayer/index.html`, { vp: { width: 390, height: 844 }, dsf: 2, wait: 10000, minElements: 60 }) await shot('prod-cities-internet-phone', `/${TREE_NAME}/cities-internet/connectivity-map/index.html`, { vp: { width: 390, height: 844 }, dsf: 2, wait: 10000, minElements: 60 }) await shot('prod-gospel-resources-phone', `/${TREE_NAME}/gospel-resources/resources-map/index.html`, { vp: { width: 390, height: 844 }, dsf: 2, wait: 10000, minElements: 60 }) // ---- EXPERIMENTAL SHELF: the plugins the README pictures there ---- await shot('exp-wall-of-people-groups', `/${TREE_NAME}/wall-of-people-groups/wall-of-people-groups/index.html`, { wait: 12000, minElements: 40 }) await shot('exp-prayer-walk-planner', `/${TREE_NAME}/prayer-walk-planner/prayer-walk-planner/index.html`, { wait: 12000, minElements: 40 }) // ---- LANGUAGES: UPG-100 in Arabic (?lang=ar), right-to-left — the most completely translated map ---- await shot('lang-arabic-upg100', `/${TREE_NAME}/my-upg-100-list/upg-100-list/index.html?lang=ar`, { wait: 20000, minElements: 200 }) // same leaf, same paint time: see cap4-upg100-list // ---- DEPLOY2: mobile — the UPG-100 list as the phone app ---- // The mobile example is UPG-100 (ruled 2026-09-24; Simple Research was retired as the example because // it was never built for phones). Shot once its owner marked the phone layout ready at 88a566f: // posters one per row, the legend a bottom sheet flush with the map, print options wrapping. await shot('deploy2-mobile-upg100', `/${TREE_NAME}/my-upg-100-list/upg-100-mfe/index.html`, { vp: { width: 390, height: 844 }, wait: 10000, dsf: 3 }) // ---- DEPLOY3: JS embed — the Research Gallery on a real host site ---- // The whole cap-5 gallery JS-embedded on dx-life's research page (a