public/ is one folder: the output tree plus a README, with the Plugin Studio as its front page
Some checks are pending
Deploy to GitHub Pages / build (push) Waiting to run
Deploy to GitHub Pages / deploy (push) Blocked by required conditions

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.
This commit is contained in:
doxa-maps-88655c70 2026-09-25 17:38:50 +00:00
parent 93c52cf498
commit 79bd6d2ac3
451 changed files with 286 additions and 43783 deletions

11
.gitignore vendored
View file

@ -134,3 +134,14 @@ _qa-cycle/
# Private datasets never live in this repo (see DOXA_PRIVATE_DIR in # Private datasets never live in this repo (see DOXA_PRIVATE_DIR in
# library/scripts/research/build-tabulator-tables.mjs). Guard against re-creation. # library/scripts/research/build-tabulator-tables.mjs). Guard against re-creation.
data-private/ data-private/
# ── public/ is the OUTPUT FOLDER + README.md only (Driver decision, BUILD-OUTPUT-IS-ONE-FOLDER, 2026-09-25) ──
# These rules used to live in public/.gitignore, which was itself a tracked file INSIDE the generated
# folder (so `rm -rf public && bun run build` deleted it). Local-only files stay untracked:
/public/token.local.js
# the build mutex's stamp (internal/build-lock.sh) — runtime state, now at the repo root
/.build.lock
# what the OLD layout wrote beside the tree; internal/clean.mjs removes any a checkout still has
/public/assets/
/public/__gallery/
/public/index.html

View file

@ -2,24 +2,20 @@
"appId": "life.doxa.maps", "appId": "life.doxa.maps",
"appName": "Doxa Maps", "appName": "Doxa Maps",
"//webDir": [ "//webDir": [
"webDir = ./public \u2014 THE CDN WEB-ROOT. It is a POINTER, NOT A COPY.", "webDir = public/<tree> \u2014 THE OUTPUT FOLDER, written by the build (internal/sync-app-shells.mjs keeps it",
"public/ is the single SERVED SURFACE, so the bundler IS the CDN: it serves its own bundles AND", "in step with the tree's name in vite.doxa-maps-build-config.json; do not edit it by hand).",
"non-bundle assets (brand/logos, vendored libs, fonts) that belong to no single bundle.", "public/ holds ONLY the output folder + README.md (Driver decision, BUILD-OUTPUT-IS-ONE-FOLDER). The folder is",
" public/doxa-maps/<bundle>/ built bundles (vite outDir app/ + copied here by the build config's paths)", "self-contained: its index.html is the front page (the Plugin Studio), <bundle>/ holds each plugin, and",
" public/assets/brand/ logos and brand imagery", "assets/ carries the data, fonts and images the plugins fetch.",
" public/assets/vendor/ self-hosted libs (e.g. mapbox-gl) \u2014 no CDN, per the no-CDN rule", "ONE build, THREE consumers, all reading THIS folder:",
" public/assets/fonts/ Bebas Neue / Poppins (both SIL OFL, so self-hostable)", " 1. Railway / CDN \u2014 serve it statically; partner sites embed from stable URLs",
"ONE build, THREE consumers, all reading THIS root:", " 2. Capacitor \u2014 this webDir; `cap sync` copies it into deployment/capacitor/android/app/src/main/assets/public/,",
" 1. Railway / CDN \u2014 serve public/ statically; partner sites embed from stable URLs", " then `bun run app:start-page` makes the UPG-100 list the app's start page",
" 2. Capacitor \u2014 this webDir; `cap sync` copies public/ into deployment/capacitor/android/app/src/main/assets/public/", " 3. local staging \u2014 `bun run serve` (public/ on :4173; open /<tree>/index.html)",
" 3. local staging \u2014 `bun run serve` (public/ on :4173)",
"NOTE the drop-portability gate still governs what goes INSIDE a bundle: bundle-internal refs must stay",
"RELATIVE. public/ is the HOST root, which is a different layer \u2014 putting shared assets here does not",
"make a bundle root-relative, so the gate stays green.",
"\u26a0\ufe0f SOFT FAILURE: a wrong webDir yields an EMPTY assets/public and a BLANK WHITE SCREEN at launch, not an", "\u26a0\ufe0f SOFT FAILURE: a wrong webDir yields an EMPTY assets/public and a BLANK WHITE SCREEN at launch, not an",
"error. After every sync, verify deployment/capacitor/android/app/src/main/assets/public/index.html exists (`bun run cap:verify`)." "error. After every sync, verify deployment/capacitor/android/app/src/main/assets/public/index.html exists (`bun run cap:verify`)."
], ],
"webDir": "public", "webDir": "public/doxa-maps",
"server": { "server": {
"//": [ "//": [
"Capacitor defaults are deliberately kept: iosScheme 'capacitor', androidScheme 'https', hostname", "Capacitor defaults are deliberately kept: iosScheme 'capacitor', androidScheme 'https', hostname",

View file

@ -1,16 +1,26 @@
# data/ — the bundler's source-of-truth data # data/ — the bundler's source-of-truth data
**The law: data lives here; `public/` is generated.** Every folder in `data/` is copied into the **The law: data lives here; `public/` is generated.** `public/` holds only the output folder
served web-root by the build (`internal/generate-tree.mjs`), exactly like the per-profile data in (`public/<tree>/`, named by `name` in `vite.doxa-maps-build-config.json`) and a generated README. The
`plugin-designs/*/data/`. You should be able to `rm -rf public/` and get it all back with `bun run build`. build (`internal/generate-tree.mjs`) copies what the plugins FETCH at runtime from here into
`<tree>/assets/`, alongside the per-profile data in `plugin-designs/*/data/`. You should be able to
`rm -rf public/` and get it all back with `bun run build`.
| Folder | Served at | What it is | | Folder | Served at | What it is |
|---|---|---| |---|---|---|
| `people-groups/` | `/assets/people-groups/` | People-group datasets (CSV/JSON) the maps read at runtime | | `people-groups/` | `<tree>/assets/people-groups/` (the fetched cuts only) | People-group datasets (CSV/JSON) the maps read at runtime |
| `vendor/` | `/assets/vendor/` | Third-party runtime assets (e.g. table libraries) | | `vendor/` | `<tree>/assets/vendor/` | Third-party runtime assets (e.g. table libraries) |
| `posters/` | `/assets/posters/` | Rendered poster webp set (heavy pipeline output; poster walls fetch these) | | `posters/` | `<tree>/assets/posters/` (opt-in: `bundlePosters` / `posterSources`) | Rendered poster set (heavy pipeline output; the export button fetches these) |
| `tables/` | `/assets/tables/` | The MERGED served table set (its index.json is the merge; per-profile `plugin-designs/*/data/tables` are build-time sources) | | `tables/` | `<tree>/assets/tables/` | The MERGED served table set (its index.json is the merge; per-profile `plugin-designs/*/data/tables` are build-time sources) |
| `external-bundles/` | not served | Prebuilt map IIFEs vendored from sibling projects (not built here); only the optional `layouts/` shell mounted them, so the build no longer copies them into `public/` | | `pplr/` | `<tree>/assets/pplr/` (the fetched files only) | The PPLR data maps' derived datasets |
| `fonts/` | `<tree>/assets/fonts/` | Vendored typefaces (SIL OFL), e.g. Bebas Neue for the regions clone |
| `images/` | `<tree>/assets/images/` | Images the plugins fetch (e.g. the regions list's world-map watermark; see its PROVENANCE) |
| `brand/` | not served | Logos and brand imagery (sources; the optional `library/layouts/app-wrapper-material` shell references them) |
| `legacy/` | not served | Archived standalone builds kept for reference (e.g. the original upg-neighbor page) |
| `external-bundles/` | not served | Prebuilt map IIFEs vendored from sibling projects (not built here); only the optional `layouts/` shell mounted them |
`fonts/`, `images/`, `brand/` and `legacy/` moved here from `public/assets/` on 2026-09-25 (card
BUILD-OUTPUT-IS-ONE-FOLDER): they were sources that existed only in the generated folder.
Don't edit anything under `public/` by hand — change it here (or in `plugin-designs/*/data/`, Don't edit anything under `public/` by hand — change it here (or in `plugin-designs/*/data/`,
`layouts/*/assets/`) and rebuild. `layouts/*/assets/`) and rebuild.

View file

Before

Width:  |  Height:  |  Size: 422 KiB

After

Width:  |  Height:  |  Size: 422 KiB

View file

Before

Width:  |  Height:  |  Size: 304 KiB

After

Width:  |  Height:  |  Size: 304 KiB

View file

@ -4,7 +4,7 @@
"version": "0.1.0", "version": "0.1.0",
"identifier": "life.doxa.maps", "identifier": "life.doxa.maps",
"build": { "build": {
"frontendDist": "../../public", "frontendDist": "../../public/doxa-maps",
"beforeBuildCommand": "" "beforeBuildCommand": ""
}, },
"app": { "app": {
@ -16,7 +16,7 @@
"minWidth": 900, "minWidth": 900,
"minHeight": 600, "minHeight": 600,
"resizable": true, "resizable": true,
"url": "doxa-maps/doxa-maps-spa/doxa-maps-spa/index.html" "url": "doxa-maps-spa/doxa-maps-spa/index.html"
} }
], ],
"security": { "security": {

View file

@ -21,7 +21,7 @@
* Say "contract-proven, not WP-shot" and nothing stronger. * Say "contract-proven, not WP-shot" and nothing stronger.
* *
* Usage: php wordpress/tests/wp-embed-contract-test.php (exit 0 pass / 1 fail / 2 no target) * Usage: php wordpress/tests/wp-embed-contract-test.php (exit 0 pass / 1 fail / 2 no target)
* Requires: php CLI + a BUILT tree (public/doxa-maps/manifest.json). * Requires: php CLI + a BUILT tree (public/<tree>/manifest.json; <tree> = the build config's name).
*/ */
require __DIR__.'/wp-stubs.php'; require __DIR__.'/wp-stubs.php';
$PLUGIN = dirname(__DIR__).'/doxa-maps-embed'; $PLUGIN = dirname(__DIR__).'/doxa-maps-embed';
@ -30,7 +30,13 @@ require $PLUGIN.'/includes/class-doxa-maps-embed.php';
// Read the AUTHORITATIVE contract from the built catalog manifest. // Read the AUTHORITATIVE contract from the built catalog manifest.
// dirname(__DIR__,3) — THREE levels, not two: wordpress/ moved under deployment/ on // dirname(__DIR__,3) — THREE levels, not two: wordpress/ moved under deployment/ on
// 2026-09-21, so tests/ is now deployment/wordpress/tests and the repo root is 3 up. // 2026-09-21, so tests/ is now deployment/wordpress/tests and the repo root is 3 up.
$mf = json_decode(file_get_contents(dirname(__DIR__,3).'/public/doxa-maps/manifest.json'), true); // The output folder's name comes from the build config (vite.doxa-maps-build-config.json "name"), never a literal:
// the tree is renamed by that one line, and a hardcoded 'doxa-maps' here would silently test a missing folder.
$cfg = json_decode(@file_get_contents(dirname(__DIR__,3).'/vite.doxa-maps-build-config.json'), true);
$tree = (is_array($cfg) && !empty($cfg['name']) && preg_match('/^[a-z0-9][a-z0-9._-]*$/i', $cfg['name'])) ? $cfg['name'] : 'doxa-maps';
$mfPath = dirname(__DIR__,3).'/public/'.$tree.'/manifest.json';
if(!is_file($mfPath)){ fwrite(STDERR,"no built tree at public/$tree/manifest.json -- build first (this gate runs in gates:post)\n"); exit(2); }
$mf = json_decode(file_get_contents($mfPath), true);
$bundles = $mf['bundles']; $bundles = $mf['bundles'];
$target = null; $target = null;
foreach($bundles as $b){ foreach(($b['profiles']??[]) as $p){ if(!empty($p['element']) && !empty($p['integrity'])){ $target=['bundle'=>$b['name']??$b['bundle'],'profile'=>$p,'b'=>$b]; break 2; } } } foreach($bundles as $b){ foreach(($b['profiles']??[]) as $p){ if(!empty($p['element']) && !empty($p['integrity'])){ $target=['bundle'=>$b['name']??$b['bundle'],'profile'=>$p,'b'=>$b]; break 2; } } }

View file

@ -47,9 +47,9 @@ The scripts these commands are grounded in (from this branch's `package.json`):
// claiming to be ground truth, and this one had silently stopped being it. // claiming to be ground truth, and this one had silently stopped being it.
"dev": "bun x vite", "dev": "bun x vite",
"build": "bash internal/build-lock.sh", "build": "bash internal/build-lock.sh",
"build:unlocked": "bun run gates:pre && bun run library-index && bun run check:library-index && bun run clean && bun run token && bun run build:bundles && bun run tree && bun run gates:post && bun run gallery", "build:unlocked": "bun run gates:pre && bun run library-index && bun run check:library-index && bun run clean && bun run token && bun run build:bundles && bun run tree && bun run gates:post",
"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:wp-embed check:doc-scripts", "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",
"gates:post": "node internal/run-parallel.mjs --label=post -- 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: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:legend-source check:backlog-refs check:clustering-modes check:composite-embed-knobs check:composite-locale-leaks check:linked-vocabulary", "gates:post": "node internal/run-parallel.mjs --label=post -- 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: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:legend-source check:backlog-refs check:clustering-modes check:composite-embed-knobs check:composite-locale-leaks check:linked-vocabulary check:wp-embed",
"build:bundles": "node internal/run-parallel.mjs --bundles", // the 22 vite builds, 4 at a time "build:bundles": "node internal/run-parallel.mjs --bundles", // the 22 vite builds, 4 at a time
"preview": "bun x vite preview", "preview": "bun x vite preview",
"serve": "node internal/serve-strict.mjs", // 404s a directory, like production "serve": "node internal/serve-strict.mjs", // 404s a directory, like production

View file

@ -13,7 +13,7 @@
> bundle `parameterized` (cap-2) no matter how light it looks, and a `meta.json` `type` > bundle `parameterized` (cap-2) no matter how light it looks, and a `meta.json` `type`
> declares tiers 3/4/5. > declares tiers 3/4/5.
Generated from 21 bundle(s). Generated from 23 bundle(s).
### One canonical exemplar per tier (generated — quote these) ### One canonical exemplar per tier (generated — quote these)
@ -21,7 +21,7 @@ Generated from 21 bundle(s).
|---|---|---| |---|---|---|
| 1 — single-element | `single` | `all-people-groups` | | 1 — single-element | `single` | `all-people-groups` |
| 2 — preset-element (prop-table) | `parameterized` | `doxa-simple-map` | | 2 — preset-element (prop-table) | `parameterized` | `doxa-simple-map` |
| 3 — multi-view-element (monolithic) | `monolithic` | `research-dashboard` | | 3 — multi-view-element (monolithic) | `monolithic` | `pplr-data-maps` |
| 4 — composite-element (nested-element SPA) | `nested-elements-mfe` | `big-picture` | | 4 — composite-element (nested-element SPA) | `nested-elements-mfe` | `big-picture` |
| 5 — app-shell (nested-SPA) | `nested-mfe-spa` | `doxa-maps-spa` | | 5 — app-shell (nested-SPA) | `nested-mfe-spa` | `doxa-maps-spa` |
@ -67,8 +67,10 @@ Generated from 21 bundle(s).
| Bundle | Profile | Machine key | | Bundle | Profile | Machine key |
|---|---|---| |---|---|---|
| `doxa-research-map` | `research-map` | `monolithic` | | `doxa-research-map` | `research-map` | `monolithic` |
| `pplr-data-maps` | `pplr-data-maps` | `monolithic` |
| `research-dashboard` | `research-dashboard` | `monolithic` | | `research-dashboard` | `research-dashboard` | `monolithic` |
| `upg-neighbor` | `upg-neighbor` | `monolithic` | | `upg-neighbor` | `upg-neighbor` | `monolithic` |
| `wall-of-people-groups` | `wall-of-people-groups` | `monolithic` |
## Capability 4 — composite-element (nested-element SPA) ## Capability 4 — composite-element (nested-element SPA)

View file

@ -12,9 +12,9 @@
# half-tree): # half-tree):
# 1. rename public/doxa-maps -> public/doxa-maps.old (instant; old still fully valid) # 1. rename public/doxa-maps -> public/doxa-maps.old (instant; old still fully valid)
# 2. rename STAGE/doxa-maps -> public/doxa-maps (instant; new fully valid, built+verified) # 2. rename STAGE/doxa-maps -> public/doxa-maps (instant; new fully valid, built+verified)
# 3. cp STAGE/index.html -> public/index.html (root gallery page) # 3. cp STAGE/README.md -> public/README.md (the one other entry public/ holds)
# 4. rm -rf public/doxa-maps.old # 4. rm -rf public/doxa-maps.old
# public/assets/, public/.gitignore, and public/.build.lock are NEVER touched — the flock handle stays valid. # public/api/ (write-token) is NEVER touched — the flock handle stays valid.
# #
# This is the LOCAL form of the atomic-publish discipline; the catalog MERGE (into platform/mfe-index) has its # This is the LOCAL form of the atomic-publish discipline; the catalog MERGE (into platform/mfe-index) has its
# own hard-gate runbook. This script only makes the LOCAL public/ swap safe. # own hard-gate runbook. This script only makes the LOCAL public/ swap safe.
@ -22,7 +22,7 @@
# USAGE: # USAGE:
# internal/atomic-public-swap.sh <STAGE_DIR> # internal/atomic-public-swap.sh <STAGE_DIR>
# <STAGE_DIR> must contain a fully-built <STAGE_DIR>/doxa-maps/ tree (with manifest.json) and an # <STAGE_DIR> must contain a fully-built <STAGE_DIR>/doxa-maps/ tree (with manifest.json) and an
# <STAGE_DIR>/index.html. Typically produced by a build with BUILD_STAGE_DIR pointed here. # <STAGE_DIR>/README.md. Typically produced by a build with BUILD_STAGE_DIR pointed here.
# Env: # Env:
# PUBLIC_DIR (default: public) the served web-root to swap into # PUBLIC_DIR (default: public) the served web-root to swap into
# DRY_RUN=1 print the plan, make no changes # DRY_RUN=1 print the plan, make no changes
@ -39,18 +39,18 @@ die() { echo "❌ atomic-public-swap: $*" >&2; exit 1; }
TREE="$(node "$(dirname "$0")/_tree-name.mjs")" || die "could not read the tree name (internal/_tree-name.mjs)" TREE="$(node "$(dirname "$0")/_tree-name.mjs")" || die "could not read the tree name (internal/_tree-name.mjs)"
[ -n "$TREE" ] || die "empty tree name from internal/_tree-name.mjs" [ -n "$TREE" ] || die "empty tree name from internal/_tree-name.mjs"
[ -n "$STAGE_DIR" ] || die "usage: atomic-public-swap.sh <STAGE_DIR> (dir holding a built $TREE/ + index.html)" [ -n "$STAGE_DIR" ] || die "usage: atomic-public-swap.sh <STAGE_DIR> (dir holding a built $TREE/ + README.md)"
[ -d "$STAGE_DIR" ] || die "STAGE_DIR '$STAGE_DIR' is not a directory" [ -d "$STAGE_DIR" ] || die "STAGE_DIR '$STAGE_DIR' is not a directory"
[ -d "$PUBLIC_DIR" ] || die "PUBLIC_DIR '$PUBLIC_DIR' is not a directory (run from the studio root)" [ -d "$PUBLIC_DIR" ] || die "PUBLIC_DIR '$PUBLIC_DIR' is not a directory (run from the studio root)"
STAGE_TREE="$STAGE_DIR/$TREE" STAGE_TREE="$STAGE_DIR/$TREE"
STAGE_INDEX="$STAGE_DIR/index.html" STAGE_README="$STAGE_DIR/README.md" # public/ holds ONLY the output folder + README.md (BUILD-OUTPUT-IS-ONE-FOLDER)
# --- GATE: the stage must be a COMPLETE, verified tree before we touch the live web-root --------------------- # --- GATE: the stage must be a COMPLETE, verified tree before we touch the live web-root ---------------------
# (This is the same "verify is a GATE that aborts, never chained after the destructive op" lesson.) # (This is the same "verify is a GATE that aborts, never chained after the destructive op" lesson.)
[ -d "$STAGE_TREE" ] || die "stage tree missing: $STAGE_TREE" [ -d "$STAGE_TREE" ] || die "stage tree missing: $STAGE_TREE"
[ -f "$STAGE_TREE/manifest.json" ] || die "stage tree has no manifest.json — refusing to swap an incomplete build" [ -f "$STAGE_TREE/manifest.json" ] || die "stage tree has no manifest.json — refusing to swap an incomplete build"
[ -f "$STAGE_INDEX" ] || die "stage index.html missing: $STAGE_INDEX" [ -f "$STAGE_DIR/$TREE/index.html" ] || die "stage front page missing: $STAGE_DIR/$TREE/index.html"
# a complete tree has >=15 bundle dirs (the known catalog size floor); guard against a mid-churn/empty stage. # a complete tree has >=15 bundle dirs (the known catalog size floor); guard against a mid-churn/empty stage.
tree_dirs=$(find "$STAGE_TREE" -mindepth 1 -maxdepth 1 -type d | wc -l | tr -d ' ') tree_dirs=$(find "$STAGE_TREE" -mindepth 1 -maxdepth 1 -type d | wc -l | tr -d ' ')
@ -79,7 +79,7 @@ PY
) )
[ -z "$missing" ] || die "stage bundles missing a .js: $missing — refusing to swap a broken tree" [ -z "$missing" ] || die "stage bundles missing a .js: $missing — refusing to swap a broken tree"
echo "✅ stage verified: $tree_dirs bundle dirs + manifest.json + index.html, all bundles resolve." echo "✅ stage verified: $tree_dirs bundle dirs + manifest.json + the front page, all bundles resolve."
# --- GUARD: stage and public must be on the SAME filesystem for rename() to be atomic ------------------------ # --- GUARD: stage and public must be on the SAME filesystem for rename() to be atomic ------------------------
stage_dev=$(stat -c '%d' "$STAGE_DIR") stage_dev=$(stat -c '%d' "$STAGE_DIR")
@ -92,7 +92,7 @@ if [ "$DRY_RUN" = "1" ]; then
echo "DRY_RUN — plan (no changes):" echo "DRY_RUN — plan (no changes):"
echo " 1. mv $PUBLIC_DIR/$TREE -> $OLD_TREE" echo " 1. mv $PUBLIC_DIR/$TREE -> $OLD_TREE"
echo " 2. mv $STAGE_TREE -> $PUBLIC_DIR/$TREE" echo " 2. mv $STAGE_TREE -> $PUBLIC_DIR/$TREE"
echo " 3. cp $STAGE_INDEX -> $PUBLIC_DIR/index.html" echo " 3. cp $STAGE_README -> $PUBLIC_DIR/README.md (if present)"
echo " 4. rm -rf $OLD_TREE" echo " 4. rm -rf $OLD_TREE"
echo " (NEVER touched: $PUBLIC_DIR/assets/ $PUBLIC_DIR/.gitignore $PUBLIC_DIR/.build.lock)" echo " (NEVER touched: $PUBLIC_DIR/assets/ $PUBLIC_DIR/.gitignore $PUBLIC_DIR/.build.lock)"
exit 0 exit 0
@ -103,7 +103,7 @@ if [ -d "$PUBLIC_DIR/$TREE" ]; then
mv "$PUBLIC_DIR/$TREE" "$OLD_TREE" # 1. instant; old tree still fully valid under new name mv "$PUBLIC_DIR/$TREE" "$OLD_TREE" # 1. instant; old tree still fully valid under new name
fi fi
mv "$STAGE_TREE" "$PUBLIC_DIR/$TREE" # 2. instant; new tree (built+verified) becomes live mv "$STAGE_TREE" "$PUBLIC_DIR/$TREE" # 2. instant; new tree (built+verified) becomes live
cp "$STAGE_INDEX" "$PUBLIC_DIR/index.html" # 3. fresh root gallery page [ -f "$STAGE_README" ] && cp "$STAGE_README" "$PUBLIC_DIR/README.md" # 3. the generated README
rm -rf "$OLD_TREE" # 4. drop the superseded tree rm -rf "$OLD_TREE" # 4. drop the superseded tree
echo "🔀 atomic swap complete — $PUBLIC_DIR/$TREE is the freshly-built tree; assets/ + .build.lock untouched." echo "🔀 atomic swap complete — $PUBLIC_DIR/$TREE is the freshly-built tree; assets/ + .build.lock untouched."

View file

@ -15,7 +15,8 @@
# BUILD_LOCK_WAIT=<seconds> (default 0 = fail-fast/nonblock; set e.g. 300 to QUEUE behind a running build). # BUILD_LOCK_WAIT=<seconds> (default 0 = fail-fast/nonblock; set e.g. 300 to QUEUE behind a running build).
set -euo pipefail set -euo pipefail
LOCK_FILE="${BUILD_LOCK_FILE:-public/.build.lock}" # At the repo root, NOT in public/: public/ holds only the output folder + README.md (BUILD-OUTPUT-IS-ONE-FOLDER).
LOCK_FILE="${BUILD_LOCK_FILE:-.build.lock}"
WAIT="${BUILD_LOCK_WAIT:-0}" WAIT="${BUILD_LOCK_WAIT:-0}"
mkdir -p "$(dirname "$LOCK_FILE")" mkdir -p "$(dirname "$LOCK_FILE")"

View file

@ -35,10 +35,13 @@
import { existsSync, statSync, readdirSync } from 'node:fs' import { existsSync, statSync, readdirSync } from 'node:fs'
import { resolve, dirname, join } from 'node:path' import { resolve, dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url' import { fileURLToPath } from 'node:url'
import { TREE_NAME } from './_tree-name.mjs'
const HERE = dirname(fileURLToPath(import.meta.url)) const HERE = dirname(fileURLToPath(import.meta.url))
const REPO = process.env.DOXA_REPO || resolve(HERE, '..') const REPO = process.env.DOXA_REPO || resolve(HERE, '..')
const ASSETS = resolve(REPO, 'public/assets') // The published weight is the OUTPUT FOLDER's assets (public/ holds only the output folder since
// BUILD-OUTPUT-IS-ONE-FOLDER; the old web-root public/assets is gone).
const ASSETS = resolve(REPO, `public/${TREE_NAME}/assets`)
if (!existsSync(ASSETS)) { if (!existsSync(ASSETS)) {
console.log(`TREE-WEIGHT: SKIP — no asset tree at ${ASSETS}`) console.log(`TREE-WEIGHT: SKIP — no asset tree at ${ASSETS}`)
@ -74,7 +77,7 @@ const total = top.reduce((s, d) => s + d.bytes, 0)
const mb = (b) => (b / MB).toFixed(1) const mb = (b) => (b / MB).toFixed(1)
console.log('TREE-WEIGHT') console.log('TREE-WEIGHT')
console.log(` public/assets : ${mb(total)}MB (ceiling ${MAX_MB}MB)`) console.log(` public/${TREE_NAME}/assets : ${mb(total)}MB (ceiling ${MAX_MB}MB)`)
for (const d of top.slice(0, 6)) console.log(` ${d.name.padEnd(14)} ${mb(d.bytes).padStart(6)}MB`) for (const d of top.slice(0, 6)) console.log(` ${d.name.padEnd(14)} ${mb(d.bytes).padStart(6)}MB`)
// The posters split is the actionable part — see the header. Reported whenever it exists. // The posters split is the actionable part — see the header. Reported whenever it exists.
@ -105,7 +108,7 @@ if (existsSync(posters)) {
} }
if (total > MAX_MB * MB) { if (total > MAX_MB * MB) {
console.error(`\nTREE-WEIGHT: FAIL — public/assets is ${mb(total)}MB, over the ${MAX_MB}MB ceiling.`) console.error(`\nTREE-WEIGHT: FAIL — public/${TREE_NAME}/assets is ${mb(total)}MB, over the ${MAX_MB}MB ceiling.`)
console.error(' A publish was already blocked once at a 74MB asset tree (Forgejo rejected the pack),') console.error(' A publish was already blocked once at a 74MB asset tree (Forgejo rejected the pack),')
console.error(' and nothing has measured it since; it grew from 94MB to 134MB unnoticed.') console.error(' and nothing has measured it since; it grew from 94MB to 134MB unnoticed.')
console.error(' Before deleting anything, read the split above: the city posters are the heavy half') console.error(' Before deleting anything, read the split above: the city posters are the heavy half')

View file

@ -1,77 +0,0 @@
#!/usr/bin/env node
/**
* check-staging-forwards-lang — a staging page opened at ?lang=ar embeds its leaves in Arabic.
*
* THE BUG (found by gospel 2026-09-25). The tree's staging page (public/doxa-maps/index.html, built
* from internal/staging-forge.js) built each embedded leaf's URL as withProvider(withTk(href)) — the
* token and the engine rode along, the language did not. The leaf pages bake <html lang="en">, so a
* maintainer or reviewer reading the tree at ?lang=ar saw every map in English, and the leaf was
* right to do so: nothing told it otherwise.
*
* WHAT IS ASSERTED, on the served tree:
* 1. /doxa-maps/index.html?lang=ar, gospel-resources/resources-map selected: the stage iframe's src
* AND the "open ↗" link both carry lang=ar;
* 2. inside that iframe the Gospel map's mode toggle reads gospel.mode.gap in Arabic ("الفجوة",
* from library/i18n/locales/ar/gospel.json) — the language actually arrived, not just the param.
*
* USAGE node internal/check-staging-forwards-lang.mjs [--dir <tree root>] (through browser-slot.sh)
* EXIT 0 forwarded and rendered · 1 not · 75 not measured.
* NEGATIVE-TESTED 2026-09-25: on the tree built before withLang() existed it FAILS at step 1.
*/
import { chromium } from 'playwright'
import { spawn } from 'node:child_process'
import { createServer } from 'node:http'
import { readFileSync } from 'node:fs'
import { resolve, dirname } from 'node:path'
import { fileURLToPath } from 'node:url'
import { TREE_NAME } from './_tree-name.mjs'
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..')
const argv = process.argv.slice(2)
const arg = (k, d) => { const i = argv.indexOf(k); return i >= 0 && argv[i + 1] ? argv[i + 1] : d }
const DIR = resolve(arg('--dir', resolve(ROOT, 'public')))
const KEY = 'gospel-resources/resources-map'
const GAP_AR = JSON.parse(readFileSync(resolve(ROOT, 'library/i18n/locales/ar/gospel.json'), 'utf8')).mode.gap
const port = await new Promise((ok) => { const s = createServer().listen(0, '127.0.0.1', () => { const p = s.address().port; s.close(() => ok(p)) }) })
const srv = spawn(process.execPath, [resolve(ROOT, 'internal/serve-strict.mjs'), '--port', String(port), '--directory', DIR], { stdio: 'ignore' })
const stop = () => { try { srv.kill() } catch { /* already gone */ } }
let up = false
for (let i = 0; i < 40 && !up; i++) { await new Promise((r) => setTimeout(r, 150)); up = await fetch(`http://127.0.0.1:${port}/${TREE_NAME}/index.html`).then((r) => r.ok).catch(() => false) }
if (!up) { stop(); console.log(`STAGING-LANG: NOT MEASURED — could not serve ${DIR}`); process.exit(75) }
const browser = await chromium.launch({ args: ['--use-gl=swiftshader', '--enable-webgl', '--ignore-gpu-blocklist'] })
const done = async (code, msg) => { console.log(msg); await browser.close(); stop(); process.exit(code) }
const page = await browser.newPage({ viewport: { width: 1400, height: 900 } })
await page.goto(`http://127.0.0.1:${port}/${TREE_NAME}/index.html?lang=ar`, { waitUntil: 'load' })
const node = page.locator(`.node[data-key="${KEY}"]`)
if (!(await node.count())) await done(75, `STAGING-LANG: NOT MEASURED — no ${KEY} node on the staging page`)
await node.first().click()
await page.waitForSelector('#stage iframe', { timeout: 15000 }).catch(() => {})
const src = await page.locator('#stage iframe').first().getAttribute('src').catch(() => null)
const pop = await page.locator('#stage a.popout').first().getAttribute('href').catch(() => null)
if (!src) await done(75, 'STAGING-LANG: NOT MEASURED — the stage never rendered an iframe')
const carries = (u) => new URL(u, 'http://x/').searchParams.get('lang') === 'ar'
console.log(` iframe src ${carries(src) ? 'carries lang=ar' : 'NO lang'} ${src}`)
console.log(` open ↗ ${pop && carries(pop) ? 'carries lang=ar' : 'NO lang'} ${pop}`)
if (!carries(src) || !pop || !carries(pop)) await done(1, 'STAGING-LANG: FAIL — the staging page does not forward ?lang= to the leaf it embeds.')
const frame = page.frameLocator('#stage iframe')
let label = null
for (let i = 0; i < 60 && label === null; i++) {
await page.waitForTimeout(500)
label = await frame.locator('body').evaluate(() => {
// the toggle lives inside the element's shadow root (possibly nested) — walk every root
const f = (r) => {
const b = [...r.querySelectorAll('button.gm-mode')]
if (b.length) return b.map((x) => x.textContent.trim())
for (const e of r.querySelectorAll('*')) if (e.shadowRoot) { const m = f(e.shadowRoot); if (m) return m }
return null
}
return f(document)
}).catch(() => null)
}
if (!label) await done(75, 'STAGING-LANG: NOT MEASURED — the Gospel map\'s mode toggle never rendered in the iframe')
console.log(` mode toggle ${JSON.stringify(label)} (expect "${GAP_AR}")`)
if (!label.includes(GAP_AR)) await done(1, `STAGING-LANG: FAIL — lang=ar reached the iframe but the Gospel map did not render gospel.mode.gap in Arabic.`)
await done(0, 'STAGING-LANG: PASS — ?lang=ar is forwarded to the embedded leaf and the Gospel map renders in Arabic.')

View file

@ -7,7 +7,7 @@
* otherwise renaming the tree would leave a stale folder behind. Runs BEFORE the * otherwise renaming the tree would leave a stale folder behind. Runs BEFORE the
* per-bundle vite builds (which emit into public/<name>/); generate-tree.mjs runs after. * per-bundle vite builds (which emit into public/<name>/); generate-tree.mjs runs after.
*/ */
import { readFileSync, rmSync, mkdirSync } from 'node:fs' import { readFileSync, rmSync, mkdirSync, existsSync } from 'node:fs'
import { dirname, resolve } from 'node:path' import { dirname, resolve } from 'node:path'
import { fileURLToPath } from 'node:url' import { fileURLToPath } from 'node:url'
import { TREE_NAME } from './_tree-name.mjs' import { TREE_NAME } from './_tree-name.mjs'
@ -36,8 +36,7 @@ const { name, paths } = config()
// LESSON: a clean step must clear EVERY output destination the build writes to. // LESSON: a clean step must clear EVERY output destination the build writes to.
// Cleaning only the canonical outDir is a silent, compounding leak. // Cleaning only the canonical outDir is a silent, compounding leak.
// The build writes into public/<name>/ directly (public/ is the committed served // The build writes into public/<name>/ directly (public/ is the committed served
// web-root). Only that ONE subfolder is cleaned — never public/ itself, which also // web-root). That ONE subfolder is reset — never public/ itself (write-token's public/api/ lives there).
// holds the shell and the pre-generated posters that are committed source.
const roots = [ const roots = [
resolve(__dirname, '..', 'public', name), resolve(__dirname, '..', 'public', name),
...paths.map(p => resolve(__dirname, '..', p, name)), ...paths.map(p => resolve(__dirname, '..', p, name)),
@ -48,3 +47,13 @@ for (const root of roots) {
mkdirSync(root, { recursive: true }) mkdirSync(root, { recursive: true })
console.log(`[clean] reset ${root}`) console.log(`[clean] reset ${root}`)
} }
// ONE FOLDER (Driver decision, card BUILD-OUTPUT-IS-ONE-FOLDER, 2026-09-25): public/ now holds only the
// output folder + a generated README.md. These are the entries the OLD layout wrote beside the tree;
// removing them here lets a checkout that still has them converge on the new layout at its next build
// instead of carrying them forever. (public/api/ is left alone: write-token owns it, and only when a
// MAPBOX_TOKEN is supplied.)
for (const legacy of ['index.html', 'assets', '__gallery']) {
const p = resolve(__dirname, '..', 'public', legacy)
if (existsSync(p)) { rmSync(p, { recursive: true, force: true }); console.log(`[clean] removed public/${legacy} (no longer part of the output)`) }
}

View file

@ -676,10 +676,24 @@ function main() {
const nProfiles = baked.reduce((n, b) => n + b.p.length, 0) const nProfiles = baked.reduce((n, b) => n + b.p.length, 0)
const nInstances = baked.reduce((n, b) => n + b.p.reduce((k, p) => k + (p.in ? p.in.length : 0), 0), 0) const nInstances = baked.reduce((n, b) => n + b.p.reduce((k, p) => k + (p.in ? p.in.length : 0), 0), 0)
const nOverrides = baked.reduce((n, b) => n + b.p.reduce((k, p) => k + (p.em ? 1 : 0) + (p.in || []).filter(x => x.em).length, 0), 0) const nOverrides = baked.reduce((n, b) => n + b.p.reduce((k, p) => k + (p.em ? 1 : 0) + (p.in || []).filter(x => x.em).length, 0), 0)
const dir = join(ROOT, 'public', '__gallery') // ARCHIVED OUT OF public/ (Driver decision, card BUILD-OUTPUT-IS-ONE-FOLDER): public/ holds only the
// output folder + README.md, and the studio at <tree>/index.html is the front page. The last
// generated copy is kept in the umbrella's _qa-cycle/archive/. This still renders on request, into a
// directory you name — never into public/.
const oi = process.argv.indexOf('--out')
const outDir = oi >= 0 && process.argv[oi + 1] ? resolve(process.argv[oi + 1]) : null
if (!outDir) {
console.log('[generate-gallery] ARCHIVED — not written. The gallery is no longer part of the build or of public/;')
console.log(' the Plugin Studio (<tree>/index.html) is the front page. To render it anyway: bun run gallery -- --out <dir>')
return
}
if (outDir === resolve(ROOT, 'public') || outDir.startsWith(resolve(ROOT, 'public') + '/')) {
console.error('[generate-gallery] refusing to write into public/ — it holds only the output folder.'); process.exit(1)
}
const dir = outDir
mkdirSync(dir, { recursive: true }) mkdirSync(dir, { recursive: true })
writeFileSync(join(dir, 'index.html'), html) writeFileSync(join(dir, 'index.html'), html)
console.log(`[generate-gallery] public/__gallery/index.html (ARCHIVED; \`/\` is the studio) — ${(html.length / 1024).toFixed(1)} KB · ${baked.length} bundle(s) · ${nProfiles} profile(s) · ${nInstances} instance(s) · ${nOverrides} embed snippet(s) baked verbatim (the rest re-derive)`) console.log(`[generate-gallery] ${dir}/index.html (ARCHIVED rendering) — ${(html.length / 1024).toFixed(1)} KB · ${baked.length} bundle(s) · ${nProfiles} profile(s) · ${nInstances} instance(s) · ${nOverrides} embed snippet(s) baked verbatim (the rest re-derive)`)
} }
// Runs only as a script; importing it (the dev plugin does) has no side effects. // Runs only as a script; importing it (the dev plugin does) has no side effects.
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) main() if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) main()

View file

@ -32,8 +32,9 @@
*/ */
import { import {
readFileSync, writeFileSync, readdirSync, existsSync, mkdirSync, statSync, readFileSync, writeFileSync, readdirSync, existsSync, mkdirSync, statSync,
cpSync, rmSync, cpSync, rmSync, mkdtempSync,
} from 'node:fs' } from 'node:fs'
import { tmpdir } from 'node:os'
import { dirname, resolve, join } from 'node:path' import { dirname, resolve, join } from 'node:path'
import { fileURLToPath } from 'node:url' import { fileURLToPath } from 'node:url'
import { createHash } from 'node:crypto' import { createHash } from 'node:crypto'
@ -43,6 +44,7 @@ import { createHash } from 'node:crypto'
// consumer (plugin-studio) mis-tiered every profile as cap-1 (single). Single source of truth. // consumer (plugin-studio) mis-tiered every profile as cap-1 (single). Single source of truth.
import { detectProfileType } from './plugin-gallery/discover.mjs' import { detectProfileType } from './plugin-gallery/discover.mjs'
import { TREE_NAME as CONFIGURED_TREE_NAME } from './_tree-name.mjs' import { TREE_NAME as CONFIGURED_TREE_NAME } from './_tree-name.mjs'
import { syncAppShells } from './sync-app-shells.mjs'
const __dirname = dirname(fileURLToPath(import.meta.url)) const __dirname = dirname(fileURLToPath(import.meta.url))
const APP_PROFILES = resolve(__dirname, '..', 'plugin-designs') const APP_PROFILES = resolve(__dirname, '..', 'plugin-designs')
@ -692,9 +694,12 @@ function rootHtml(bundles, manifest) {
* "the plugin works"; a mounted element with no data is the failure that looks like success. * "the plugin works"; a mounted element with no data is the failure that looks like success.
*/ */
function studioRootHtml(bakedToken) { function studioRootHtml(bakedToken) {
// Relative, so the served root stays position-independent (drop-portability): the same file // THE TREE'S FRONT PAGE (<tree>/index.html) — Driver decision, card
// works at a domain root, under a foreign subpath, and from file://. // BUILD-PLUGIN-STUDIO-IS-THE-OUTPUT-FRONT-PAGE: the output is ONE folder, and opening it opens the
const src = `./${TREE_NAME}/plugin-studio/plugin-studio.js` // studio. It used to sit one level ABOVE the tree (public/index.html) with the staging-forge chooser
// at <tree>/index.html; both are replaced by this. Relative paths, so the folder stays
// position-independent (drop-portability): a domain root, a foreign subpath, or file://.
const src = `./plugin-studio/plugin-studio.js`
return `<!DOCTYPE html> return `<!DOCTYPE html>
<html lang="en"> <html lang="en">
<head> <head>
@ -708,7 +713,7 @@ ${bakedToken ? ` <script>window.MAP_TOKEN=${JSON.stringify(bakedToken)};</scrip
</style> </style>
</head> </head>
<body> <body>
<plugin-studio tree-base="./${TREE_NAME}"></plugin-studio> <plugin-studio tree-base="."></plugin-studio>
<script src="${src}"></script> <script src="${src}"></script>
</body> </body>
</html> </html>
@ -1359,16 +1364,10 @@ function writeManifestAndHtml(root, bundles, tokenUrlExpr, bakedToken) {
if (existsSync(join(root, 'doxa-maps-spa'))) { if (existsSync(join(root, 'doxa-maps-spa'))) {
writeFileSync(join(root, 'doxa-maps-spa', 'manifest.json'), JSON.stringify(manifest, null, 2) + '\n') writeFileSync(join(root, 'doxa-maps-spa', 'manifest.json'), JSON.stringify(manifest, null, 2) + '\n')
} }
writeFileSync(join(root, 'index.html'), rootHtml(bundles, manifest)) // THE FRONT PAGE IS THE STUDIO (see studioRootHtml). The staging-forge chooser that used to be
// written here (rootHtml) is retired with it; nothing is written above the tree any more.
writeFileSync(join(root, 'index.html'), studioRootHtml(bakedToken))
// ── THE WEB-ROOT PAGE = THE STUDIO ──────────────────────────────────────────────────────────
// One level ABOVE the tree (public/index.html, beside public/<tree>/ and public/assets/), which
// is what `bun run serve` serves at `/`. Written here rather than by generate-gallery.mjs
// because the studio host page is part of the TREE's contract: it names the tree folder and
// loads a bundle out of it, so it belongs with the code that knows the tree's shape.
// Previously this slot held the 57 KB staging gallery; that page is now archived at
// public/__gallery/index.html by generate-gallery.mjs.
writeFileSync(join(root, '..', 'index.html'), studioRootHtml(bakedToken))
bundles.forEach((b) => { bundles.forEach((b) => {
const bundleDir = join(root, b.bundle) const bundleDir = join(root, b.bundle)
@ -1403,6 +1402,25 @@ function writeManifestAndHtml(root, bundles, tokenUrlExpr, bakedToken) {
}) })
} }
function publicReadme(bundles) {
const profiles = bundles.reduce((n, b) => n + b.profiles.length, 0)
return `# public/ — the built output
This folder is GENERATED by \`bun run build\`; do not edit it by hand. It holds exactly one thing:
- **\`${TREE_NAME}/\`** — the output folder: ${bundles.length} plugin bundles and ${profiles} pages, self-contained.
Copy this one folder to any web host (or open it from disk) and it works.
- \`${TREE_NAME}/index.html\` — the front page: the Plugin Studio, every plugin in one place.
- \`${TREE_NAME}/<plugin>/<plugin>.js\` — a plugin's bundle (a custom element; load it with a \`<script>\` tag).
- \`${TREE_NAME}/<plugin>/<page>/index.html\` — a ready-made page for one plugin view (embed it in an \`<iframe>\`).
- \`${TREE_NAME}/manifest.json\` — every plugin, page and embed snippet, machine-readable.
- \`${TREE_NAME}/assets/\` — the data, fonts and images the plugins fetch at runtime.
The folder's name comes from \`name\` in \`vite.doxa-maps-build-config.json\`. Sources live in
\`plugin-designs/\`, \`library/\` and \`data/\`; \`rm -rf public/ && bun run build\` recreates all of this.
`
}
function main() { function main() {
const bundles = scanBundles() const bundles = scanBundles()
// SUBRESOURCE INTEGRITY is computed inside scanBundles() (each bundle record carries // SUBRESOURCE INTEGRITY is computed inside scanBundles() (each bundle record carries
@ -1426,7 +1444,13 @@ function main() {
// They must be COPIED here or a clean build drops them and the wall falls back // They must be COPIED here or a clean build drops them and the wall falls back
// to text placeholders while the map fails to resolve its boundaries. // to text placeholders while the map fails to resolve its boundaries.
{ {
const pubAssets = resolve(__dirname, '..', 'public', 'assets') // STAGED IN A TEMP DIRECTORY, NOT public/assets (Driver decision, card BUILD-OUTPUT-IS-ONE-FOLDER:
// public/ holds only the output folder + README.md). This step still gathers the sources
// (data/, plugin previews, the upg-100 boundaries) into one pool and copies the fetched
// allowlist into <tree>/assets below; the pool itself is a build intermediate. The web-root
// sibling it used to be was also a trap: it made a sibling-relative asset path resolve locally
// while it 404'd live (library/utils/embedBase.js records that incident).
const pubAssets = mkdtempSync(join(tmpdir(), `${TREE_NAME}-assets-`))
// EVERY profile's previews, not just the 100-list's. This was hardcoded to // EVERY profile's previews, not just the 100-list's. This was hardcoded to
// my-upg-100-list, so country-posters shipped with zero thumbnails and its // my-upg-100-list, so country-posters shipped with zero thumbnails and its
// whole wall fell back to "poster not generated yet" placeholders even though // whole wall fell back to "poster not generated yet" placeholders even though
@ -1445,7 +1469,7 @@ function main() {
cpSync(src, d, { recursive: true }) cpSync(src, d, { recursive: true })
copied += readdirSync(src).length copied += readdirSync(src).length
} }
if (copied) console.log(`[generate-tree] copied ${copied} poster previews → public/assets/previews`) if (copied) console.log(`[generate-tree] gathered ${copied} poster previews`)
} }
const bDir = join(pubAssets, 'boundaries') const bDir = join(pubAssets, 'boundaries')
mkdirSync(bDir, { recursive: true }) mkdirSync(bDir, { recursive: true })
@ -1455,7 +1479,7 @@ function main() {
[join(dataDir, 'gb-IND-ADM1.geojson'), 'gb-IND-ADM1.geojson'], [join(dataDir, 'gb-IND-ADM1.geojson'), 'gb-IND-ADM1.geojson'],
[join(dataDir, 'gb-CHN-ADM1.geojson'), 'gb-CHN-ADM1.geojson'], [join(dataDir, 'gb-CHN-ADM1.geojson'), 'gb-CHN-ADM1.geojson'],
]) if (existsSync(from)) cpSync(from, join(bDir, to)) ]) if (existsSync(from)) cpSync(from, join(bDir, to))
console.log('[generate-tree] copied boundary geojson → public/assets/boundaries') console.log('[generate-tree] gathered boundary geojson')
// DATA LIVES IN data/, THE WEB-ROOT IS GENERATED (the D1 reorganization): // DATA LIVES IN data/, THE WEB-ROOT IS GENERATED (the D1 reorganization):
// source-of-truth folders that used to sit ONLY inside public/ now live in // source-of-truth folders that used to sit ONLY inside public/ now live in
@ -1475,6 +1499,10 @@ function main() {
['tables', join(pubAssets, 'tables')], ['tables', join(pubAssets, 'tables')],
// pplr — the PPLR data maps' derived datasets (plugin-designs/pplr-data-maps/scripts/build-data.mjs) // pplr — the PPLR data maps' derived datasets (plugin-designs/pplr-data-maps/scripts/build-data.mjs)
['pplr', join(pubAssets, 'pplr')], ['pplr', join(pubAssets, 'pplr')],
// fonts/ + images/ — vendored faces and images the leaves fetch (see the staging notes
// below). They used to live ONLY in public/assets; they are sources, so they live in data/.
['fonts', join(pubAssets, 'fonts')],
['images', join(pubAssets, 'images')],
]) { ]) {
const src = join(repoData, srcName) const src = join(repoData, srcName)
if (!existsSync(src)) continue if (!existsSync(src)) continue
@ -1482,7 +1510,7 @@ function main() {
mkdirSync(destRel, { recursive: true }) mkdirSync(destRel, { recursive: true })
cpSync(src, destRel, { recursive: true }) cpSync(src, destRel, { recursive: true })
} }
console.log('[generate-tree] copied data/ sources → web-root/assets (people-groups, vendor, posters, tables, pplr)') console.log('[generate-tree] gathered data/ sources into the build pool (people-groups, vendor, posters, tables, pplr, fonts, images)')
// ── THE TREE IS THE SHIPPING UNIT: stage the fetched assets INSIDE it ────── // ── THE TREE IS THE SHIPPING UNIT: stage the fetched assets INSIDE it ──────
// BUG-DATA-ASSETS-NOT-SHIPPED-TREE-NOT-PORTABLE (2026-09-20). The web-root copy // BUG-DATA-ASSETS-NOT-SHIPPED-TREE-NOT-PORTABLE (2026-09-20). The web-root copy
@ -1648,8 +1676,12 @@ function main() {
console.log('[generate-tree] posters/ NOT bundled — the wall renders from previews; export and print need posterSources:true, bundlePosters:true, or an absolute assetsBaseUrl') console.log('[generate-tree] posters/ NOT bundled — the wall renders from previews; export and print need posterSources:true, bundlePosters:true, or an absolute assetsBaseUrl')
} }
console.log(`[generate-tree] staged ${staged} runtime asset(s) → ${TREE_NAME}/assets (tree is self-contained)`) console.log(`[generate-tree] staged ${staged} runtime asset(s) → ${TREE_NAME}/assets (tree is self-contained)`)
rmSync(pubAssets, { recursive: true, force: true })
} }
// public/README.md — the one other entry public/ holds: what this folder is, in plain words.
writeFileSync(join(DEFAULT_ROOT, '..', 'README.md'), publicReadme(bundles))
for (const dest of dests) { for (const dest of dests) {
if (dest === DEFAULT_ROOT) continue if (dest === DEFAULT_ROOT) continue
rmSync(dest, { recursive: true, force: true }) rmSync(dest, { recursive: true, force: true })
@ -1658,6 +1690,9 @@ function main() {
console.log(`[generate-tree] copied ${TREE_NAME}/ → ${dest}`) console.log(`[generate-tree] copied ${TREE_NAME}/ → ${dest}`)
} }
// The native app shells load THIS folder; keep their paths in step with the tree's name.
for (const r of syncAppShells()) if (r.changed) console.log(`[generate-tree] app shell ${r.file}: ${r.notes.join('; ')}`)
console.log(`[generate-tree] ${bundles.length} bundle(s), ` + console.log(`[generate-tree] ${bundles.length} bundle(s), ` +
`${bundles.reduce((n, b) => n + b.profiles.length, 0)} profile(s) → ${dests.length} destination(s)`) `${bundles.reduce((n, b) => n + b.profiles.length, 0)} profile(s) → ${dests.length} destination(s)`)
} }

View file

@ -38,7 +38,9 @@ const DEV_ASSET_MIME = {
} }
export function resolveDevAsset(rel, root = REPO_ROOT) { export function resolveDevAsset(rel, root = REPO_ROOT) {
const isFile = (p) => existsSync(p) && statSync(p).isFile() const isFile = (p) => existsSync(p) && statSync(p).isFile()
const shipped = resolve(root, 'public', 'assets', rel) // The built copy lives INSIDE the output folder (public/<tree>/assets) — public/ holds nothing else
// since BUILD-OUTPUT-IS-ONE-FOLDER. The data/ fallback below keeps a fresh clone working unbuilt.
const shipped = resolve(root, 'public', TREE_NAME, 'assets', rel)
if (isFile(shipped)) return shipped if (isFile(shipped)) return shipped
// data/<rel> is the TRACKED source of truth public/assets mirrors (see .gitignore): generate-tree // data/<rel> is the TRACKED source of truth public/assets mirrors (see .gitignore): generate-tree
// writes the mirror, so on a fresh clone — which is what DX receives — public/assets holds almost // writes the mirror, so on a fresh clone — which is what DX receives — public/assets holds almost
@ -260,7 +262,7 @@ export function autoSpaDevPlugin(token = '') {
try { rel = decodeURIComponent(path.slice(assetPrefix.length)) } catch { rel = '' } try { rel = decodeURIComponent(path.slice(assetPrefix.length)) } catch { rel = '' }
if (!rel || rel.split('/').includes('..')) { res.statusCode = 400; return res.end('bad asset path') } if (!rel || rel.split('/').includes('..')) { res.statusCode = 400; return res.end('bad asset path') }
const file = resolveDevAsset(rel) const file = resolveDevAsset(rel)
if (!file) return json(res, { error: 'asset not found', asset: rel, looked: ['public/assets/', 'data/', 'plugin-designs/*/data/'] }, 404) if (!file) return json(res, { error: 'asset not found', asset: rel, looked: [`public/${TREE_NAME}/assets/`, 'data/', 'plugin-designs/*/data/'] }, 404)
res.statusCode = 200 res.statusCode = 200
res.setHeader('Content-Type', DEV_ASSET_MIME[extname(file).toLowerCase()] || 'application/octet-stream') res.setHeader('Content-Type', DEV_ASSET_MIME[extname(file).toLowerCase()] || 'application/octet-stream')
res.setHeader('Cache-Control', 'no-cache') res.setHeader('Cache-Control', 'no-cache')

View file

@ -123,7 +123,10 @@ for (const f of files) {
if (ext === '.map') hit(findings, 'debug', f.rel, 'source map file') if (ext === '.map') hit(findings, 'debug', f.rel, 'source map file')
// NOTE: 'docs' here is the FINDING-CATEGORY name documented in this file's header, not a // NOTE: 'docs' here is the FINDING-CATEGORY name documented in this file's header, not a
// folder path. It deliberately did NOT change when docs/ was renamed to documentation/. // folder path. It deliberately did NOT change when docs/ was renamed to documentation/.
if (ext === '.md' || ext === '.markdown' || ext === '.txt') hit(findings, 'docs', f.rel, 'documentation in a served folder') // The ONE exception is public/README.md: the build generates it (generate-tree publicReadme) to explain
// the folder, per the Driver's decision (BUILD-OUTPUT-IS-ONE-FOLDER: "only the output folder + a short
// README.md"). A doc anywhere else — inside the tree included — is still a finding.
if ((ext === '.md' || ext === '.markdown' || ext === '.txt') && f.rel !== 'README.md') hit(findings, 'docs', f.rel, 'documentation in a served folder')
const isText = TEXT_EXT.has(ext) || TEXT_EXT.has(base) const isText = TEXT_EXT.has(ext) || TEXT_EXT.has(base)
if (!isText || f.size > 40 * 1024 * 1024) continue if (!isText || f.size > 40 * 1024 * 1024) continue
const t = readFileSync(f.abs, 'utf8') const t = readFileSync(f.abs, 'utf8')
@ -168,7 +171,9 @@ for (const f of files) {
for (const d of notes) hit(STRICT ? findings : warnings, 'meta', f.rel, d) for (const d of notes) hit(STRICT ? findings : warnings, 'meta', f.rel, d)
} }
// staging surfaces: <tree>/index.html and <tree>/<bundle>/index.html (leaf pages are deeper) // staging surfaces: <tree>/index.html and <tree>/<bundle>/index.html (leaf pages are deeper)
const staging = files.filter(f => f.rel === `${TREE}/index.html` || /^[^/]+\/[^/]+\/index\.html$/.test(f.rel) && f.rel.startsWith(`${TREE}/`)).map(f => f.rel) // <tree>/index.html is NOT a staging page any more: it is the Plugin Studio, the output's front page
// (Driver decision, BUILD-PLUGIN-STUDIO-IS-THE-OUTPUT-FRONT-PAGE). A <tree>/<bundle>/index.html chooser still would be.
const staging = files.filter(f => /^[^/]+\/[^/]+\/index\.html$/.test(f.rel) && f.rel.startsWith(`${TREE}/`)).map(f => f.rel)
for (const s of staging) hit(STRICT ? findings : warnings, 'staging', s, 'preview/staging page (internal surface) — exclude from a public mirror or accept knowingly') for (const s of staging) hit(STRICT ? findings : warnings, 'staging', s, 'preview/staging page (internal surface) — exclude from a public mirror or accept knowingly')
// The drop-in contract: only the entries vite.doxa-maps-build-config.json `dropin.include` declares // The drop-in contract: only the entries vite.doxa-maps-build-config.json `dropin.include` declares
// may sit at the root. Strict = every other root entry is a finding; otherwise a warning. // may sit at the root. Strict = every other root entry is a finding; otherwise a warning.
@ -185,15 +190,16 @@ const CONTRACT = (() => {
try { try {
const cfg = JSON.parse(readFileSync(join(ROOT, 'vite.doxa-maps-build-config.json'), 'utf8')) const cfg = JSON.parse(readFileSync(join(ROOT, 'vite.doxa-maps-build-config.json'), 'utf8'))
const inc = cfg?.dropin?.include const inc = cfg?.dropin?.include
if (Array.isArray(inc) && inc.length) return new Set(inc) // The output folder is always part of the contract (it is the product); include lists the extras.
if (Array.isArray(inc)) return new Set([TREE, ...inc])
} catch { /* fall through */ } } catch { /* fall through */ }
return new Set(['index.html', 'assets', TREE]) return new Set(['README.md', TREE])
})() })()
const rootEntries = [...new Set(files.map(f => f.rel.split('/')[0]))].sort() const rootEntries = [...new Set(files.map(f => f.rel.split('/')[0]))].sort()
for (const e of rootEntries) { for (const e of rootEntries) {
if (CONTRACT.has(e)) continue if (CONTRACT.has(e)) continue
const label = files.some(f => f.rel.startsWith(`${e}/`)) ? `${e}/` : e const label = files.some(f => f.rel.startsWith(`${e}/`)) ? `${e}/` : e
hit(STRICT ? findings : warnings, 'contract', label, `outside the drop-in contract (index.html, assets/, ${TREE}/ only)`) hit(STRICT ? findings : warnings, 'contract', label, `outside the drop-in contract (${[...CONTRACT].join(', ')} only)`)
} }
// inventory // inventory

View file

@ -79,7 +79,7 @@ const send = (res, code, body, headers = {}) => {
// //
// A 503 is deliberate, not a 404: 404 means "this is not here", which a gate may reasonably report // A 503 is deliberate, not a 404: 404 means "this is not here", which a gate may reasonably report
// as a product defect. 503 with a named cause means "ask again later" and cannot be mistaken for a // as a product defect. 503 with a named cause means "ask again later" and cannot be mistaken for a
// finding. Static assets outside the bundle tree (assets/, __gallery/) are unaffected. // finding. Anything outside the bundle tree is unaffected.
const TREE = join(DIR, TREE_NAME) const TREE = join(DIR, TREE_NAME)
const MANIFEST = join(TREE, 'manifest.json') const MANIFEST = join(TREE, 'manifest.json')
function treeIsTorn() { function treeIsTorn() {
@ -135,4 +135,5 @@ createServer((req, res) => {
}).listen(PORT, HOST, () => { }).listen(PORT, HOST, () => {
console.log(`serve-strict: http://${HOST}:${PORT} -> ${DIR.replace(ROOT + '/', '')}`) console.log(`serve-strict: http://${HOST}:${PORT} -> ${DIR.replace(ROOT + '/', '')}`)
console.log(' directory requests return 404 (matching production). Ask for /index.html explicitly.') console.log(' directory requests return 404 (matching production). Ask for /index.html explicitly.')
console.log(` front page (the Plugin Studio): http://${HOST}:${PORT}/${TREE_NAME}/index.html`)
}) })

View file

@ -41,7 +41,7 @@ const APP_PUBLIC = join(ROOT, 'deployment/capacitor/android/app/src/main/assets/
const APP_ROOT = join(APP_PUBLIC, 'index.html') const APP_ROOT = join(APP_PUBLIC, 'index.html')
// The showcase leaf, inside the served tree. // The showcase leaf, inside the served tree.
const TREE = TREE_NAME const TREE = TREE_NAME
const LEAF_REL = `${TREE}/my-upg-100-list/upg-100-mfe/index.html` const LEAF_REL = `my-upg-100-list/upg-100-mfe/index.html` // the app's web root IS the tree (webDir public/<tree>)
const LEAF = join(APP_PUBLIC, LEAF_REL) const LEAF = join(APP_PUBLIC, LEAF_REL)
// A marker so --check can tell the app page apart from the studio page without parsing HTML. // A marker so --check can tell the app page apart from the studio page without parsing HTML.
@ -78,7 +78,7 @@ if (CHECK) {
// `../my-upg-100-list.js`. At the app root that same relative path resolves ABOVE the web root. // `../my-upg-100-list.js`. At the app root that same relative path resolves ABOVE the web root.
// Every leaf-relative reference gains the leaf's own directory prefix; absolute and protocol URLs // Every leaf-relative reference gains the leaf's own directory prefix; absolute and protocol URLs
// (the Mapbox CDN tags) are left alone. // (the Mapbox CDN tags) are left alone.
const LEAF_DIR = `${TREE}/my-upg-100-list/upg-100-mfe/` const LEAF_DIR = `my-upg-100-list/upg-100-mfe/`
const html = readFileSync(LEAF, 'utf8') const html = readFileSync(LEAF, 'utf8')
const rebased = html.replace( const rebased = html.replace(
/\b(src|href)=(["'])(?!https?:|data:|#|\/)([^"']+)\2/g, /\b(src|href)=(["'])(?!https?:|data:|#|\/)([^"']+)\2/g,

View file

@ -0,0 +1,71 @@
#!/usr/bin/env node
/**
* sync-app-shells.mjs — point the native app shells at the OUTPUT FOLDER, from the tree's name.
*
* public/ holds only the output folder (Driver decision, card BUILD-OUTPUT-IS-ONE-FOLDER), so the app
* shells load THAT folder as their web root:
* capacitor.config.json "webDir": "public/<tree>"
* `cap copy` hard-fails unless webDir holds an index.html; the tree's front page is its
* index.html (the Plugin Studio), and set-app-start-page then installs the app's start page.
* deployment/src-tauri/tauri.conf.json build.frontendDist "../../public/<tree>"
* windows[].url relative to that folder
* The window url used to carry the tree's name ("doxa-maps/doxa-maps-spa/…"), so renaming the
* tree opened a BLANK desktop window (check:tauri caught it in the zz-maps rename proof).
*
* Both files are hand-formatted JSON, so only those values are replaced in place — nothing else
* moves. Idempotent. generate-tree runs this after writing the tree, so a rename stays ONE line
* (vite.doxa-maps-build-config.json "name").
*
* USAGE node internal/sync-app-shells.mjs [--check] --check: exit 1 if a file is out of date.
*/
import { readFileSync, writeFileSync, existsSync } from 'node:fs'
import { resolve, dirname } from 'node:path'
import { fileURLToPath } from 'node:url'
import { TREE_NAME } from './_tree-name.mjs'
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..')
/** Returns [{ file, changed: bool, notes: [] }]; writes unless check. */
export function syncAppShells({ check = false } = {}) {
const out = []
// ── Capacitor ──
const cap = resolve(ROOT, 'capacitor.config.json')
if (existsSync(cap)) {
const src = readFileSync(cap, 'utf8')
const want = `public/${TREE_NAME}`
const next = src.replace(/("webDir"\s*:\s*)"[^"]*"/, `$1"${want}"`)
out.push({ file: 'capacitor.config.json', changed: next !== src, notes: [`webDir -> ${want}`] })
if (next !== src && !check) { JSON.parse(next); writeFileSync(cap, next) }
}
// ── Tauri ──
const tauri = resolve(ROOT, 'deployment', 'src-tauri', 'tauri.conf.json')
if (existsSync(tauri)) {
const src = readFileSync(tauri, 'utf8')
const conf = JSON.parse(src)
const want = `../../public/${TREE_NAME}`
let next = src.replace(/("frontendDist"\s*:\s*)"[^"]*"/, `$1"${want}"`)
const notes = [`frontendDist -> ${want}`]
// A window url that still starts with a tree folder (any name that is a sibling of the bundles, i.e.
// the first segment is not a bundle) is made relative to the tree.
for (const w of (conf.app && conf.app.windows) || []) {
if (!w.url || /^[a-z]+:/i.test(w.url)) continue
const segs = w.url.split('/')
if (segs.length > 1 && !existsSync(resolve(ROOT, 'public', TREE_NAME, segs[0])) && existsSync(resolve(ROOT, 'public', TREE_NAME, ...segs.slice(1)))) {
const rel = segs.slice(1).join('/')
next = next.replace(`"url": "${w.url}"`, `"url": "${rel}"`)
notes.push(`window url ${w.url} -> ${rel}`)
}
}
out.push({ file: 'deployment/src-tauri/tauri.conf.json', changed: next !== src, notes })
if (next !== src && !check) { JSON.parse(next); writeFileSync(tauri, next) }
}
return out
}
if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
const check = process.argv.includes('--check')
const res = syncAppShells({ check })
for (const r of res) console.log(` ${r.changed ? (check ? 'STALE' : 'wrote') : 'ok '} ${r.file} (${r.notes.join('; ')})`)
if (check && res.some((r) => r.changed)) { console.log('APP-SHELLS: FAIL — run node internal/sync-app-shells.mjs (generate-tree does it on every build).'); process.exit(1) }
console.log(`APP-SHELLS: ${check ? 'PASS — both shells load' : 'synced to'} public/${TREE_NAME}/`)
}

View file

@ -102,11 +102,13 @@ if (args.includes('--only')) {
try { cfg = JSON.parse(readFileSync(CONFIG, 'utf8')) } catch (e) { console.error(`[sync-dropin] cannot read ${CONFIG}: ${e.message}`); process.exit(1) } try { cfg = JSON.parse(readFileSync(CONFIG, 'utf8')) } catch (e) { console.error(`[sync-dropin] cannot read ${CONFIG}: ${e.message}`); process.exit(1) }
const inc = cfg?.dropin?.include const inc = cfg?.dropin?.include
if (!Array.isArray(inc) || !inc.length || !inc.every(s => typeof s === 'string')) { console.error(`[sync-dropin] --from-config: ${norm(relative(ROOT, CONFIG))} declares no "dropin": { "include": [ …path prefixes relative to public/… ] }`); process.exit(1) } if (!Array.isArray(inc) || !inc.length || !inc.every(s => typeof s === 'string')) { console.error(`[sync-dropin] --from-config: ${norm(relative(ROOT, CONFIG))} declares no "dropin": { "include": [ …path prefixes relative to public/… ] }`); process.exit(1) }
ALLOW = inc.map(normPrefix).filter(Boolean) // The OUTPUT FOLDER is always shipped: it is the product, and naming it here would put the tree's
// name in a second place in the config (a rename must stay one line). include lists the EXTRAS.
ALLOW = [TREE, ...inc.map((s) => s.replaceAll('{tree}', TREE))].map(normPrefix).filter(Boolean)
ALLOW_FROM = `${norm(relative(ROOT, CONFIG))} · dropin.include` ALLOW_FROM = `${norm(relative(ROOT, CONFIG))} · dropin.include`
const exc = cfg?.dropin?.exclude const exc = cfg?.dropin?.exclude
if (exc !== undefined && (!Array.isArray(exc) || !exc.every(s => typeof s === 'string'))) { console.error(`[sync-dropin] --from-config: "dropin.exclude" must be an array of path prefixes relative to public/`); process.exit(1) } if (exc !== undefined && (!Array.isArray(exc) || !exc.every(s => typeof s === 'string'))) { console.error(`[sync-dropin] --from-config: "dropin.exclude" must be an array of path prefixes relative to public/`); process.exit(1) }
NOT_SHIPPED = (exc || []).map(normPrefix).filter(Boolean) NOT_SHIPPED = (exc || []).map((s) => s.replaceAll('{tree}', TREE)).map(normPrefix).filter(Boolean)
if (NOT_SHIPPED.some(p => p.split('/').includes('..'))) { console.error(`[sync-dropin] dropin.exclude climbs out of ${norm(relative(ROOT, FROM))}/: ${JSON.stringify(NOT_SHIPPED)}`); process.exit(1) } if (NOT_SHIPPED.some(p => p.split('/').includes('..'))) { console.error(`[sync-dropin] dropin.exclude climbs out of ${norm(relative(ROOT, FROM))}/: ${JSON.stringify(NOT_SHIPPED)}`); process.exit(1) }
} }
if (ALLOW && (!ALLOW.length || ALLOW.some(p => p.split('/').includes('..')))) { console.error(`[sync-dropin] allowlist is empty or climbs out of ${norm(relative(ROOT, FROM))}/: ${JSON.stringify(ALLOW)}`); process.exit(1) } if (ALLOW && (!ALLOW.length || ALLOW.some(p => p.split('/').includes('..')))) { console.error(`[sync-dropin] allowlist is empty or climbs out of ${norm(relative(ROOT, FROM))}/: ${JSON.stringify(ALLOW)}`); process.exit(1) }

View file

@ -4,7 +4,7 @@ the app ships.
python3 make-previews.py python3 make-previews.py
-> plugin-designs/my-upg-100-list/assets/previews/<zone>.webp 560px card thumbnails -> plugin-designs/my-upg-100-list/assets/previews/<zone>.webp 560px card thumbnails
-> public/assets/posters/<zone>.webp 1500px the download -> data/posters/<zone>.webp 1500px the download
WHY TWO SIZES WHY TWO SIZES
The wall card shows a small thumbnail; tapping "Poster Map" must hand the user The wall card shows a small thumbnail; tapping "Poster Map" must hand the user
@ -37,7 +37,7 @@ REPO = os.path.abspath(os.path.join(HERE, '..', '..'))
PROFILE = os.environ.get('PROFILE', 'my-upg-100-list') PROFILE = os.environ.get('PROFILE', 'my-upg-100-list')
ONLY = os.environ.get('ONLY_PREFIX', '') ONLY = os.environ.get('ONLY_PREFIX', '')
THUMB = os.path.join(REPO, 'plugin-designs', PROFILE, 'assets', 'previews') THUMB = os.path.join(REPO, 'plugin-designs', PROFILE, 'assets', 'previews')
FULL = os.path.join(REPO, 'public', 'assets', 'posters') FULL = os.path.join(REPO, 'data', 'posters') # a SOURCE: the build stages it into <tree>/assets/posters on request
os.makedirs(THUMB, exist_ok=True) os.makedirs(THUMB, exist_ok=True)
os.makedirs(FULL, exist_ok=True) os.makedirs(FULL, exist_ok=True)
@ -63,4 +63,4 @@ for p in pngs:
print(f'[make-previews] {len(pngs)} posters') print(f'[make-previews] {len(pngs)} posters')
print(f' thumbnails -> plugin-designs/.../assets/previews {tkb/1024:5.1f} MB (inlined in the bundle)') print(f' thumbnails -> plugin-designs/.../assets/previews {tkb/1024:5.1f} MB (inlined in the bundle)')
print(f' full size -> public/assets/posters {fkb/1024:5.1f} MB (served + shipped in the APK)') print(f' full size -> data/posters {fkb/1024:5.1f} MB (served + shipped in the APK)')

View file

@ -96,7 +96,7 @@ const SCRIPT_FONT_FILE = {
let fontData = null; let fontData = null;
const fontRel = SCRIPT_FONT_FILE[LANG]; const fontRel = SCRIPT_FONT_FILE[LANG];
if (fontRel) { if (fontRel) {
const fp = join(__dirname, '..', '..', '..', 'public', 'assets', 'fonts', fontRel); const fp = join(__dirname, '..', '..', '..', 'data', 'fonts', fontRel); // fonts are sources in data/ (BUILD-OUTPUT-IS-ONE-FOLDER)
if (existsSync(fp)) { if (existsSync(fp)) {
fontData = readFileSync(fp).toString('base64'); fontData = readFileSync(fp).toString('base64');
} else { } else {

View file

@ -32,7 +32,7 @@ import { dirname, join, resolve } from 'node:path';
import { TREE_NAME } from '../../../internal/_tree-name.mjs' import { TREE_NAME } from '../../../internal/_tree-name.mjs'
const __dirname = dirname(fileURLToPath(import.meta.url)); const __dirname = dirname(fileURLToPath(import.meta.url));
const ASSETS = resolve(__dirname, '..', '..', '..', 'public', 'assets'); const ASSETS = resolve(__dirname, '..', '..', '..', 'public', TREE_NAME, 'assets'); // the served assets live inside the output folder
const BANDS = { const BANDS = {
gospel: { critical: '#b42318', high: '#e07a2b', partial: '#e8c15a', covered: '#10b981' }, gospel: { critical: '#b42318', high: '#e07a2b', partial: '#e8c15a', covered: '#10b981' },

View file

@ -12,7 +12,7 @@
"build": "bash internal/build-lock.sh", "build": "bash internal/build-lock.sh",
"build:bundle": "bun x vite build", "build:bundle": "bun x vite build",
"build:bundles": "node internal/run-parallel.mjs --bundles", "build:bundles": "node internal/run-parallel.mjs --bundles",
"build:unlocked": "bun run gates:pre && bun run library-index && bun run check:library-index && bun run clean && bun run token && bun run build:bundles && bun run tree && bun run gates:post && bun run gallery", "build:unlocked": "bun run gates:pre && bun run library-index && bun run check:library-index && bun run clean && bun run token && bun run build:bundles && bun run tree && bun run gates:post",
"build:watch": "bun x vite build --watch", "build:watch": "bun x vite build --watch",
"cap:sync": "./node_modules/.bin/cap sync android", "cap:sync": "./node_modules/.bin/cap sync android",
"cap:verify": "test -f deployment/capacitor/android/app/src/main/assets/public/index.html && echo \"OK webDir copied\" || (echo \"FAIL: empty assets/public -> blank white screen. Check webDir.\" && exit 1)", "cap:verify": "test -f deployment/capacitor/android/app/src/main/assets/public/index.html && echo \"OK webDir copied\" || (echo \"FAIL: empty assets/public -> blank white screen. Check webDir.\" && exit 1)",
@ -149,8 +149,7 @@
"check:snapshot-guard:poison": "node internal/check-snapshot-guard.mjs --poison", "check:snapshot-guard:poison": "node internal/check-snapshot-guard.mjs --poison",
"check:spa-tab-order": "node internal/check-spa-tab-order.mjs", "check:spa-tab-order": "node internal/check-spa-tab-order.mjs",
"check:sri": "node internal/sri-consistency-check.mjs", "check:sri": "node internal/sri-consistency-check.mjs",
"check:staging-lang": "node internal/check-staging-forwards-lang.mjs", "check:stale-sweep-claim": "node internal/check-no-stale-sweep-claim.mjs",
"check:stale-sweep-claim": "node internal/check-no-stale-sweep-claim.mjs # a caveat that has stopped being true is a wrong number. The gospel surfaces carry a paragraph saying which pools were searched; while the GRN sweep ran they truthfully said part of it was unread, the sweep finished, and the sentence stayed for four more commits. The DATA decides here: undecided gap codes mean the claim is allowed, all-decided means it must go, so it re-arms itself for the next slow pool. Does NOT flag a claim rendered under a v-if on the pending count — that form expires on its own. Self-test: `the check:stale-sweep-claim:poison entry`. Static, sub-second.",
"check:studio-all": "node internal/check-studio-every-plugin.mjs", "check:studio-all": "node internal/check-studio-every-plugin.mjs",
"check:surfaces-read-the-tier": "node internal/check-surfaces-read-the-tier.mjs", "check:surfaces-read-the-tier": "node internal/check-surfaces-read-the-tier.mjs",
"check:surfaces-read-the-tier:poison": "node internal/check-surfaces-read-the-tier.mjs --poison", "check:surfaces-read-the-tier:poison": "node internal/check-surfaces-read-the-tier.mjs --poison",
@ -173,8 +172,8 @@
"dev": "bun x vite", "dev": "bun x vite",
"fix:sri": "node internal/fix-sri.mjs", "fix:sri": "node internal/fix-sri.mjs",
"gallery": "node internal/generate-gallery.mjs", "gallery": "node internal/generate-gallery.mjs",
"gates:post": "node internal/run-parallel.mjs --label=post -- 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: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:legend-source check:backlog-refs check:clustering-modes check:composite-embed-knobs check:composite-locale-leaks check:linked-vocabulary", "gates:post": "node internal/run-parallel.mjs --label=post -- 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: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:legend-source check:backlog-refs check:clustering-modes check:composite-embed-knobs check:composite-locale-leaks check:linked-vocabulary check:wp-embed",
"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:wp-embed check:doc-scripts", "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",
"library-index": "node internal/generate-library-index.mjs", "library-index": "node internal/generate-library-index.mjs",
"packet-fixture": "node internal/generate-packet-fixture.mjs", "packet-fixture": "node internal/generate-packet-fixture.mjs",
"packet:ci": "node internal/build-packet-fixtures.mjs", "packet:ci": "node internal/build-packet-fixtures.mjs",
@ -301,7 +300,6 @@
"check:shots": "a README screenshot that predates the bundle it shows is a picture telling a lie — and it looks IDENTICAL to a current one. Compares each shot's PNG against the BUILT BUNDLE it renders (not raw sources: an unbuilt edit is not yet in any leaf). Fix: node internal/readme-shots.mjs", "check:shots": "a README screenshot that predates the bundle it shows is a picture telling a lie — and it looks IDENTICAL to a current one. Compares each shot's PNG against the BUILT BUNDLE it renders (not raw sources: an unbuilt edit is not yet in any leaf). Fix: node internal/readme-shots.mjs",
"check:snapshot-guard": "every gospel-resources catalogue fetcher must write through scripts/_snapshot-guard.mjs, which refuses a write that loses more than a tenth of the previous snapshot. Written after a rate-limited FCBH run silently replaced 1,666 video scopes with 1,103. Static, sub-second. Self-test: `bun run check:snapshot-guard:poison` injects a raw write and the check must fail on it.", "check:snapshot-guard": "every gospel-resources catalogue fetcher must write through scripts/_snapshot-guard.mjs, which refuses a write that loses more than a tenth of the previous snapshot. Written after a rate-limited FCBH run silently replaced 1,666 video scopes with 1,103. Static, sub-second. Self-test: `bun run check:snapshot-guard:poison` injects a raw write and the check must fail on it.",
"check:spa-tab-order": "the cap-5 SPA must open on Big Picture and keep the Driver's order (Big Picture, UPG-100, Prayer Progress, Gospel Resources) — asserted as PRESENCE + tab-1 + RELATIVE order on the RENDERED rail, because the rail gained three sections and now renders 12 rows with others between them. ⚠ NOT WAVE-WIRED: it FAILS rather than skips when the SPA is unreachable, deliberately (\"a gate that cannot reach its subject has not passed, it has not run\") — correct standalone, but gates:post runs on checkouts with no server. Needs `bun run serve` on 4173.", "check:spa-tab-order": "the cap-5 SPA must open on Big Picture and keep the Driver's order (Big Picture, UPG-100, Prayer Progress, Gospel Resources) — asserted as PRESENCE + tab-1 + RELATIVE order on the RENDERED rail, because the rail gained three sections and now renders 12 rows with others between them. ⚠ NOT WAVE-WIRED: it FAILS rather than skips when the SPA is unreachable, deliberately (\"a gate that cannot reach its subject has not passed, it has not run\") — correct standalone, but gates:post runs on checkouts with no server. Needs `bun run serve` on 4173.",
"check:staging-lang": "the tree's staging page (public/doxa-maps/index.html, from internal/staging-forge.js) opened at ?lang=ar forwards lang=ar to the leaf it embeds (iframe src AND the open link), and the Gospel map inside renders gospel.mode.gap in Arabic. Negative-tested: the tree built before withLang() FAILS. --dir <tree>. Through browser-slot.sh.",
"check:stale-sweep-claim": "a caveat that has stopped being true is a wrong number. The gospel surfaces carry a paragraph saying which pools were searched; while the GRN sweep ran they truthfully said part of it was unread, the sweep finished, and the sentence stayed for four more commits. The DATA decides here: undecided gap codes mean the claim is allowed, all-decided means it must go, so it re-arms itself for the next slow pool. Does NOT flag a claim rendered under a v-if on the pending count — that form expires on its own. Self-test: `--poison`. Static, sub-second.", "check:stale-sweep-claim": "a caveat that has stopped being true is a wrong number. The gospel surfaces carry a paragraph saying which pools were searched; while the GRN sweep ran they truthfully said part of it was unread, the sweep finished, and the sentence stayed for four more commits. The DATA decides here: undecided gap codes mean the claim is allowed, all-decided means it must go, so it re-arms itself for the next slow pool. Does NOT flag a claim rendered under a v-if on the pending count — that form expires on its own. Self-test: `--poison`. Static, sub-second.",
"check:surfaces-read-the-tier": "a surface may READ the tier the pin index computed, never re-derive 'this group has nothing' from the raw resource fields. That hand-rolled field list went stale every time a source was added: 7 codes drawn as having nothing while the map showed them a link, then 28 when Scripture Earth landed. Static, sub-second. Self-test: `bun run check:surfaces-read-the-tier:poison` re-injects the old formula into every surface and each must be caught.", "check:surfaces-read-the-tier": "a surface may READ the tier the pin index computed, never re-derive 'this group has nothing' from the raw resource fields. That hand-rolled field list went stale every time a source was added: 7 codes drawn as having nothing while the map showed them a link, then 28 when Scripture Earth landed. Static, sub-second. Self-test: `bun run check:surfaces-read-the-tier:poison` re-injects the old formula into every surface and each must be caught.",
"check:tabs-localized": "a chosen locale must CHANGE the rendered tab text. check:i18n-parity compares catalogs to each OTHER, so it cannot tell a shipped translation from a dead one; tabs.json is reached only via a DYNAMIC key (SemanticTreeLegend.vue:40), invisible to any grep. Renders it and reads the result.", "check:tabs-localized": "a chosen locale must CHANGE the rendered tab text. check:i18n-parity compares catalogs to each OTHER, so it cannot tell a shipped translation from a dead one; tabs.json is reached only via a DYNAMIC key (SemanticTreeLegend.vue:40), invisible to any grep. Renders it and reads the result.",

38
public/.gitignore vendored
View file

@ -1,38 +0,0 @@
token.local.js
# The build MUTEX's stamp file (internal/build-lock.sh writes it under flock).
# It is pure runtime state — a pid, a timestamp and `by=$(whoami)` — rewritten on
# every `bun run build`. It was tracked, so each rebuild committed the BUILDING
# MACHINE'S USERNAME into the repo. Nothing reads it but the lock itself, and a
# clone that inherits a stale pid is at best noise.
.build.lock
# ── GENERATED MIRRORS OF data/ — 80.7 MB committed TWICE ─────────────────────
# generate-tree.mjs:1353 copies data/<name> -> public/assets/<name>, rmSync'ing
# the destination first, on EVERY `bun run tree`. So these five directories are
# not a source: they are a byte-identical copy of a tracked source, and both
# copies were committed.
#
# MEASURED 2026-09-24, all five verified identical file-set and hash:
# posters 378 files 40.9 MB
# people-groups 2 files 23.0 MB
# tables 19 files 14.2 MB
# legacy 4 files 2.0 MB
# vendor 3 files 0.6 MB
# Proven regenerable, not assumed: deleting public/assets/posters and running
# `bun run tree` restored all 378 files with laos.webp md5 ea65d2f1 — the same
# hash the committed copy had.
#
# This is the "80mb of images in a public folder" directly: a publish was already
# rejected once at a 74 MB asset tree, and half of that weight was a duplicate of
# something already in the same repo.
#
# ⚠ previews/ IS NOT LISTED AND MUST STAY TRACKED. It has no data/ source — it is
# generated by make-previews.py from the full posters — and it is what the poster
# WALL renders from, so a dropped folder needs it (bundlePosters defaults false,
# which ships previews and not posters). Untracking it would blank every wall.
/assets/posters/
/assets/people-groups/
/assets/tables/
/assets/pplr/
/assets/vendor/

14
public/README.md Normal file
View file

@ -0,0 +1,14 @@
# public/ — the built output
This folder is GENERATED by `bun run build`; do not edit it by hand. It holds exactly one thing:
- **`doxa-maps/`** — the output folder: 23 plugin bundles and 35 pages, self-contained.
Copy this one folder to any web host (or open it from disk) and it works.
- `doxa-maps/index.html` — the front page: the Plugin Studio, every plugin in one place.
- `doxa-maps/<plugin>/<plugin>.js` — a plugin's bundle (a custom element; load it with a `<script>` tag).
- `doxa-maps/<plugin>/<page>/index.html` — a ready-made page for one plugin view (embed it in an `<iframe>`).
- `doxa-maps/manifest.json` — every plugin, page and embed snippet, machine-readable.
- `doxa-maps/assets/` — the data, fonts and images the plugins fetch at runtime.
The folder's name comes from `name` in `vite.doxa-maps-build-config.json`. Sources live in
`plugin-designs/`, `library/` and `data/`; `rm -rf public/ && bun run build` recreates all of this.

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

Binary file not shown.

Before

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 55 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

Some files were not shown because too many files have changed in this diff Show more