Doxa Maps — public snapshot refresh
Everything the flights landed since the first snapshot: all 11 interface languages complete, the gospel-resources campaign (audio Scripture pins for 2,121 languages, the Deaf-gap completion, 3,603 resource links verified), the shared poster wall with print-in-place, prayer-walk sharing, and the 34-gate build pipeline. Tree rebuilt from source; SRI consistent; hygiene clean.
This commit is contained in:
parent
19e699a174
commit
f9433cfb63
521 changed files with 231437 additions and 53107 deletions
17
.gitignore
vendored
17
.gitignore
vendored
|
|
@ -115,5 +115,18 @@ internal-tracker/
|
|||
# ── GENERATOR OUTPUT — regenerable render artifacts, never committed ──────────
|
||||
# internal/<tool>/out/ holds what the poster/research generators RENDER
|
||||
# (upg100-posters/out alone is ~619MB / 12,623 PNGs). The generators themselves
|
||||
# are source and ARE committed; their output is derived. What actually ships is
|
||||
# the exported subset in public/assets/ (see export-public.mjs).
|
||||
# are source and ARE committed; their output is derived.
|
||||
#
|
||||
# ⚠ THIS BLOCK USED TO SAY "what actually ships is the exported subset in
|
||||
# public/assets/ (see export-public.mjs)". BOTH HALVES OF THAT WERE STALE, and
|
||||
# the sentence is why 80.7 MB sat committed twice:
|
||||
# - export-public.mjs does NOT write public/assets/. It writes a SIBLING OF THE
|
||||
# REPO (../../../../upg-100-posters, or $DOXA_PUBLIC), and says so in its own
|
||||
# header — explicitly "NOT inside public/, because capacitor packages public/
|
||||
# whole and these are 52 MB that must not ship in the APK".
|
||||
# - public/assets/<name> is now a GENERATED MIRROR of the tracked data/<name>,
|
||||
# rewritten by generate-tree.mjs on every `bun run tree`. It is derived, not
|
||||
# exported, so it is ignored in public/.gitignore — which carries the
|
||||
# measurements and the one exception (previews/, which has no data/ source
|
||||
# and must stay tracked).
|
||||
# The tracked source for shipped assets is data/<name>.
|
||||
|
|
|
|||
14
README.md
14
README.md
|
|
@ -219,8 +219,8 @@ Four steps from nothing to every map in one place.
|
|||
|
||||
```bash
|
||||
# 1. Clone (the repo root IS the studio — there is no subfolder to cd into).
|
||||
# This host is PRIVATE: it will prompt for your the forge username + token.
|
||||
git clone --branch doxa-maps-plugin-studio https://the-git-host/the-source-repository.git
|
||||
# This host is PRIVATE: it will prompt for your the git forge username + token.
|
||||
git clone --branch doxa-maps-plugin-studio https://your-git-host.example/doxa-maps.git
|
||||
cd doxa-maps
|
||||
|
||||
# 2. Install dependencies (installs 206 packages; needs Node >= 18)
|
||||
|
|
@ -239,9 +239,9 @@ spot. (If port 5173 is busy Vite picks another one and prints it; read the line
|
|||
> **You do not need to build first.** The build output in `public/` is committed, so a fresh clone has
|
||||
> plugins to show the moment the dev server starts.
|
||||
|
||||
> **If step 1 fails with `remote: Unauthorized`,** the clone is not broken — `the-git-host` is a
|
||||
> private self-hosted the forge and anonymous cloning is refused. Ask for an account, then use your
|
||||
> username plus a the forge **access token** (not your password) when git prompts.
|
||||
> **If step 1 fails with `remote: Unauthorized`,** the clone is not broken — `your-git-host.example` is a
|
||||
> private self-hosted the git forge and anonymous cloning is refused. Ask for an account, then use your
|
||||
> username plus a the git forge **access token** (not your password) when git prompts.
|
||||
|
||||
Two things worth knowing:
|
||||
|
||||
|
|
@ -295,10 +295,10 @@ manifest. To check one bundle's compiled output without touching anyone else's:
|
|||
BUNDLE=<name> bun x vite build # this bundle only; does NOT clean the tree
|
||||
```
|
||||
|
||||
### Contributing changes
|
||||
### Push as you go
|
||||
|
||||
Commit and push to your fork or branch as usual; small, frequently pushed commits are
|
||||
preferred over large local ones.
|
||||
easier to review and impossible to lose.
|
||||
|
||||
|
||||
### The production build
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
75
data/legacy/upg-neighbor/index.html
Normal file
75
data/legacy/upg-neighbor/index.html
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>legacy map host</title>
|
||||
<style>html,body{margin:0;height:100%;background:#f3f3f1}.host{position:fixed;inset:0}</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="host" id="host"></div>
|
||||
<script>
|
||||
// Sandboxed host for the LEGACY upg-neighbor IIFE (pre-bundler architecture). This page runs
|
||||
// inside an IFRAME so the old engine and its dependencies cannot collide with the modern
|
||||
// bundles -- the isolation pattern the roundup mandates ("add without breaking doxa-maps").
|
||||
var q = new URLSearchParams(location.search);
|
||||
var el = (q.get('el') || 'send-map').replace(/[^a-z0-9-]/g, '');
|
||||
var tk = q.get('tk') || '';
|
||||
// Token globals the legacy build may read.
|
||||
window.MAPBOX_TOKEN = tk; window.MAP_TOKEN = tk; window.UPG_TOKEN = tk;
|
||||
// The legacy code fetches relative "/api/people-groups/..." which only resolved on its old
|
||||
// origin. Shim fetch so those calls reach the live API from ANY host (static tree, iframe).
|
||||
var API = 'https://pray.doxa.life';
|
||||
// The cockpit profile fails closed without a configured API base and names this global in
|
||||
// its own error banner (measured: "No API base configured (apiBase / window.MAP_APP_API_URL
|
||||
// / VITE_API_BASE_URL) -- failing closed").
|
||||
window.MAP_APP_API_URL = API;
|
||||
var origFetch = window.fetch.bind(window);
|
||||
window.fetch = function (input, init) {
|
||||
try {
|
||||
var url = typeof input === 'string' ? input : (input && input.url) || '';
|
||||
if (url.indexOf('/api/') === 0) return origFetch(API + url, init);
|
||||
} catch (e) { /* fall through */ }
|
||||
return origFetch(input, init);
|
||||
};
|
||||
// ── KEYLESS SHIM ──────────────────────────────────────────────────────────
|
||||
// The legacy bundle expects a mapboxgl GLOBAL and requests mapbox://styles/mapbox/light-v11.
|
||||
// MapLibre is API-compatible with the mapbox-gl v1 surface the bundle uses, and OpenFreeMap
|
||||
// Positron is the keyless twin of light-v11 -- so the shim below makes every legacy map run
|
||||
// WITHOUT a Mapbox token: load our self-hosted MapLibre, alias it as mapboxgl, and rewrite
|
||||
// any mapbox:// style to the keyless URL inside a patched Map constructor.
|
||||
var KEYLESS_STYLE = 'https://tiles.openfreemap.org/styles/positron';
|
||||
var lib = document.createElement('link'); lib.rel='stylesheet'; lib.href='./maplibre-gl.css';
|
||||
document.head.appendChild(lib);
|
||||
var ml = document.createElement('script');
|
||||
ml.src = './maplibre-gl.js';
|
||||
ml.onload = function () {
|
||||
var Real = window.maplibregl.Map;
|
||||
function PatchedMap (opts) {
|
||||
if (opts && typeof opts.style === 'string' && opts.style.indexOf('mapbox://') === 0) opts.style = KEYLESS_STYLE;
|
||||
return new Real(opts);
|
||||
}
|
||||
PatchedMap.prototype = Real.prototype;
|
||||
window.mapboxgl = Object.assign({}, window.maplibregl, { Map: PatchedMap, accessToken: '' });
|
||||
loadLegacy();
|
||||
};
|
||||
ml.onerror = function () { loadLegacy(); };
|
||||
document.head.appendChild(ml);
|
||||
|
||||
function loadLegacy () {
|
||||
var s = document.createElement('script');
|
||||
s.src = './upg-neighbor.iife.js';
|
||||
s.onload = function () {
|
||||
var node = document.createElement(el);
|
||||
node.style.cssText = 'display:block;width:100%;height:100%';
|
||||
// The legacy elements use the SAME profile-config attribute contract as the modern
|
||||
// bundles (measured: without it the element renders its own usage banner).
|
||||
node.setAttribute('profile-config', JSON.stringify({ profile: el, tk: tk }));
|
||||
document.getElementById('host').appendChild(node);
|
||||
};
|
||||
s.onerror = function () { document.body.innerHTML = '<p style="font:14px system-ui;padding:20px">legacy bundle failed to load</p>'; };
|
||||
document.head.appendChild(s);
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
1
data/legacy/upg-neighbor/maplibre-gl.css
Normal file
1
data/legacy/upg-neighbor/maplibre-gl.css
Normal file
File diff suppressed because one or more lines are too long
59
data/legacy/upg-neighbor/maplibre-gl.js
Normal file
59
data/legacy/upg-neighbor/maplibre-gl.js
Normal file
File diff suppressed because one or more lines are too long
548
data/legacy/upg-neighbor/upg-neighbor.iife.js
Normal file
548
data/legacy/upg-neighbor/upg-neighbor.iife.js
Normal file
File diff suppressed because one or more lines are too long
355
data/posters/andean-caribbean.html
Normal file
355
data/posters/andean-caribbean.html
Normal file
File diff suppressed because one or more lines are too long
355
data/posters/asia-china-tibet-autonomous-region.html
Normal file
355
data/posters/asia-china-tibet-autonomous-region.html
Normal file
File diff suppressed because one or more lines are too long
355
data/posters/asia-china-yunnan-province.html
Normal file
355
data/posters/asia-china-yunnan-province.html
Normal file
File diff suppressed because one or more lines are too long
355
data/posters/asia-india-andhra-pradesh.html
Normal file
355
data/posters/asia-india-andhra-pradesh.html
Normal file
File diff suppressed because one or more lines are too long
355
data/posters/asia-india-gujarat.html
Normal file
355
data/posters/asia-india-gujarat.html
Normal file
File diff suppressed because one or more lines are too long
355
data/posters/asia-india-karnataka.html
Normal file
355
data/posters/asia-india-karnataka.html
Normal file
File diff suppressed because one or more lines are too long
355
data/posters/asia-india-kerala.html
Normal file
355
data/posters/asia-india-kerala.html
Normal file
File diff suppressed because one or more lines are too long
355
data/posters/asia-india-madhya-pradesh.html
Normal file
355
data/posters/asia-india-madhya-pradesh.html
Normal file
File diff suppressed because one or more lines are too long
355
data/posters/asia-india-odisha.html
Normal file
355
data/posters/asia-india-odisha.html
Normal file
File diff suppressed because one or more lines are too long
355
data/posters/asia-india-rajasthan.html
Normal file
355
data/posters/asia-india-rajasthan.html
Normal file
File diff suppressed because one or more lines are too long
355
data/posters/asia-india-tamil-nadu.html
Normal file
355
data/posters/asia-india-tamil-nadu.html
Normal file
File diff suppressed because one or more lines are too long
355
data/posters/asia-india-uttarakhand.html
Normal file
355
data/posters/asia-india-uttarakhand.html
Normal file
File diff suppressed because one or more lines are too long
355
data/posters/asia-india-west-bengal.html
Normal file
355
data/posters/asia-india-west-bengal.html
Normal file
File diff suppressed because one or more lines are too long
355
data/posters/brazil.html
Normal file
355
data/posters/brazil.html
Normal file
File diff suppressed because one or more lines are too long
355
data/posters/central-africa.html
Normal file
355
data/posters/central-africa.html
Normal file
File diff suppressed because one or more lines are too long
355
data/posters/deaf.html
Normal file
355
data/posters/deaf.html
Normal file
File diff suppressed because one or more lines are too long
355
data/posters/east-africa.html
Normal file
355
data/posters/east-africa.html
Normal file
File diff suppressed because one or more lines are too long
355
data/posters/europe.html
Normal file
355
data/posters/europe.html
Normal file
File diff suppressed because one or more lines are too long
355
data/posters/laos.html
Normal file
355
data/posters/laos.html
Normal file
File diff suppressed because one or more lines are too long
355
data/posters/maghreb-egypt.html
Normal file
355
data/posters/maghreb-egypt.html
Normal file
File diff suppressed because one or more lines are too long
355
data/posters/mainland-se-asia-philippines.html
Normal file
355
data/posters/mainland-se-asia-philippines.html
Normal file
File diff suppressed because one or more lines are too long
355
data/posters/middle-east-core.html
Normal file
355
data/posters/middle-east-core.html
Normal file
File diff suppressed because one or more lines are too long
355
data/posters/oceania-indonesia.html
Normal file
355
data/posters/oceania-indonesia.html
Normal file
File diff suppressed because one or more lines are too long
355
data/posters/southern-africa-islands.html
Normal file
355
data/posters/southern-africa-islands.html
Normal file
File diff suppressed because one or more lines are too long
355
data/posters/sudan.html
Normal file
355
data/posters/sudan.html
Normal file
File diff suppressed because one or more lines are too long
355
data/posters/uncovered-nas-a.html
Normal file
355
data/posters/uncovered-nas-a.html
Normal file
File diff suppressed because one or more lines are too long
355
data/posters/uncovered-scatter.html
Normal file
355
data/posters/uncovered-scatter.html
Normal file
File diff suppressed because one or more lines are too long
355
data/posters/west-africa.html
Normal file
355
data/posters/west-africa.html
Normal file
File diff suppressed because one or more lines are too long
1
data/tables/arclight-index.json
Normal file
1
data/tables/arclight-index.json
Normal file
File diff suppressed because one or more lines are too long
1
data/tables/arclight-share-urls.json
Normal file
1
data/tables/arclight-share-urls.json
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large
Load diff
3800
data/tables/door43-index.json
Normal file
3800
data/tables/door43-index.json
Normal file
File diff suppressed because it is too large
Load diff
1
data/tables/ebible-index.json
Normal file
1
data/tables/ebible-index.json
Normal file
File diff suppressed because one or more lines are too long
34214
data/tables/fcbh-index.json
Normal file
34214
data/tables/fcbh-index.json
Normal file
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
758
data/tables/godtools-index.json
Normal file
758
data/tables/godtools-index.json
Normal file
|
|
@ -0,0 +1,758 @@
|
|||
{
|
||||
"_note": "Cru gospel presentations (GodTools) on knowgod.com, keyed by ISO 639-3. godtools.org is a PARKED DOMAIN. knowgod.com is a client-rendered shell that answers 200 with identical bytes for any URL, so existence is proved from Cru's own API by intersecting a resource's latest-translations with a language's translations — never from the page.",
|
||||
"source": "https://mobile-content-api.cru.org/languages + https://mobile-content-api.cru.org/resources",
|
||||
"site": "https://knowgod.com",
|
||||
"generated": "2026-09-24",
|
||||
"tools_allowed": [
|
||||
"kgp (Knowing God Personally)",
|
||||
"fourlaws (Four Spiritual Laws)",
|
||||
"honorrestored (Honor Restored)",
|
||||
"fantasticfacts (Four Fantastic Facts)",
|
||||
"satisfied (Satisfied?)",
|
||||
"connectingwithgod (Connecting With God)"
|
||||
],
|
||||
"counts": {
|
||||
"languages": 123,
|
||||
"resources": 45,
|
||||
"codes": 105,
|
||||
"noIso": 0,
|
||||
"noTool": 9
|
||||
},
|
||||
"codes": {
|
||||
"afr": {
|
||||
"u": "https://knowgod.com/af/kgp",
|
||||
"n": "Knowing God Personally",
|
||||
"ln": "Afrikaans",
|
||||
"locale": "af",
|
||||
"tool": "kgp"
|
||||
},
|
||||
"sqi": {
|
||||
"u": "https://knowgod.com/sq/kgp",
|
||||
"n": "Knowing God Personally",
|
||||
"ln": "Albanian",
|
||||
"locale": "sq",
|
||||
"tool": "kgp"
|
||||
},
|
||||
"amh": {
|
||||
"u": "https://knowgod.com/am/kgp",
|
||||
"n": "Knowing God Personally",
|
||||
"ln": "Amharic",
|
||||
"locale": "am",
|
||||
"tool": "kgp"
|
||||
},
|
||||
"ara": {
|
||||
"u": "https://knowgod.com/ar/kgp",
|
||||
"n": "Knowing God Personally",
|
||||
"ln": "Arabic",
|
||||
"locale": "ar",
|
||||
"tool": "kgp"
|
||||
},
|
||||
"hye": {
|
||||
"u": "https://knowgod.com/hy/kgp",
|
||||
"n": "Knowing God Personally",
|
||||
"ln": "Armenian",
|
||||
"locale": "hy",
|
||||
"tool": "kgp"
|
||||
},
|
||||
"aze": {
|
||||
"u": "https://knowgod.com/az/fourlaws",
|
||||
"n": "Four Spiritual Laws",
|
||||
"ln": "Azerbaijani",
|
||||
"locale": "az",
|
||||
"tool": "fourlaws"
|
||||
},
|
||||
"bam": {
|
||||
"u": "https://knowgod.com/bm/fourlaws",
|
||||
"n": "Four Spiritual Laws",
|
||||
"ln": "Bambara",
|
||||
"locale": "bm",
|
||||
"tool": "fourlaws"
|
||||
},
|
||||
"bem": {
|
||||
"u": "https://knowgod.com/bem/fourlaws",
|
||||
"n": "Four Spiritual Laws",
|
||||
"ln": "Bemba",
|
||||
"locale": "bem",
|
||||
"tool": "fourlaws"
|
||||
},
|
||||
"ben": {
|
||||
"u": "https://knowgod.com/bn/kgp",
|
||||
"n": "Knowing God Personally",
|
||||
"ln": "Bengali",
|
||||
"locale": "bn",
|
||||
"tool": "kgp"
|
||||
},
|
||||
"bis": {
|
||||
"u": "https://knowgod.com/bi/kgp",
|
||||
"n": "Knowing God Personally",
|
||||
"ln": "Bislama",
|
||||
"locale": "bi",
|
||||
"tool": "kgp"
|
||||
},
|
||||
"bos": {
|
||||
"u": "https://knowgod.com/bs-BA/kgp",
|
||||
"n": "Knowing God Personally",
|
||||
"ln": "Bosnian (Bosnia and Herzegovina)",
|
||||
"locale": "bs-BA",
|
||||
"tool": "kgp"
|
||||
},
|
||||
"bul": {
|
||||
"u": "https://knowgod.com/bg/kgp",
|
||||
"n": "Knowing God Personally",
|
||||
"ln": "Bulgarian",
|
||||
"locale": "bg",
|
||||
"tool": "kgp"
|
||||
},
|
||||
"mya": {
|
||||
"u": "https://knowgod.com/my/kgp",
|
||||
"n": "Knowing God Personally",
|
||||
"ln": "Burmese",
|
||||
"locale": "my",
|
||||
"tool": "kgp"
|
||||
},
|
||||
"ceb": {
|
||||
"u": "https://knowgod.com/ceb/fourlaws",
|
||||
"n": "Four Spiritual Laws",
|
||||
"ln": "Cebuano",
|
||||
"locale": "ceb",
|
||||
"tool": "fourlaws"
|
||||
},
|
||||
"nya": {
|
||||
"u": "https://knowgod.com/ny/fourlaws",
|
||||
"n": "Four Spiritual Laws",
|
||||
"ln": "Chichewa",
|
||||
"locale": "ny",
|
||||
"tool": "fourlaws"
|
||||
},
|
||||
"zho": {
|
||||
"u": "https://knowgod.com/zh-Hans/kgp",
|
||||
"n": "Knowing God Personally",
|
||||
"ln": "Chinese Simplified",
|
||||
"locale": "zh-Hans",
|
||||
"tool": "kgp"
|
||||
},
|
||||
"hrv": {
|
||||
"u": "https://knowgod.com/hr/kgp",
|
||||
"n": "Knowing God Personally",
|
||||
"ln": "Croatian",
|
||||
"locale": "hr",
|
||||
"tool": "kgp"
|
||||
},
|
||||
"ces": {
|
||||
"u": "https://knowgod.com/cs/kgp",
|
||||
"n": "Knowing God Personally",
|
||||
"ln": "Czech",
|
||||
"locale": "cs",
|
||||
"tool": "kgp"
|
||||
},
|
||||
"dan": {
|
||||
"u": "https://knowgod.com/da/kgp",
|
||||
"n": "Knowing God Personally",
|
||||
"ln": "Danish",
|
||||
"locale": "da",
|
||||
"tool": "kgp"
|
||||
},
|
||||
"nld": {
|
||||
"u": "https://knowgod.com/nl/fourlaws",
|
||||
"n": "Four Spiritual Laws",
|
||||
"ln": "Dutch",
|
||||
"locale": "nl",
|
||||
"tool": "fourlaws"
|
||||
},
|
||||
"dzo": {
|
||||
"u": "https://knowgod.com/dz/fourlaws",
|
||||
"n": "Four Spiritual Laws",
|
||||
"ln": "Dzongkha",
|
||||
"locale": "dz",
|
||||
"tool": "fourlaws"
|
||||
},
|
||||
"eng": {
|
||||
"u": "https://knowgod.com/en/kgp",
|
||||
"n": "Knowing God Personally",
|
||||
"ln": "English",
|
||||
"locale": "en",
|
||||
"tool": "kgp"
|
||||
},
|
||||
"est": {
|
||||
"u": "https://knowgod.com/et/kgp",
|
||||
"n": "Knowing God Personally",
|
||||
"ln": "Estonian (Estonia)",
|
||||
"locale": "et",
|
||||
"tool": "kgp"
|
||||
},
|
||||
"fij": {
|
||||
"u": "https://knowgod.com/fj/fourlaws",
|
||||
"n": "Four Spiritual Laws",
|
||||
"ln": "Fijian",
|
||||
"locale": "fj",
|
||||
"tool": "fourlaws"
|
||||
},
|
||||
"fil": {
|
||||
"u": "https://knowgod.com/fil/kgp",
|
||||
"n": "Knowing God Personally",
|
||||
"ln": "Filipino",
|
||||
"locale": "fil",
|
||||
"tool": "kgp"
|
||||
},
|
||||
"fin": {
|
||||
"u": "https://knowgod.com/fi/kgp",
|
||||
"n": "Knowing God Personally",
|
||||
"ln": "Finnish",
|
||||
"locale": "fi",
|
||||
"tool": "kgp"
|
||||
},
|
||||
"fra": {
|
||||
"u": "https://knowgod.com/fr/kgp",
|
||||
"n": "Knowing God Personally",
|
||||
"ln": "French",
|
||||
"locale": "fr",
|
||||
"tool": "kgp"
|
||||
},
|
||||
"kat": {
|
||||
"u": "https://knowgod.com/ka/fourlaws",
|
||||
"n": "Four Spiritual Laws",
|
||||
"ln": "Georgian",
|
||||
"locale": "ka",
|
||||
"tool": "fourlaws"
|
||||
},
|
||||
"deu": {
|
||||
"u": "https://knowgod.com/de/kgp",
|
||||
"n": "Knowing God Personally",
|
||||
"ln": "German",
|
||||
"locale": "de",
|
||||
"tool": "kgp"
|
||||
},
|
||||
"ell": {
|
||||
"u": "https://knowgod.com/el/kgp",
|
||||
"n": "Knowing God Personally",
|
||||
"ln": "Greek",
|
||||
"locale": "el",
|
||||
"tool": "kgp"
|
||||
},
|
||||
"guj": {
|
||||
"u": "https://knowgod.com/gu/fourlaws",
|
||||
"n": "Four Spiritual Laws",
|
||||
"ln": "Gujarati",
|
||||
"locale": "gu",
|
||||
"tool": "fourlaws"
|
||||
},
|
||||
"hat": {
|
||||
"u": "https://knowgod.com/ht-HT/fourlaws",
|
||||
"n": "Four Spiritual Laws",
|
||||
"ln": "Haitian Creole",
|
||||
"locale": "ht-HT",
|
||||
"tool": "fourlaws"
|
||||
},
|
||||
"hau": {
|
||||
"u": "https://knowgod.com/ha/kgp",
|
||||
"n": "Knowing God Personally",
|
||||
"ln": "Hausa",
|
||||
"locale": "ha",
|
||||
"tool": "kgp"
|
||||
},
|
||||
"heb": {
|
||||
"u": "https://knowgod.com/he/kgp",
|
||||
"n": "Knowing God Personally",
|
||||
"ln": "Hebrew",
|
||||
"locale": "he",
|
||||
"tool": "kgp"
|
||||
},
|
||||
"hin": {
|
||||
"u": "https://knowgod.com/hi/kgp",
|
||||
"n": "Knowing God Personally",
|
||||
"ln": "Hindi",
|
||||
"locale": "hi",
|
||||
"tool": "kgp"
|
||||
},
|
||||
"hmn": {
|
||||
"u": "https://knowgod.com/hmn/honorrestored",
|
||||
"n": "Honor Restored",
|
||||
"ln": "Hmong",
|
||||
"locale": "hmn",
|
||||
"tool": "honorrestored"
|
||||
},
|
||||
"hun": {
|
||||
"u": "https://knowgod.com/hu/kgp",
|
||||
"n": "Knowing God Personally",
|
||||
"ln": "Hungarian",
|
||||
"locale": "hu",
|
||||
"tool": "kgp"
|
||||
},
|
||||
"isl": {
|
||||
"u": "https://knowgod.com/is-IS/kgp",
|
||||
"n": "Knowing God Personally",
|
||||
"ln": "Icelandic (Iceland)",
|
||||
"locale": "is-IS",
|
||||
"tool": "kgp"
|
||||
},
|
||||
"ibo": {
|
||||
"u": "https://knowgod.com/ig/fourlaws",
|
||||
"n": "Four Spiritual Laws",
|
||||
"ln": "Igbo",
|
||||
"locale": "ig",
|
||||
"tool": "fourlaws"
|
||||
},
|
||||
"ind": {
|
||||
"u": "https://knowgod.com/id/kgp",
|
||||
"n": "Knowing God Personally",
|
||||
"ln": "Indonesian",
|
||||
"locale": "id",
|
||||
"tool": "kgp"
|
||||
},
|
||||
"ita": {
|
||||
"u": "https://knowgod.com/it/kgp",
|
||||
"n": "Knowing God Personally",
|
||||
"ln": "Italian",
|
||||
"locale": "it",
|
||||
"tool": "kgp"
|
||||
},
|
||||
"jpn": {
|
||||
"u": "https://knowgod.com/ja/kgp",
|
||||
"n": "Knowing God Personally",
|
||||
"ln": "Japanese",
|
||||
"locale": "ja",
|
||||
"tool": "kgp"
|
||||
},
|
||||
"jav": {
|
||||
"u": "https://knowgod.com/jv/fourlaws",
|
||||
"n": "Four Spiritual Laws",
|
||||
"ln": "Javanese",
|
||||
"locale": "jv",
|
||||
"tool": "fourlaws"
|
||||
},
|
||||
"kan": {
|
||||
"u": "https://knowgod.com/kn/kgp",
|
||||
"n": "Knowing God Personally",
|
||||
"ln": "Kannada",
|
||||
"locale": "kn",
|
||||
"tool": "kgp"
|
||||
},
|
||||
"kaz": {
|
||||
"u": "https://knowgod.com/kk/fourlaws",
|
||||
"n": "Four Spiritual Laws",
|
||||
"ln": "Kazakh",
|
||||
"locale": "kk",
|
||||
"tool": "fourlaws"
|
||||
},
|
||||
"kha": {
|
||||
"u": "https://knowgod.com/kha/fourlaws",
|
||||
"n": "Four Spiritual Laws",
|
||||
"ln": "Khasi",
|
||||
"locale": "kha",
|
||||
"tool": "fourlaws"
|
||||
},
|
||||
"khm": {
|
||||
"u": "https://knowgod.com/km-KH/fourlaws",
|
||||
"n": "Four Spiritual Laws",
|
||||
"ln": "Khmer",
|
||||
"locale": "km-KH",
|
||||
"tool": "fourlaws"
|
||||
},
|
||||
"kik": {
|
||||
"u": "https://knowgod.com/ki/fourlaws",
|
||||
"n": "Four Spiritual Laws",
|
||||
"ln": "Kikuyu",
|
||||
"locale": "ki",
|
||||
"tool": "fourlaws"
|
||||
},
|
||||
"kin": {
|
||||
"u": "https://knowgod.com/rw/honorrestored",
|
||||
"n": "Honor Restored",
|
||||
"ln": "Kinyarwanda",
|
||||
"locale": "rw",
|
||||
"tool": "honorrestored"
|
||||
},
|
||||
"run": {
|
||||
"u": "https://knowgod.com/rn/kgp",
|
||||
"n": "Knowing God Personally",
|
||||
"ln": "Kirundi",
|
||||
"locale": "rn",
|
||||
"tool": "kgp"
|
||||
},
|
||||
"ktu": {
|
||||
"u": "https://knowgod.com/ktu/fourlaws",
|
||||
"n": "Four Spiritual Laws",
|
||||
"ln": "Kituba",
|
||||
"locale": "ktu",
|
||||
"tool": "fourlaws"
|
||||
},
|
||||
"kor": {
|
||||
"u": "https://knowgod.com/ko/kgp",
|
||||
"n": "Knowing God Personally",
|
||||
"ln": "Korean",
|
||||
"locale": "ko",
|
||||
"tool": "kgp"
|
||||
},
|
||||
"ckb": {
|
||||
"u": "https://knowgod.com/ckb/fourlaws",
|
||||
"n": "Four Spiritual Laws",
|
||||
"ln": "Kurdish Sorani",
|
||||
"locale": "ckb",
|
||||
"tool": "fourlaws"
|
||||
},
|
||||
"kir": {
|
||||
"u": "https://knowgod.com/ky/fourlaws",
|
||||
"n": "Four Spiritual Laws",
|
||||
"ln": "Kyrgyz",
|
||||
"locale": "ky",
|
||||
"tool": "fourlaws"
|
||||
},
|
||||
"lao": {
|
||||
"u": "https://knowgod.com/lo/kgp",
|
||||
"n": "Knowing God Personally",
|
||||
"ln": "Lao",
|
||||
"locale": "lo",
|
||||
"tool": "kgp"
|
||||
},
|
||||
"lav": {
|
||||
"u": "https://knowgod.com/lv/kgp",
|
||||
"n": "Knowing God Personally",
|
||||
"ln": "Latvian",
|
||||
"locale": "lv",
|
||||
"tool": "kgp"
|
||||
},
|
||||
"lin": {
|
||||
"u": "https://knowgod.com/ln/kgp",
|
||||
"n": "Knowing God Personally",
|
||||
"ln": "Lingala",
|
||||
"locale": "ln",
|
||||
"tool": "kgp"
|
||||
},
|
||||
"lit": {
|
||||
"u": "https://knowgod.com/lt-LT/fourlaws",
|
||||
"n": "Four Spiritual Laws",
|
||||
"ln": "Lithuanian",
|
||||
"locale": "lt-LT",
|
||||
"tool": "fourlaws"
|
||||
},
|
||||
"lug": {
|
||||
"u": "https://knowgod.com/lg/fourlaws",
|
||||
"n": "Four Spiritual Laws",
|
||||
"ln": "Luganda",
|
||||
"locale": "lg",
|
||||
"tool": "fourlaws"
|
||||
},
|
||||
"mkd": {
|
||||
"u": "https://knowgod.com/mk/kgp",
|
||||
"n": "Knowing God Personally",
|
||||
"ln": "Macedonian",
|
||||
"locale": "mk",
|
||||
"tool": "kgp"
|
||||
},
|
||||
"mlg": {
|
||||
"u": "https://knowgod.com/mg/kgp",
|
||||
"n": "Knowing God Personally",
|
||||
"ln": "Malagasy",
|
||||
"locale": "mg",
|
||||
"tool": "kgp"
|
||||
},
|
||||
"msa": {
|
||||
"u": "https://knowgod.com/ms/kgp",
|
||||
"n": "Knowing God Personally",
|
||||
"ln": "Malay",
|
||||
"locale": "ms",
|
||||
"tool": "kgp"
|
||||
},
|
||||
"mal": {
|
||||
"u": "https://knowgod.com/ml/fourlaws",
|
||||
"n": "Four Spiritual Laws",
|
||||
"ln": "Malayalam",
|
||||
"locale": "ml",
|
||||
"tool": "fourlaws"
|
||||
},
|
||||
"mri": {
|
||||
"u": "https://knowgod.com/mi/kgp",
|
||||
"n": "Knowing God Personally",
|
||||
"ln": "Māori",
|
||||
"locale": "mi",
|
||||
"tool": "kgp"
|
||||
},
|
||||
"mar": {
|
||||
"u": "https://knowgod.com/mr/kgp",
|
||||
"n": "Knowing God Personally",
|
||||
"ln": "Marathi",
|
||||
"locale": "mr",
|
||||
"tool": "kgp"
|
||||
},
|
||||
"mon": {
|
||||
"u": "https://knowgod.com/mn/kgp",
|
||||
"n": "Knowing God Personally",
|
||||
"ln": "Mongolian",
|
||||
"locale": "mn",
|
||||
"tool": "kgp"
|
||||
},
|
||||
"nep": {
|
||||
"u": "https://knowgod.com/ne/kgp",
|
||||
"n": "Knowing God Personally",
|
||||
"ln": "Nepali",
|
||||
"locale": "ne",
|
||||
"tool": "kgp"
|
||||
},
|
||||
"nor": {
|
||||
"u": "https://knowgod.com/no/kgp",
|
||||
"n": "Knowing God Personally",
|
||||
"ln": "Norwegian",
|
||||
"locale": "no",
|
||||
"tool": "kgp"
|
||||
},
|
||||
"ori": {
|
||||
"u": "https://knowgod.com/or/kgp",
|
||||
"n": "Knowing God Personally",
|
||||
"ln": "Oriya",
|
||||
"locale": "or",
|
||||
"tool": "kgp"
|
||||
},
|
||||
"orm": {
|
||||
"u": "https://knowgod.com/om/kgp",
|
||||
"n": "Knowing God Personally",
|
||||
"ln": "Oromo",
|
||||
"locale": "om",
|
||||
"tool": "kgp"
|
||||
},
|
||||
"pap": {
|
||||
"u": "https://knowgod.com/pap/kgp",
|
||||
"n": "Knowing God Personally",
|
||||
"ln": "Papiamentu",
|
||||
"locale": "pap",
|
||||
"tool": "kgp"
|
||||
},
|
||||
"pus": {
|
||||
"u": "https://knowgod.com/ps/fourlaws",
|
||||
"n": "Four Spiritual Laws",
|
||||
"ln": "Pashto",
|
||||
"locale": "ps",
|
||||
"tool": "fourlaws"
|
||||
},
|
||||
"fas": {
|
||||
"u": "https://knowgod.com/fa/kgp",
|
||||
"n": "Knowing God Personally",
|
||||
"ln": "Persian",
|
||||
"locale": "fa",
|
||||
"tool": "kgp"
|
||||
},
|
||||
"pol": {
|
||||
"u": "https://knowgod.com/pl/kgp",
|
||||
"n": "Knowing God Personally",
|
||||
"ln": "Polish",
|
||||
"locale": "pl",
|
||||
"tool": "kgp"
|
||||
},
|
||||
"por": {
|
||||
"u": "https://knowgod.com/pt/kgp",
|
||||
"n": "Knowing God Personally",
|
||||
"ln": "Portuguese",
|
||||
"locale": "pt",
|
||||
"tool": "kgp"
|
||||
},
|
||||
"pan": {
|
||||
"u": "https://knowgod.com/pa/kgp",
|
||||
"n": "Knowing God Personally",
|
||||
"ln": "Punjabi",
|
||||
"locale": "pa",
|
||||
"tool": "kgp"
|
||||
},
|
||||
"ron": {
|
||||
"u": "https://knowgod.com/ro/kgp",
|
||||
"n": "Knowing God Personally",
|
||||
"ln": "Romanian",
|
||||
"locale": "ro",
|
||||
"tool": "kgp"
|
||||
},
|
||||
"rus": {
|
||||
"u": "https://knowgod.com/ru/kgp",
|
||||
"n": "Knowing God Personally",
|
||||
"ln": "Russian",
|
||||
"locale": "ru",
|
||||
"tool": "kgp"
|
||||
},
|
||||
"smo": {
|
||||
"u": "https://knowgod.com/sm/kgp",
|
||||
"n": "Knowing God Personally",
|
||||
"ln": "Samoan",
|
||||
"locale": "sm",
|
||||
"tool": "kgp"
|
||||
},
|
||||
"srp": {
|
||||
"u": "https://knowgod.com/sr-ME/kgp",
|
||||
"n": "Knowing God Personally",
|
||||
"ln": "Serbian (Montenegro)",
|
||||
"locale": "sr-ME",
|
||||
"tool": "kgp"
|
||||
},
|
||||
"sna": {
|
||||
"u": "https://knowgod.com/sn/kgp",
|
||||
"n": "Knowing God Personally",
|
||||
"ln": "Shona",
|
||||
"locale": "sn",
|
||||
"tool": "kgp"
|
||||
},
|
||||
"sid": {
|
||||
"u": "https://knowgod.com/sid/fourlaws",
|
||||
"n": "Four Spiritual Laws",
|
||||
"ln": "Sidama",
|
||||
"locale": "sid",
|
||||
"tool": "fourlaws"
|
||||
},
|
||||
"sin": {
|
||||
"u": "https://knowgod.com/si/kgp",
|
||||
"n": "Knowing God Personally",
|
||||
"ln": "Sinhala",
|
||||
"locale": "si",
|
||||
"tool": "kgp"
|
||||
},
|
||||
"slk": {
|
||||
"u": "https://knowgod.com/sk/kgp",
|
||||
"n": "Knowing God Personally",
|
||||
"ln": "Slovak",
|
||||
"locale": "sk",
|
||||
"tool": "kgp"
|
||||
},
|
||||
"slv": {
|
||||
"u": "https://knowgod.com/sl/kgp",
|
||||
"n": "Knowing God Personally",
|
||||
"ln": "Slovenian",
|
||||
"locale": "sl",
|
||||
"tool": "kgp"
|
||||
},
|
||||
"som": {
|
||||
"u": "https://knowgod.com/so/kgp",
|
||||
"n": "Knowing God Personally",
|
||||
"ln": "Somali",
|
||||
"locale": "so",
|
||||
"tool": "kgp"
|
||||
},
|
||||
"spa": {
|
||||
"u": "https://knowgod.com/es/kgp",
|
||||
"n": "Knowing God Personally",
|
||||
"ln": "Spanish",
|
||||
"locale": "es",
|
||||
"tool": "kgp"
|
||||
},
|
||||
"swa": {
|
||||
"u": "https://knowgod.com/sw/kgp",
|
||||
"n": "Knowing God Personally",
|
||||
"ln": "Swahili",
|
||||
"locale": "sw",
|
||||
"tool": "kgp"
|
||||
},
|
||||
"swe": {
|
||||
"u": "https://knowgod.com/sv/kgp",
|
||||
"n": "Knowing God Personally",
|
||||
"ln": "Swedish",
|
||||
"locale": "sv",
|
||||
"tool": "kgp"
|
||||
},
|
||||
"tgk": {
|
||||
"u": "https://knowgod.com/tg/fourlaws",
|
||||
"n": "Four Spiritual Laws",
|
||||
"ln": "Tajik",
|
||||
"locale": "tg",
|
||||
"tool": "fourlaws"
|
||||
},
|
||||
"tam": {
|
||||
"u": "https://knowgod.com/ta/kgp",
|
||||
"n": "Knowing God Personally",
|
||||
"ln": "Tamil",
|
||||
"locale": "ta",
|
||||
"tool": "kgp"
|
||||
},
|
||||
"tel": {
|
||||
"u": "https://knowgod.com/te-IN/kgp",
|
||||
"n": "Knowing God Personally",
|
||||
"ln": "Telugu (India)",
|
||||
"locale": "te-IN",
|
||||
"tool": "kgp"
|
||||
},
|
||||
"tet": {
|
||||
"u": "https://knowgod.com/tet/fourlaws",
|
||||
"n": "Four Spiritual Laws",
|
||||
"ln": "Tetun",
|
||||
"locale": "tet",
|
||||
"tool": "fourlaws"
|
||||
},
|
||||
"tha": {
|
||||
"u": "https://knowgod.com/th/kgp",
|
||||
"n": "Knowing God Personally",
|
||||
"ln": "Thai",
|
||||
"locale": "th",
|
||||
"tool": "kgp"
|
||||
},
|
||||
"bod": {
|
||||
"u": "https://knowgod.com/bo/fourlaws",
|
||||
"n": "Four Spiritual Laws",
|
||||
"ln": "Tibetan",
|
||||
"locale": "bo",
|
||||
"tool": "fourlaws"
|
||||
},
|
||||
"tpi": {
|
||||
"u": "https://knowgod.com/tpi/kgp",
|
||||
"n": "Knowing God Personally",
|
||||
"ln": "Tok Pisin",
|
||||
"locale": "tpi",
|
||||
"tool": "kgp"
|
||||
},
|
||||
"ton": {
|
||||
"u": "https://knowgod.com/to/kgp",
|
||||
"n": "Knowing God Personally",
|
||||
"ln": "Tongan",
|
||||
"locale": "to",
|
||||
"tool": "kgp"
|
||||
},
|
||||
"tur": {
|
||||
"u": "https://knowgod.com/tr/fourlaws",
|
||||
"n": "Four Spiritual Laws",
|
||||
"ln": "Turkish",
|
||||
"locale": "tr",
|
||||
"tool": "fourlaws"
|
||||
},
|
||||
"ukr": {
|
||||
"u": "https://knowgod.com/uk/kgp",
|
||||
"n": "Knowing God Personally",
|
||||
"ln": "Ukrainian",
|
||||
"locale": "uk",
|
||||
"tool": "kgp"
|
||||
},
|
||||
"urd": {
|
||||
"u": "https://knowgod.com/ur/kgp",
|
||||
"n": "Knowing God Personally",
|
||||
"ln": "Urdu",
|
||||
"locale": "ur",
|
||||
"tool": "kgp"
|
||||
},
|
||||
"uzb": {
|
||||
"u": "https://knowgod.com/uz/fourlaws",
|
||||
"n": "Four Spiritual Laws",
|
||||
"ln": "Uzbek",
|
||||
"locale": "uz",
|
||||
"tool": "fourlaws"
|
||||
},
|
||||
"vie": {
|
||||
"u": "https://knowgod.com/vi/kgp",
|
||||
"n": "Knowing God Personally",
|
||||
"ln": "Vietnamese",
|
||||
"locale": "vi",
|
||||
"tool": "kgp"
|
||||
},
|
||||
"cym": {
|
||||
"u": "https://knowgod.com/cy/kgp",
|
||||
"n": "Knowing God Personally",
|
||||
"ln": "Welsh",
|
||||
"locale": "cy",
|
||||
"tool": "kgp"
|
||||
},
|
||||
"yor": {
|
||||
"u": "https://knowgod.com/yo/kgp",
|
||||
"n": "Knowing God Personally",
|
||||
"ln": "Yoruba",
|
||||
"locale": "yo",
|
||||
"tool": "kgp"
|
||||
},
|
||||
"zul": {
|
||||
"u": "https://knowgod.com/zu/fourlaws",
|
||||
"n": "Four Spiritual Laws",
|
||||
"ln": "Zulu",
|
||||
"locale": "zu",
|
||||
"tool": "fourlaws"
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load diff
1
data/tables/gospel-gaps.json
Normal file
1
data/tables/gospel-gaps.json
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large
Load diff
1
data/tables/gospel-resource-links.json
Normal file
1
data/tables/gospel-resource-links.json
Normal file
File diff suppressed because one or more lines are too long
|
|
@ -44,7 +44,7 @@
|
|||
},
|
||||
{
|
||||
"name": "deaf-gap",
|
||||
"rows": 120
|
||||
"rows": 234
|
||||
},
|
||||
{
|
||||
"name": "open-doors",
|
||||
|
|
@ -61,6 +61,18 @@
|
|||
{
|
||||
"name": "gaps-and-opportunities",
|
||||
"rows": 2106
|
||||
},
|
||||
{
|
||||
"name": "people-group-pins",
|
||||
"rows": 12379
|
||||
},
|
||||
{
|
||||
"name": "gospel-resource-links",
|
||||
"rows": 3550
|
||||
},
|
||||
{
|
||||
"name": "gospel-gaps",
|
||||
"rows": 2901
|
||||
}
|
||||
]
|
||||
}
|
||||
1029
data/tables/iso-macrolanguages.json
Normal file
1029
data/tables/iso-macrolanguages.json
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -5363,7 +5363,7 @@
|
|||
},
|
||||
{
|
||||
"operator": "Air Force Systems Networking",
|
||||
"operator_raw": "a tracked issue - Air Force Systems Networking",
|
||||
"operator_raw": "DEC-MARLBORO-AS - Air Force Systems Networking",
|
||||
"asn": "AS61",
|
||||
"country_iso3": "ASM",
|
||||
"country_iso2": "US",
|
||||
|
|
@ -29459,7 +29459,7 @@
|
|||
},
|
||||
{
|
||||
"operator": "Air Force Systems Networking",
|
||||
"operator_raw": "a tracked issue - Air Force Systems Networking",
|
||||
"operator_raw": "DEC-MARLBORO-AS - Air Force Systems Networking",
|
||||
"asn": "AS61",
|
||||
"country_iso3": "GUM",
|
||||
"country_iso2": "US",
|
||||
|
|
@ -48167,7 +48167,7 @@
|
|||
},
|
||||
{
|
||||
"operator": "Air Force Systems Networking",
|
||||
"operator_raw": "a tracked issue - Air Force Systems Networking",
|
||||
"operator_raw": "DEC-MARLBORO-AS - Air Force Systems Networking",
|
||||
"asn": "AS61",
|
||||
"country_iso3": "MNP",
|
||||
"country_iso2": "US",
|
||||
|
|
@ -61750,8 +61750,8 @@
|
|||
"source": "RIPEstat"
|
||||
},
|
||||
{
|
||||
"operator": "a tracked issue Ministry of Education and Higher Education",
|
||||
"operator_raw": "a tracked issue Ministry of Education and Higher Education",
|
||||
"operator": "QA-MOEHE-QA Ministry of Education and Higher Education",
|
||||
"operator_raw": "QA-MOEHE-QA Ministry of Education and Higher Education",
|
||||
"asn": "AS198464",
|
||||
"country_iso3": "QAT",
|
||||
"country_iso2": "QA",
|
||||
|
|
@ -61774,8 +61774,8 @@
|
|||
"source": "RIPEstat"
|
||||
},
|
||||
{
|
||||
"operator": "a tracked issue Ministry of Communication and Information Technology",
|
||||
"operator_raw": "a tracked issue Ministry of Communication and Information Technology - Qatar",
|
||||
"operator": "QA-MCIT-AS Ministry of Communication and Information Technology",
|
||||
"operator_raw": "QA-MCIT-AS Ministry of Communication and Information Technology - Qatar",
|
||||
"asn": "AS201680",
|
||||
"country_iso3": "QAT",
|
||||
"country_iso2": "QA",
|
||||
|
|
@ -72347,7 +72347,7 @@
|
|||
},
|
||||
{
|
||||
"operator": "Air Force Systems Networking",
|
||||
"operator_raw": "a tracked issue - Air Force Systems Networking",
|
||||
"operator_raw": "DEC-MARLBORO-AS - Air Force Systems Networking",
|
||||
"asn": "AS61",
|
||||
"country_iso3": "USA",
|
||||
"country_iso2": "US",
|
||||
|
|
@ -75155,7 +75155,7 @@
|
|||
},
|
||||
{
|
||||
"operator": "Air Force Systems Networking",
|
||||
"operator_raw": "a tracked issue - Air Force Systems Networking",
|
||||
"operator_raw": "DEC-MARLBORO-AS - Air Force Systems Networking",
|
||||
"asn": "AS61",
|
||||
"country_iso3": "VIR",
|
||||
"country_iso2": "US",
|
||||
|
|
|
|||
1
data/tables/people-group-pins.json
Normal file
1
data/tables/people-group-pins.json
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large
Load diff
851
data/tables/zume-index.json
Normal file
851
data/tables/zume-index.json
Normal file
|
|
@ -0,0 +1,851 @@
|
|||
{
|
||||
"source": "https://zume.training/sitemap.xml + https://zume.training/articles/zume-languages",
|
||||
"fetched": "2026-09-24",
|
||||
"locales": 62,
|
||||
"live": 54,
|
||||
"codes": 52,
|
||||
"approxCodes": 3,
|
||||
"byIso": {
|
||||
"amh": {
|
||||
"locale": "am",
|
||||
"name": "Amharic",
|
||||
"url": "https://zume.training/am"
|
||||
},
|
||||
"arb": {
|
||||
"locale": "ar",
|
||||
"name": "Arabic",
|
||||
"url": "https://zume.training/ar"
|
||||
},
|
||||
"ajp": {
|
||||
"locale": "ar_jo",
|
||||
"name": "Arabic (Jordanian)",
|
||||
"url": "https://zume.training/ar_jo"
|
||||
},
|
||||
"aeb": {
|
||||
"locale": "ar_tn",
|
||||
"name": "Arabic (Tunisian)",
|
||||
"url": "https://zume.training/ar_tn"
|
||||
},
|
||||
"ase": {
|
||||
"locale": "asl",
|
||||
"name": "American Sign Language",
|
||||
"url": "https://zume.training/asl"
|
||||
},
|
||||
"bho": {
|
||||
"locale": "bho",
|
||||
"name": "Bhojpuri",
|
||||
"url": "https://zume.training/bho"
|
||||
},
|
||||
"ben": {
|
||||
"locale": "bn",
|
||||
"name": "Bengali (India)",
|
||||
"url": "https://zume.training/bn"
|
||||
},
|
||||
"bos": {
|
||||
"locale": "bs",
|
||||
"name": "Bosnian",
|
||||
"url": "https://zume.training/bs"
|
||||
},
|
||||
"deu": {
|
||||
"locale": "de",
|
||||
"name": "German",
|
||||
"url": "https://zume.training/de"
|
||||
},
|
||||
"eng": {
|
||||
"locale": "en",
|
||||
"name": "English",
|
||||
"url": "https://zume.training/en"
|
||||
},
|
||||
"spa": {
|
||||
"locale": "es",
|
||||
"name": "Spanish",
|
||||
"url": "https://zume.training/es"
|
||||
},
|
||||
"pes": {
|
||||
"locale": "fa",
|
||||
"name": "Persian/Farsi",
|
||||
"url": "https://zume.training/fa"
|
||||
},
|
||||
"fra": {
|
||||
"locale": "fr",
|
||||
"name": "French",
|
||||
"url": "https://zume.training/fr"
|
||||
},
|
||||
"guj": {
|
||||
"locale": "gu",
|
||||
"name": "Gujarati",
|
||||
"url": "https://zume.training/gu"
|
||||
},
|
||||
"hau": {
|
||||
"locale": "ha",
|
||||
"name": "Hausa",
|
||||
"url": "https://zume.training/ha"
|
||||
},
|
||||
"hin": {
|
||||
"locale": "hi",
|
||||
"name": "Hindi",
|
||||
"url": "https://zume.training/hi"
|
||||
},
|
||||
"hrv": {
|
||||
"locale": "hr",
|
||||
"name": "Croatian",
|
||||
"url": "https://zume.training/hr"
|
||||
},
|
||||
"hye": {
|
||||
"locale": "hy",
|
||||
"name": "Armenian",
|
||||
"url": "https://zume.training/hy"
|
||||
},
|
||||
"ind": {
|
||||
"locale": "id",
|
||||
"name": "Indonesian",
|
||||
"url": "https://zume.training/id"
|
||||
},
|
||||
"ita": {
|
||||
"locale": "it",
|
||||
"name": "Italian",
|
||||
"url": "https://zume.training/it"
|
||||
},
|
||||
"jpn": {
|
||||
"locale": "ja",
|
||||
"name": "Japanese",
|
||||
"url": "https://zume.training/ja"
|
||||
},
|
||||
"kan": {
|
||||
"locale": "kn",
|
||||
"name": "Kannada",
|
||||
"url": "https://zume.training/kn"
|
||||
},
|
||||
"kor": {
|
||||
"locale": "ko",
|
||||
"name": "Korean",
|
||||
"url": "https://zume.training/ko"
|
||||
},
|
||||
"ckb": {
|
||||
"locale": "ku",
|
||||
"name": "Kurdish",
|
||||
"url": "https://zume.training/ku"
|
||||
},
|
||||
"kir": {
|
||||
"locale": "ky",
|
||||
"name": "ky",
|
||||
"url": "https://zume.training/ky"
|
||||
},
|
||||
"lao": {
|
||||
"locale": "lo",
|
||||
"name": "Lao",
|
||||
"url": "https://zume.training/lo"
|
||||
},
|
||||
"mai": {
|
||||
"locale": "mai",
|
||||
"name": "Maithili",
|
||||
"url": "https://zume.training/mai"
|
||||
},
|
||||
"mal": {
|
||||
"locale": "ml",
|
||||
"name": "Malayalam",
|
||||
"url": "https://zume.training/ml"
|
||||
},
|
||||
"mar": {
|
||||
"locale": "mr",
|
||||
"name": "Marathi",
|
||||
"url": "https://zume.training/mr"
|
||||
},
|
||||
"zlm": {
|
||||
"locale": "ms",
|
||||
"name": "Malay",
|
||||
"url": "https://zume.training/ms"
|
||||
},
|
||||
"mya": {
|
||||
"locale": "my",
|
||||
"name": "Burmese",
|
||||
"url": "https://zume.training/my"
|
||||
},
|
||||
"npi": {
|
||||
"locale": "ne",
|
||||
"name": "Nepali",
|
||||
"url": "https://zume.training/ne"
|
||||
},
|
||||
"ory": {
|
||||
"locale": "or",
|
||||
"name": "Odia",
|
||||
"url": "https://zume.training/or"
|
||||
},
|
||||
"pan": {
|
||||
"locale": "pa",
|
||||
"name": "Punjabi",
|
||||
"url": "https://zume.training/pa"
|
||||
},
|
||||
"pnb": {
|
||||
"locale": "pa_pk",
|
||||
"name": "Punjabi (Western)",
|
||||
"url": "https://zume.training/pa_pk"
|
||||
},
|
||||
"pol": {
|
||||
"locale": "pl",
|
||||
"name": "Polish",
|
||||
"url": "https://zume.training/pl"
|
||||
},
|
||||
"prs": {
|
||||
"locale": "prs",
|
||||
"name": "prs",
|
||||
"url": "https://zume.training/prs"
|
||||
},
|
||||
"por": {
|
||||
"locale": "pt",
|
||||
"name": "Portuguese",
|
||||
"url": "https://zume.training/pt"
|
||||
},
|
||||
"ron": {
|
||||
"locale": "ro",
|
||||
"name": "Romanian",
|
||||
"url": "https://zume.training/ro"
|
||||
},
|
||||
"rus": {
|
||||
"locale": "ru",
|
||||
"name": "Russian",
|
||||
"url": "https://zume.training/ru"
|
||||
},
|
||||
"slv": {
|
||||
"locale": "sl",
|
||||
"name": "Slovenian",
|
||||
"url": "https://zume.training/sl"
|
||||
},
|
||||
"som": {
|
||||
"locale": "so",
|
||||
"name": "Somali",
|
||||
"url": "https://zume.training/so"
|
||||
},
|
||||
"swh": {
|
||||
"locale": "swa",
|
||||
"name": "Swahili",
|
||||
"url": "https://zume.training/swa"
|
||||
},
|
||||
"tam": {
|
||||
"locale": "ta",
|
||||
"name": "Tamil",
|
||||
"url": "https://zume.training/ta"
|
||||
},
|
||||
"tel": {
|
||||
"locale": "te",
|
||||
"name": "Telugu",
|
||||
"url": "https://zume.training/te"
|
||||
},
|
||||
"tha": {
|
||||
"locale": "th",
|
||||
"name": "Thai",
|
||||
"url": "https://zume.training/th"
|
||||
},
|
||||
"tur": {
|
||||
"locale": "tr",
|
||||
"name": "Turkish",
|
||||
"url": "https://zume.training/tr"
|
||||
},
|
||||
"urd": {
|
||||
"locale": "ur",
|
||||
"name": "Urdu",
|
||||
"url": "https://zume.training/ur"
|
||||
},
|
||||
"vie": {
|
||||
"locale": "vi",
|
||||
"name": "Vietnamese",
|
||||
"url": "https://zume.training/vi"
|
||||
},
|
||||
"yor": {
|
||||
"locale": "yo",
|
||||
"name": "Yoruba",
|
||||
"url": "https://zume.training/yo"
|
||||
},
|
||||
"cmn": {
|
||||
"locale": "zhcn",
|
||||
"name": "Chinese (Simplified)",
|
||||
"url": "https://zume.training/zhcn"
|
||||
},
|
||||
"yue": {
|
||||
"locale": "zhhk",
|
||||
"name": "Cantonese (Traditional)",
|
||||
"url": "https://zume.training/zhhk"
|
||||
}
|
||||
},
|
||||
"approx": {
|
||||
"pbu": {
|
||||
"locale": "pus",
|
||||
"lang": "Pashto",
|
||||
"note": "Published in Pashto; the publisher does not say which variety, and this group speaks one of three",
|
||||
"url": "https://zume.training/pus"
|
||||
},
|
||||
"pbt": {
|
||||
"locale": "pus",
|
||||
"lang": "Pashto",
|
||||
"note": "Published in Pashto; the publisher does not say which variety, and this group speaks one of three",
|
||||
"url": "https://zume.training/pus"
|
||||
},
|
||||
"pst": {
|
||||
"locale": "pus",
|
||||
"lang": "Pashto",
|
||||
"note": "Published in Pashto; the publisher does not say which variety, and this group speaks one of three",
|
||||
"url": "https://zume.training/pus"
|
||||
}
|
||||
},
|
||||
"entries": [
|
||||
{
|
||||
"locale": "am",
|
||||
"name": "Amharic",
|
||||
"native": "አማርኛ",
|
||||
"iso": "amh",
|
||||
"url": "https://zume.training/am",
|
||||
"listed": true,
|
||||
"sitemap": true
|
||||
},
|
||||
{
|
||||
"locale": "ar",
|
||||
"name": "Arabic",
|
||||
"native": "العربية",
|
||||
"iso": "arb",
|
||||
"url": "https://zume.training/ar",
|
||||
"listed": true,
|
||||
"sitemap": true
|
||||
},
|
||||
{
|
||||
"locale": "ar_jo",
|
||||
"name": "Arabic (Jordanian)",
|
||||
"native": "العربية - الأردن",
|
||||
"iso": "ajp",
|
||||
"url": "https://zume.training/ar_jo",
|
||||
"listed": true,
|
||||
"sitemap": true
|
||||
},
|
||||
{
|
||||
"locale": "ar_ma",
|
||||
"name": "Arabic (Moroccan)",
|
||||
"native": "العربية - الأردن",
|
||||
"iso": "ary",
|
||||
"url": null,
|
||||
"listed": true,
|
||||
"sitemap": false
|
||||
},
|
||||
{
|
||||
"locale": "ar_tn",
|
||||
"name": "Arabic (Tunisian)",
|
||||
"native": "العربية التونسية",
|
||||
"iso": "aeb",
|
||||
"url": "https://zume.training/ar_tn",
|
||||
"listed": true,
|
||||
"sitemap": true
|
||||
},
|
||||
{
|
||||
"locale": "asl",
|
||||
"name": "American Sign Language",
|
||||
"native": "Sign Language",
|
||||
"iso": "ase",
|
||||
"url": "https://zume.training/asl",
|
||||
"listed": true,
|
||||
"sitemap": true
|
||||
},
|
||||
{
|
||||
"locale": "az",
|
||||
"name": "Azerbaijani",
|
||||
"native": null,
|
||||
"iso": "azj",
|
||||
"url": null,
|
||||
"listed": true,
|
||||
"sitemap": false
|
||||
},
|
||||
{
|
||||
"locale": "bg",
|
||||
"name": "Bulgarian",
|
||||
"native": "български език",
|
||||
"iso": "bul",
|
||||
"url": null,
|
||||
"listed": true,
|
||||
"sitemap": false
|
||||
},
|
||||
{
|
||||
"locale": "bho",
|
||||
"name": "Bhojpuri",
|
||||
"native": "भोजपुरी",
|
||||
"iso": "bho",
|
||||
"url": "https://zume.training/bho",
|
||||
"listed": true,
|
||||
"sitemap": true
|
||||
},
|
||||
{
|
||||
"locale": "bn",
|
||||
"name": "Bengali (India)",
|
||||
"native": "বাংলা",
|
||||
"iso": "ben",
|
||||
"url": "https://zume.training/bn",
|
||||
"listed": true,
|
||||
"sitemap": true
|
||||
},
|
||||
{
|
||||
"locale": "bs",
|
||||
"name": "Bosnian",
|
||||
"native": "Bosanski",
|
||||
"iso": "bos",
|
||||
"url": "https://zume.training/bs",
|
||||
"listed": true,
|
||||
"sitemap": true
|
||||
},
|
||||
{
|
||||
"locale": "de",
|
||||
"name": "German",
|
||||
"native": "Deutsch",
|
||||
"iso": "deu",
|
||||
"url": "https://zume.training/de",
|
||||
"listed": true,
|
||||
"sitemap": true
|
||||
},
|
||||
{
|
||||
"locale": "en",
|
||||
"name": "English",
|
||||
"native": null,
|
||||
"iso": "eng",
|
||||
"url": "https://zume.training/en",
|
||||
"listed": true,
|
||||
"sitemap": true
|
||||
},
|
||||
{
|
||||
"locale": "es",
|
||||
"name": "Spanish",
|
||||
"native": "Español",
|
||||
"iso": "spa",
|
||||
"url": "https://zume.training/es",
|
||||
"listed": true,
|
||||
"sitemap": true
|
||||
},
|
||||
{
|
||||
"locale": "fa",
|
||||
"name": "Persian/Farsi",
|
||||
"native": "فارسی",
|
||||
"iso": "pes",
|
||||
"url": "https://zume.training/fa",
|
||||
"listed": true,
|
||||
"sitemap": true
|
||||
},
|
||||
{
|
||||
"locale": "fo",
|
||||
"name": "Faroese",
|
||||
"native": null,
|
||||
"iso": "fao",
|
||||
"url": null,
|
||||
"listed": true,
|
||||
"sitemap": false
|
||||
},
|
||||
{
|
||||
"locale": "fr",
|
||||
"name": "French",
|
||||
"native": "Français",
|
||||
"iso": "fra",
|
||||
"url": "https://zume.training/fr",
|
||||
"listed": true,
|
||||
"sitemap": true
|
||||
},
|
||||
{
|
||||
"locale": "gu",
|
||||
"name": "Gujarati",
|
||||
"native": "ગુજરાતી",
|
||||
"iso": "guj",
|
||||
"url": "https://zume.training/gu",
|
||||
"listed": true,
|
||||
"sitemap": true
|
||||
},
|
||||
{
|
||||
"locale": "ha",
|
||||
"name": "Hausa",
|
||||
"native": null,
|
||||
"iso": "hau",
|
||||
"url": "https://zume.training/ha",
|
||||
"listed": true,
|
||||
"sitemap": true
|
||||
},
|
||||
{
|
||||
"locale": "hi",
|
||||
"name": "Hindi",
|
||||
"native": "हिन्दी",
|
||||
"iso": "hin",
|
||||
"url": "https://zume.training/hi",
|
||||
"listed": true,
|
||||
"sitemap": true
|
||||
},
|
||||
{
|
||||
"locale": "hr",
|
||||
"name": "Croatian",
|
||||
"native": "Hrvatski",
|
||||
"iso": "hrv",
|
||||
"url": "https://zume.training/hr",
|
||||
"listed": true,
|
||||
"sitemap": true
|
||||
},
|
||||
{
|
||||
"locale": "hy",
|
||||
"name": "Armenian",
|
||||
"native": null,
|
||||
"iso": "hye",
|
||||
"url": "https://zume.training/hy",
|
||||
"listed": true,
|
||||
"sitemap": true
|
||||
},
|
||||
{
|
||||
"locale": "id",
|
||||
"name": "Indonesian",
|
||||
"native": "Bahasa Indonesia",
|
||||
"iso": "ind",
|
||||
"url": "https://zume.training/id",
|
||||
"listed": true,
|
||||
"sitemap": true
|
||||
},
|
||||
{
|
||||
"locale": "it",
|
||||
"name": "Italian",
|
||||
"native": "Italiano",
|
||||
"iso": "ita",
|
||||
"url": "https://zume.training/it",
|
||||
"listed": true,
|
||||
"sitemap": true
|
||||
},
|
||||
{
|
||||
"locale": "ja",
|
||||
"name": "Japanese",
|
||||
"native": "日本語",
|
||||
"iso": "jpn",
|
||||
"url": "https://zume.training/ja",
|
||||
"listed": true,
|
||||
"sitemap": true
|
||||
},
|
||||
{
|
||||
"locale": "kn",
|
||||
"name": "Kannada",
|
||||
"native": "ಕನ್ನಡ",
|
||||
"iso": "kan",
|
||||
"url": "https://zume.training/kn",
|
||||
"listed": true,
|
||||
"sitemap": true
|
||||
},
|
||||
{
|
||||
"locale": "ko",
|
||||
"name": "Korean",
|
||||
"native": "한국어",
|
||||
"iso": "kor",
|
||||
"url": "https://zume.training/ko",
|
||||
"listed": true,
|
||||
"sitemap": true
|
||||
},
|
||||
{
|
||||
"locale": "ku",
|
||||
"name": "Kurdish",
|
||||
"native": "کوردی",
|
||||
"iso": "ckb",
|
||||
"url": "https://zume.training/ku",
|
||||
"listed": true,
|
||||
"sitemap": true
|
||||
},
|
||||
{
|
||||
"locale": "ky",
|
||||
"name": null,
|
||||
"native": null,
|
||||
"iso": "kir",
|
||||
"url": "https://zume.training/ky",
|
||||
"listed": false,
|
||||
"sitemap": true
|
||||
},
|
||||
{
|
||||
"locale": "lo",
|
||||
"name": "Lao",
|
||||
"native": "ພາສາລາວ",
|
||||
"iso": "lao",
|
||||
"url": "https://zume.training/lo",
|
||||
"listed": true,
|
||||
"sitemap": true
|
||||
},
|
||||
{
|
||||
"locale": "lv",
|
||||
"name": "Latvian",
|
||||
"native": "Latviešu",
|
||||
"iso": "lvs",
|
||||
"url": null,
|
||||
"listed": true,
|
||||
"sitemap": false
|
||||
},
|
||||
{
|
||||
"locale": "mai",
|
||||
"name": "Maithili",
|
||||
"native": "मैथिली",
|
||||
"iso": "mai",
|
||||
"url": "https://zume.training/mai",
|
||||
"listed": true,
|
||||
"sitemap": true
|
||||
},
|
||||
{
|
||||
"locale": "ml",
|
||||
"name": "Malayalam",
|
||||
"native": "മലയാളം",
|
||||
"iso": "mal",
|
||||
"url": "https://zume.training/ml",
|
||||
"listed": true,
|
||||
"sitemap": true
|
||||
},
|
||||
{
|
||||
"locale": "mn",
|
||||
"name": "Mongolian",
|
||||
"native": "Монгол",
|
||||
"iso": "khk",
|
||||
"url": null,
|
||||
"listed": true,
|
||||
"sitemap": false
|
||||
},
|
||||
{
|
||||
"locale": "mr",
|
||||
"name": "Marathi",
|
||||
"native": "मराठी",
|
||||
"iso": "mar",
|
||||
"url": "https://zume.training/mr",
|
||||
"listed": true,
|
||||
"sitemap": true
|
||||
},
|
||||
{
|
||||
"locale": "ms",
|
||||
"name": "Malay",
|
||||
"native": "Bahasa Malaysia",
|
||||
"iso": "zlm",
|
||||
"url": "https://zume.training/ms",
|
||||
"listed": true,
|
||||
"sitemap": true
|
||||
},
|
||||
{
|
||||
"locale": "my",
|
||||
"name": "Burmese",
|
||||
"native": "မြန်မာဘာသာ",
|
||||
"iso": "mya",
|
||||
"url": "https://zume.training/my",
|
||||
"listed": true,
|
||||
"sitemap": true
|
||||
},
|
||||
{
|
||||
"locale": "ne",
|
||||
"name": "Nepali",
|
||||
"native": "नेपाली",
|
||||
"iso": "npi",
|
||||
"url": "https://zume.training/ne",
|
||||
"listed": true,
|
||||
"sitemap": true
|
||||
},
|
||||
{
|
||||
"locale": "or",
|
||||
"name": "Odia",
|
||||
"native": "ଓଡ଼ିଆ",
|
||||
"iso": "ory",
|
||||
"url": "https://zume.training/or",
|
||||
"listed": true,
|
||||
"sitemap": true
|
||||
},
|
||||
{
|
||||
"locale": "pa",
|
||||
"name": "Punjabi",
|
||||
"native": "ਪੰਜਾਬੀ",
|
||||
"iso": "pan",
|
||||
"url": "https://zume.training/pa",
|
||||
"listed": true,
|
||||
"sitemap": true
|
||||
},
|
||||
{
|
||||
"locale": "pa_pk",
|
||||
"name": "Punjabi (Western)",
|
||||
"native": "ਪੰਜਾਬੀ (ਪੱਛਮੀ)",
|
||||
"iso": "pnb",
|
||||
"url": "https://zume.training/pa_pk",
|
||||
"listed": true,
|
||||
"sitemap": true
|
||||
},
|
||||
{
|
||||
"locale": "pl",
|
||||
"name": "Polish",
|
||||
"native": "Polski",
|
||||
"iso": "pol",
|
||||
"url": "https://zume.training/pl",
|
||||
"listed": true,
|
||||
"sitemap": true
|
||||
},
|
||||
{
|
||||
"locale": "prs",
|
||||
"name": null,
|
||||
"native": null,
|
||||
"iso": "prs",
|
||||
"url": "https://zume.training/prs",
|
||||
"listed": false,
|
||||
"sitemap": true
|
||||
},
|
||||
{
|
||||
"locale": "pt",
|
||||
"name": "Portuguese",
|
||||
"native": "Português",
|
||||
"iso": "por",
|
||||
"url": "https://zume.training/pt",
|
||||
"listed": true,
|
||||
"sitemap": true
|
||||
},
|
||||
{
|
||||
"locale": "pus",
|
||||
"name": null,
|
||||
"native": null,
|
||||
"iso": null,
|
||||
"url": "https://zume.training/pus",
|
||||
"listed": false,
|
||||
"sitemap": true,
|
||||
"why": "Pashto has three individual codes in the people-group data (pbu, pbt, pst) and the publisher gives nothing that selects one"
|
||||
},
|
||||
{
|
||||
"locale": "ro",
|
||||
"name": "Romanian",
|
||||
"native": "Română",
|
||||
"iso": "ron",
|
||||
"url": "https://zume.training/ro",
|
||||
"listed": true,
|
||||
"sitemap": true
|
||||
},
|
||||
{
|
||||
"locale": "ru",
|
||||
"name": "Russian",
|
||||
"native": "Русский",
|
||||
"iso": "rus",
|
||||
"url": "https://zume.training/ru",
|
||||
"listed": true,
|
||||
"sitemap": true
|
||||
},
|
||||
{
|
||||
"locale": "sl",
|
||||
"name": "Slovenian",
|
||||
"native": "Slovenščina",
|
||||
"iso": "slv",
|
||||
"url": "https://zume.training/sl",
|
||||
"listed": true,
|
||||
"sitemap": true
|
||||
},
|
||||
{
|
||||
"locale": "so",
|
||||
"name": "Somali",
|
||||
"native": "Soomaali",
|
||||
"iso": "som",
|
||||
"url": "https://zume.training/so",
|
||||
"listed": true,
|
||||
"sitemap": true
|
||||
},
|
||||
{
|
||||
"locale": "swa",
|
||||
"name": "Swahili",
|
||||
"native": "Kiswahili",
|
||||
"iso": "swh",
|
||||
"url": "https://zume.training/swa",
|
||||
"listed": true,
|
||||
"sitemap": true
|
||||
},
|
||||
{
|
||||
"locale": "ta",
|
||||
"name": "Tamil",
|
||||
"native": "தமிழ்",
|
||||
"iso": "tam",
|
||||
"url": "https://zume.training/ta",
|
||||
"listed": true,
|
||||
"sitemap": true
|
||||
},
|
||||
{
|
||||
"locale": "te",
|
||||
"name": "Telugu",
|
||||
"native": "తెలుగు",
|
||||
"iso": "tel",
|
||||
"url": "https://zume.training/te",
|
||||
"listed": true,
|
||||
"sitemap": true
|
||||
},
|
||||
{
|
||||
"locale": "th",
|
||||
"name": "Thai",
|
||||
"native": "ไทย",
|
||||
"iso": "tha",
|
||||
"url": "https://zume.training/th",
|
||||
"listed": true,
|
||||
"sitemap": true
|
||||
},
|
||||
{
|
||||
"locale": "tl",
|
||||
"name": "Tagalog",
|
||||
"native": null,
|
||||
"iso": "tgl",
|
||||
"url": null,
|
||||
"listed": true,
|
||||
"sitemap": false
|
||||
},
|
||||
{
|
||||
"locale": "tr",
|
||||
"name": "Turkish",
|
||||
"native": "Türkçe",
|
||||
"iso": "tur",
|
||||
"url": "https://zume.training/tr",
|
||||
"listed": true,
|
||||
"sitemap": true
|
||||
},
|
||||
{
|
||||
"locale": "uk",
|
||||
"name": "Ukrainian",
|
||||
"native": "Україна",
|
||||
"iso": "ukr",
|
||||
"url": null,
|
||||
"listed": true,
|
||||
"sitemap": false
|
||||
},
|
||||
{
|
||||
"locale": "ur",
|
||||
"name": "Urdu",
|
||||
"native": "اردو",
|
||||
"iso": "urd",
|
||||
"url": "https://zume.training/ur",
|
||||
"listed": true,
|
||||
"sitemap": true
|
||||
},
|
||||
{
|
||||
"locale": "vi",
|
||||
"name": "Vietnamese",
|
||||
"native": "Tiếng Việt",
|
||||
"iso": "vie",
|
||||
"url": "https://zume.training/vi",
|
||||
"listed": true,
|
||||
"sitemap": true
|
||||
},
|
||||
{
|
||||
"locale": "yo",
|
||||
"name": "Yoruba",
|
||||
"native": "Yorùbá",
|
||||
"iso": "yor",
|
||||
"url": "https://zume.training/yo",
|
||||
"listed": true,
|
||||
"sitemap": true
|
||||
},
|
||||
{
|
||||
"locale": "zhcn",
|
||||
"name": "Chinese (Simplified)",
|
||||
"native": "中文(简体)",
|
||||
"iso": "cmn",
|
||||
"url": "https://zume.training/zhcn",
|
||||
"listed": true,
|
||||
"sitemap": true
|
||||
},
|
||||
{
|
||||
"locale": "zhhk",
|
||||
"name": "Cantonese (Traditional)",
|
||||
"native": "中文(繁體,香港)",
|
||||
"iso": "yue",
|
||||
"url": "https://zume.training/zhhk",
|
||||
"listed": true,
|
||||
"sitemap": true
|
||||
},
|
||||
{
|
||||
"locale": "zhtw",
|
||||
"name": "Chinese (Traditional)",
|
||||
"native": "中文(繁體)",
|
||||
"iso": "cmn",
|
||||
"url": "https://zume.training/zhtw",
|
||||
"listed": true,
|
||||
"sitemap": true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -49,7 +49,7 @@ the Tauri origin** — exactly the surprise `capacitor.config.json` documents fo
|
|||
**The fix is a response header on the token host, not a change in `tauri.conf.json`.**
|
||||
|
||||
Measured 2026-09-21: `https://doxa.life/api/maps/token` → **200**;
|
||||
`https://the-host/api/maps/token` → **404**. A desktop build pointed at the-platform would launch
|
||||
`https://the-host/api/maps/token` → **404**. A desktop build pointed at the-host would launch
|
||||
with blank basemaps even though the window opens and the element mounts correctly. Separately, the
|
||||
Mapbox token is **URL-restricted**: with a `the-host` Referer the style document returns 200 but
|
||||
every **tile 403s**, while the same token with a `doxa.life` Referer returns 200. Verify the token
|
||||
|
|
|
|||
|
|
@ -28,7 +28,7 @@ Then in wp-admin (http://localhost:8888/wp-admin, admin/password):
|
|||
|
||||
1. Plugins → activate **Doxa Maps Embed**.
|
||||
2. Settings → **Doxa Maps** → set the catalog base to a real served tree, e.g.
|
||||
`https://doxa.life/js/doxa-maps` (or your own CDN / GitHub Pages / doxa-base the forge host).
|
||||
`https://doxa.life/js/doxa-maps` (or your own CDN / GitHub Pages / doxa-base the git forge host).
|
||||
3. Create a page and add the shortcode:
|
||||
|
||||
```
|
||||
|
|
|
|||
|
|
@ -22,7 +22,11 @@ The scripts these commands are grounded in (from this branch's `package.json`):
|
|||
// BUILD_LOCK_WAIT=600 to QUEUE rather than collide. Note the lock serializes but does NOT
|
||||
// dedupe: N queued builds = N full rebuilds.
|
||||
// ★ THE PIPELINE RUNS IN PHASES, NOT AS ONE `&&` CHAIN (2026-09-23). Measured on the build
|
||||
// box: the 29 gates cost 84.5s chained, but 21 of them finish under 700ms — they were
|
||||
// box AS IT WAS THAT DAY — the numbers below are a DATED BENCHMARK, not a running total.
|
||||
// The gate set has grown since (36 steps as of 2026-09-24: 7 pre + 29 post); the timings
|
||||
// and the "31 in, 31 out" count are kept verbatim because they are the EVIDENCE that the
|
||||
// phase change preserved the step set. Restating them as current would destroy that.
|
||||
// Then: the 29 gates cost 84.5s chained, but 21 of them finish under 700ms — they were
|
||||
// queueing behind check:mjs-parse (26.9s) and check:parse (18.7s), not working. The 22 vite
|
||||
// builds cost 129.0s and were never ordered at all: vite.config.js gives each bundle its own
|
||||
// outDir with emptyOutDir:false, so they only ever needed to not overwrite each other.
|
||||
|
|
@ -36,13 +40,16 @@ The scripts these commands are grounded in (from this branch's `package.json`):
|
|||
// `&&` BETWEEN phases is load-bearing and stays: a source gate still blocks the build, and
|
||||
// the build still blocks every gate that reads its output. Within a phase the runner reports
|
||||
// ALL failures instead of stopping at the first, so three broken gates cost one cycle, not three.
|
||||
// ⚠ check:quoted-scripts IS ITSELF IN gates:post, so adding or removing ANY gate changes the
|
||||
// very line this doc quotes — the update is self-referential and takes two passes: wire the
|
||||
// gate, then re-transcribe. That is the mechanism working, not a bug.
|
||||
// If you edit package.json, re-transcribe this block — a doc that QUOTES a config is
|
||||
// claiming to be ground truth, and this one had silently stopped being it.
|
||||
"dev": "bun x vite",
|
||||
"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",
|
||||
"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: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",
|
||||
"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",
|
||||
"build:bundles": "node internal/run-parallel.mjs --bundles", // the 22 vite builds, 4 at a time
|
||||
"preview": "bun x vite preview",
|
||||
"serve": "node internal/serve-strict.mjs", // 404s a directory, like production
|
||||
|
|
|
|||
|
|
@ -71,7 +71,7 @@ apply it. Rule of thumb: **a path with ≥2 directory segments under the tree ro
|
|||
page → serve it framable.**
|
||||
|
||||
Portable ways to implement the split, by host:
|
||||
- **Traefik** (the-platform/go-base): a higher-priority router on `PathRegexp(^/plugin-assets/[^/]+/[^/]+/)`
|
||||
- **Traefik** (the-host/go-base): a higher-priority router on `PathRegexp(^/plugin-assets/[^/]+/[^/]+/)`
|
||||
sets `Content-Security-Policy: frame-ancestors *` + clears `X-Frame-Options`. (Live example:
|
||||
`/data/coolify/proxy/dynamic/doxa-maps-embed.yaml`.)
|
||||
- **Cloudflare Pages / Netlify**: a `_headers` file — `/<bundle>/*/* Content-Security-Policy: frame-ancestors *`.
|
||||
|
|
|
|||
|
|
@ -110,13 +110,13 @@ MAPBOX_TOKEN=$MAPBOX_TOKEN node internal/embed-smoke.mjs --grep <your-plugin>
|
|||
> **Do not fetch the token from `https://doxa.life/api/maps/token` here — measured 2026-09-23, it does
|
||||
> not work for this.** That endpoint serves a token URL-restricted to `doxa.life`, so a tile request
|
||||
> without that Referer returns **403** and every basemap in your smoke test renders blank while the pins
|
||||
> draw fine. That is the exact symptom the product owner reported as *"it's loading the pins, but it's not
|
||||
> draw fine. That is the exact symptom the agent reported as *"it's loading the pins, but it's not
|
||||
> loading the actual underlying map"*, and it is why `tokenUrl` was emptied in the build config on
|
||||
> 2026-09-22. This doc used to hand you that command, so an inheritor's first run reproduced the bug.
|
||||
>
|
||||
> The `token` field in `vite.doxa-maps-build-config.json` is the one that works: measured, the config
|
||||
> token returns **200** for a tile request with no Referer while the endpoint token returns **403**. It
|
||||
> is a `pk.` public key, baked deliberately (product owner ruling 2026-09-21) so a map works on any host —
|
||||
> is a `pk.` public key, baked deliberately (agent ruling 2026-09-21) so a map works on any host —
|
||||
> protected by URL restrictions rather than secrecy. `check:public-hygiene` allows `pk.` and fails the
|
||||
> build on `sk.`/`tk.`, so this is safe to read and never a secret to guard.
|
||||
|
||||
|
|
@ -178,7 +178,7 @@ The platform serves the catalog directly, so a pushed change is live at once —
|
|||
(The fuller runbook with every gotcha lives in the project's deploy-cycle notes; the block above is the
|
||||
load-bearing sequence.)
|
||||
|
||||
(The end-state is the the forge CI build-gate — push source, CI builds + publishes automatically. Until a runner
|
||||
(The end-state is the the git forge CI build-gate — push source, CI builds + publishes automatically. Until a runner
|
||||
is registered, the runbook is the manual path. Same `build:unlocked` seam.)
|
||||
|
||||
## Step 5 — See it live (the 5 deployment methods)
|
||||
|
|
|
|||
|
|
@ -207,11 +207,11 @@ Your built maps are just files. To show them on other websites, the files need a
|
|||
on the internet that any site can pull from — that's what a "CDN" is. **You choose the home, and
|
||||
there are options:**
|
||||
|
||||
- **Your own site (the source org).** Put the built maps in the source org's `public/` folder — its Nuxt
|
||||
site serves them, so **the source org is the CDN**. No third party.
|
||||
- **Your own site (dx-life).** Put the built maps in dx-life's `public/` folder — its Nuxt
|
||||
site serves them, so **dx-life is the CDN**. No third party.
|
||||
- **GitHub.** Push the built maps to a public GitHub repo and serve them free through
|
||||
**GitHub Pages or jsDelivr** — **GitHub is the CDN**.
|
||||
- **doxa-base's built-in home (the forge).** doxa-base ships **the forge as a CDN** — push the maps
|
||||
- **doxa-base's built-in home (the git forge).** doxa-base ships **the git forge as a CDN** — push the maps
|
||||
there and it serves them, with no extra service to set up.
|
||||
- **Anywhere else.** Any static host works — Cloudflare, an S3-style bucket, etc.
|
||||
|
||||
|
|
@ -220,7 +220,7 @@ updates everywhere.
|
|||
|
||||
**How it's wired** (one file: `vite.doxa-maps-build-config.json`; full field table in
|
||||
**[reference.md](./reference.md)**): `bun run build` always writes to `public/doxa-maps/<bundle>/`.
|
||||
Set `paths` to also copy the tree into another host's folder (e.g. the source org's `public/`). Set
|
||||
Set `paths` to also copy the tree into another host's folder (e.g. dx-life's `public/`). Set
|
||||
`embedBaseUrl` to the public URL of your chosen home — it generates absolute, paste-anywhere embed
|
||||
snippets (leave it empty for relative URLs that work wherever the folder is dropped).
|
||||
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@
|
|||
> bundle `parameterized` (cap-2) no matter how light it looks, and a `meta.json` `type`
|
||||
> declares tiers 3/4/5.
|
||||
|
||||
Generated from 19 bundle(s).
|
||||
Generated from 22 bundle(s).
|
||||
|
||||
### One canonical exemplar per tier (generated — quote these)
|
||||
|
||||
|
|
@ -49,6 +49,9 @@ Generated from 19 bundle(s).
|
|||
| `gospel-resources` | `resources-map` | `single` |
|
||||
| `my-upg-100-list` | `poster-wall` | `single` |
|
||||
| `my-upg-100-list` | `upg-100-list` | `single` |
|
||||
| `prayer-walk-planner` | `prayer-walk-planner` | `single` |
|
||||
| `regions-dashboard` | `region-dashboards` | `single` |
|
||||
| `regions-dashboard` | `regions-list` | `single` |
|
||||
| `template-bundle` | `template-map-a` | `single` |
|
||||
| `template-bundle` | `template-map-b` | `single` |
|
||||
|
||||
|
|
@ -60,6 +63,7 @@ Generated from 19 bundle(s).
|
|||
| `doxa-simple-map` | `doxa-simple-map` | `parameterized` |
|
||||
| `doxa-vision-card` | `doxa-vision-card` | `parameterized` |
|
||||
| `people-groups-grid` | `people-groups-grid` | `parameterized` |
|
||||
| `upg-neighbor` | `upg-neighbor` | `parameterized` |
|
||||
|
||||
## Capability 3 — multi-view-element (monolithic)
|
||||
|
||||
|
|
@ -73,8 +77,11 @@ Generated from 19 bundle(s).
|
|||
| Bundle | Profile | Machine key |
|
||||
|---|---|---|
|
||||
| `big-picture` | `big-picture` | `nested-elements-mfe` |
|
||||
| `country-posters` | `country-posters-mfe` | `nested-elements-mfe` |
|
||||
| `dashboard-plus-grid` | `dashboard-plus-grid` | `nested-elements-mfe` |
|
||||
| `gospel-resources` | `gospel-dashboard` | `nested-elements-mfe` |
|
||||
| `my-upg-100-list` | `upg-100-mfe` | `nested-elements-mfe` |
|
||||
| `regions-dashboard` | `regions-mfe` | `nested-elements-mfe` |
|
||||
| `research-map-plus-grid` | `research-map-plus-grid` | `nested-elements-mfe` |
|
||||
| `stack-demo` | `stack-demo` | `nested-elements-mfe` |
|
||||
|
||||
|
|
|
|||
|
|
@ -36,7 +36,7 @@ and a change here can break other maps.
|
|||
`import … from '@map/components/legends/LegendProgressBar.vue'`
|
||||
- **LegendRows** — (no description)
|
||||
`import … from '@map/components/legends/LegendRows.vue'`
|
||||
- **LegendTools** — LegendTools.vue — Fly + Clusters floating toolbar
|
||||
- **LegendTools** — Does the Fly + Clusters toolbar render for this legend type?
|
||||
`import … from '@map/components/legends/LegendTools.vue'`
|
||||
- **SemanticTreeLegend** — SemanticTreeLegend — reusable hierarchical legend for any one-to-many semantic tree.
|
||||
`import … from '@map/components/legends/SemanticTreeLegend.vue'`
|
||||
|
|
@ -107,10 +107,16 @@ and a change here can break other maps.
|
|||
`import … from '@map/composables/poster/useMapPoster.js'`
|
||||
- **usePosterLayout** — usePosterLayout — slot positioning math for poster compositions.
|
||||
`import … from '@map/composables/poster/usePosterLayout.js'`
|
||||
- **printInPlace** — printInPlace.js — SHARED (@map/composables/printInPlace.js).
|
||||
`import … from '@map/composables/printInPlace.js'`
|
||||
- **retryableAsyncComponent** — retryableAsyncComponent.js — SHARED (@map/composables/retryableAsyncComponent.js).
|
||||
`import … from '@map/composables/retryableAsyncComponent.js'`
|
||||
- **useAvatarGenerator** — Avatar Generator Composable
|
||||
`import … from '@map/composables/useAvatarGenerator.js'`
|
||||
- **useCountryOutline** — useCountryOutline.js — country boundary OUTLINE overlay on country search.
|
||||
`import … from '@map/composables/useCountryOutline.js'`
|
||||
- **useDocumentFont** — useDocumentFont.js — register a self-hosted @font-face at DOCUMENT level, once per page.
|
||||
`import … from '@map/composables/useDocumentFont.js'`
|
||||
- **useEdgeScrollAffordance** — useEdgeScrollAffordance — the gray "you can scroll past this" edge strips.
|
||||
`import … from '@map/composables/useEdgeScrollAffordance.js'`
|
||||
- **useGeocoderSearch** — Shared geocoder→map search logic (kind-switch + Mapbox filter builder + the
|
||||
|
|
@ -137,6 +143,8 @@ and a change here can break other maps.
|
|||
`import … from '@map/composables/useMapSelection.js'`
|
||||
- **useMapTheme** — useMapTheme.js — shared light/dark theme for every map profile.
|
||||
`import … from '@map/composables/useMapTheme.js'`
|
||||
- **usePosterPrints** — usePosterPrints.js — THE print actions every poster wall shares.
|
||||
`import … from '@map/composables/usePosterPrints.js'`
|
||||
- **usePplrInstance** — usePplrInstance.js — per-map "instance store" mediator.
|
||||
`import … from '@map/composables/usePplrInstance.js'`
|
||||
- **usePrayedLocally** — usePrayedLocally.js — the "my dot glows" encouragement loop.
|
||||
|
|
@ -149,6 +157,8 @@ and a change here can break other maps.
|
|||
`import … from '@map/composables/useSelectedPin.js'`
|
||||
- **useShadowStyles** — useShadowStyles.js — Shadow DOM Style Injection
|
||||
`import … from '@map/composables/useShadowStyles.js'`
|
||||
- **useSharedMapToken** — useSharedMapToken.js — ONE page, ONE map token, shared by every element on it.
|
||||
`import … from '@map/composables/useSharedMapToken.js'`
|
||||
|
||||
### Utilities (pure helper functions)
|
||||
|
||||
|
|
@ -162,6 +172,8 @@ and a change here can break other maps.
|
|||
`import … from '@map/utils/NetworkClusteringUtil.js'`
|
||||
- **apiBaseUrl** — Back-compat shim. The real apiBaseUrl.js lives in the api/ seam folder.
|
||||
`import … from '@map/utils/apiBaseUrl.js'`
|
||||
- **countries-meta** — countries-meta.js — a PORT of DX-life's config/countries-meta.ts (326 lines), the rollup
|
||||
`import … from '@map/utils/countries-meta.js'`
|
||||
- **embedBase** — embedBase.js — Where THIS bundle's built tree is served from, resolved at runtime.
|
||||
`import … from '@map/utils/embedBase.js'`
|
||||
- **geoUtils** — geoUtils.js — Geographic / GeoJSON Utility Functions
|
||||
|
|
@ -191,6 +203,10 @@ and a change here can break other maps.
|
|||
|
||||
### Data (static reference tables)
|
||||
|
||||
- **countries** — "value": "AFG",
|
||||
`import … from '@map/data/countries.json'`
|
||||
- **country-posters** — "stats": {
|
||||
`import … from '@map/data/country-posters.json'`
|
||||
- **iso-numeric-to-alpha2** — "4":"AF","8":"AL","12":"DZ","24":"AO","32":"AR","36":"AU","40":"AT","50":"BD",
|
||||
`import … from '@map/data/iso-numeric-to-alpha2.json'`
|
||||
- **langFamilyByIso** — "aar": "Afro-Asiatic",
|
||||
|
|
|
|||
|
|
@ -199,7 +199,7 @@ name comes from `vite.doxa-maps-build-config.json` `name` (default `doxa-maps`).
|
|||
`emptyOutDir: false` keeps sibling bundles intact when you rebuild one.
|
||||
|
||||
**Shadow-style comments are stripped from production bundles.** Component CSS is injected
|
||||
from `useShadowStyles(\`…\`)` template literals that ship, so `internal/vite-plugin-strip-shadow-css-comments.mjs`
|
||||
from `useShadowStyles(\`…\`)` template literals that ship verbatim, so `internal/vite-plugin-strip-shadow-css-comments.mjs`
|
||||
(build only, `enforce: 'pre'`) removes the `/* … */` blocks inside those literals and collapses the blank lines
|
||||
they leave; a literal with `${…}` is left untouched with a warning, and dev mode keeps every comment.
|
||||
|
||||
|
|
@ -344,7 +344,7 @@ export MAPBOX_TOKEN=$(python3 -c "import json;print(json.load(open('vite.doxa-ma
|
|||
> diagnosed the blank basemap and then kept prescribing its cause.
|
||||
>
|
||||
> The `token` field in `vite.doxa-maps-build-config.json` returns **200** on the identical request
|
||||
> from any origin. It is a `pk.` public key baked deliberately (product owner ruling 2026-09-21), protected
|
||||
> from any origin. It is a `pk.` public key baked deliberately (agent ruling 2026-09-21), protected
|
||||
> by URL restrictions rather than secrecy; `check:public-hygiene` allows `pk.` and fails the build on
|
||||
> `sk.`/`tk.`. So the SHELL COMMANDS above — the ones that curl a token and hand it to a gate as
|
||||
> `MAPBOX_TOKEN` — now work where they previously fetched a token that 403s.
|
||||
|
|
@ -424,7 +424,7 @@ shape** — the same one that captioned the product SPA as the plugin-studio.
|
|||
**`bun run check:quoted-scripts` is the exception that proves the rule above** — it is the one
|
||||
doc-integrity gate that DOES run in CI (blocking), because it compares two files already in the
|
||||
checkout: it needs no browser, no network, and no mtime. It asserts that the `scripts` block
|
||||
quoted in `documentation/README-SECTION-3-cli-commands.md` still matches `package.json`. That doc
|
||||
quoted verbatim in `documentation/README-SECTION-3-cli-commands.md` still matches `package.json`. That doc
|
||||
carries its own hand-written plea to re-transcribe after every edit, and drifted anyway — twice.
|
||||
The second drift omitted `check:public-hygiene` (the secret scan) from the build chain it
|
||||
documents, which is the dangerous shape: **an omission in a quoted list is invisible**, since
|
||||
|
|
@ -457,7 +457,7 @@ Edit this one file, then `bun run build`.
|
|||
|
||||
## Data sources
|
||||
|
||||
### ★ Persecution-safety — a rule about WHICH FIELDS a profile may carry
|
||||
### ★ Hostility-safety — a rule about WHICH FIELDS a profile may carry
|
||||
|
||||
Read this before adding a field or a link to any profile. **Some profiles are deliberately kept free of
|
||||
gospel/church/believer data, and a profile failing a "should have resource links" check may be OBEYING
|
||||
|
|
@ -481,9 +481,9 @@ the same line literally for `cities-internet` means **adding a gospel-resource f
|
|||
header forbids exactly that**. A requirement that names two subjects deserves a per-subject check before
|
||||
anyone drains it.
|
||||
|
||||
> **"Persecution-safe" is used for three different things in this repo** — don't assume one rule. Here it
|
||||
> **"Hostility-safe" is used for three different things in this repo** — don't assume one rule. Here it
|
||||
> means FIELD SEPARATION. In `key-cities/cities-list.vue` it means placename precision ("a wrong placename
|
||||
> in a sensitive-context-sensitive dataset is the dangerous kind of wrong"). In
|
||||
> in a safety-sensitive dataset is the dangerous kind of wrong"). In
|
||||
> `research-dashboard/.../DashboardDataTable.vue` it means a dependency-free table (no third-party grid).
|
||||
> Only the first governs what data a profile may hold.
|
||||
|
||||
|
|
|
|||
|
|
@ -28,6 +28,6 @@
|
|||
internal developer platform (MCP onboarding, multi-tenant, CI/CD) and the multiplication
|
||||
compounds. Standalone today; exponential paired.
|
||||
13. **The stack** (name the real products) — Vite · Bun · Vue web components · MapLibre/Mapbox ·
|
||||
Capacitor · the forge (source-control-as-CDN). No lock-in — un-closable primitives.
|
||||
Capacitor · the git forge (source-control-as-CDN). No lock-in — un-closable primitives.
|
||||
14. **Try it / take it** — the repo, the docs (why → tutorial → how-to → reference → architecture),
|
||||
the live plugin gallery. Deep-dive: point your agent at documentation/architecture/.
|
||||
|
|
|
|||
33
documentation/vendored-font-noto-serif-devanagari.md
Normal file
33
documentation/vendored-font-noto-serif-devanagari.md
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
<!-- MOVED OUT OF THE SERVED FOLDER, 2026-09-24. This lived at
|
||||
public/assets/fonts/noto-serif-devanagari/README.md, where check:public-hygiene flagged it
|
||||
(twice — the source copy and the copy generate-tree stages into the tree) as "documentation in
|
||||
a served folder", and that finding was red-gating build:unlocked for every builder.
|
||||
The CONTENT is kept verbatim because it answers a question the files cannot: why this font is
|
||||
vendored at all, and why 400 and 700 resolve to one variable file.
|
||||
The font's LICENSE (SIL OFL 1.1) deliberately STAYS beside the woff2 — that one is a
|
||||
distribution obligation, not documentation, and removing it would breach the licence. -->
|
||||
|
||||
# Noto Serif Devanagari — the Devanagari subset only
|
||||
|
||||
**Why vendored:** the prayer packet's font stack is Georgia / Palatino Linotype /
|
||||
serif, none of which carry Devanagari. Measured by rendering Devanagari at 48px
|
||||
and READING THE PIXELS, Hindi came out as **tofu boxes** — this machine has zero
|
||||
Devanagari fonts (`fc-list`). A `hi` translation without this file would ship a
|
||||
page of empty rectangles.
|
||||
|
||||
**Licence:** SIL Open Font License 1.1 (see `LICENSE`). Free to bundle and serve.
|
||||
|
||||
**Subset:** `U+0900-097F` plus the Devanagari extensions Google's own subsetting
|
||||
selects — the *devanagari* range ONLY, matching how `poppins/` ships `latin` and
|
||||
`latin-ext` separately so a page downloads only what it needs. 124 KB, against
|
||||
6-8 KB for a Latin subset; that is the cost of the script's glyph count, and it
|
||||
loads only on a Hindi packet.
|
||||
|
||||
**Source:** the woff2 Google Fonts serves for
|
||||
`Noto+Serif+Devanagari:wght@400;700` with a modern browser UA. Note the 400 and
|
||||
700 faces resolve to the SAME file — it is a variable font, so one file covers
|
||||
both weights and vendoring two would duplicate 124 KB for nothing.
|
||||
|
||||
To refresh: request that CSS with a Chrome user-agent (without one Google serves
|
||||
unsubsetted TTF), take the `@font-face` block whose `unicode-range` contains
|
||||
`U+0900`, and download its woff2.
|
||||
48
internal/_serves-this-tree.mjs
Normal file
48
internal/_serves-this-tree.mjs
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
/**
|
||||
* _serves-this-tree.mjs — a browser gate must prove the server it talks to is THIS build.
|
||||
*
|
||||
* THE FAILURE THIS EXISTS FOR, MEASURED. Browser gates here default to
|
||||
* http://127.0.0.1:4173 and adopt whatever answers 200 on it. On this machine that port was
|
||||
* held by an unrelated process belonging to a different worktree, and the gospel-resources
|
||||
* gate spent three runs reporting on a build that was not the one on disk — first a PASS,
|
||||
* then a mount timeout that looked exactly like a regression in code that had nothing to do
|
||||
* with it. A gate that can report on the wrong tree is worse than no gate: it produces
|
||||
* confident findings about code nobody changed, and hides real ones.
|
||||
*
|
||||
* "Answers 200" and "is serving this checkout" are different questions. This asks the second.
|
||||
*
|
||||
* THE FINGERPRINT is public/doxa-maps/manifest.json, which generate-tree rewrites on every
|
||||
* build and which names every bundle and profile in the tree. If the bytes served differ
|
||||
* from the bytes on disk, it is a different build — a different worktree, or a stale server
|
||||
* someone forgot to restart, both of which produce the same lie.
|
||||
*
|
||||
* Deliberately NOT fatal on its own: the caller decides whether to start its own server
|
||||
* instead, or to skip. What it must never do is quietly continue.
|
||||
*/
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { createHash } from 'node:crypto'
|
||||
import { resolve } from 'node:path'
|
||||
|
||||
const MANIFEST = 'doxa-maps/manifest.json'
|
||||
|
||||
/**
|
||||
* @param {string} base e.g. http://127.0.0.1:4173 (a trailing /doxa-maps/ is tolerated)
|
||||
* @param {string} repo absolute path to the repo root
|
||||
* @returns {Promise<{ok:boolean, why:string}>}
|
||||
*/
|
||||
export async function servesThisTree(base, repo) {
|
||||
let local
|
||||
try {
|
||||
local = createHash('sha256').update(readFileSync(resolve(repo, 'public', MANIFEST))).digest('hex')
|
||||
} catch {
|
||||
return { ok: false, why: `no local public/${MANIFEST} to compare against — build the tree first` }
|
||||
}
|
||||
const root = String(base).replace(/\/+$/, '').replace(/\/doxa-maps$/, '')
|
||||
const r = await fetch(`${root}/${MANIFEST}`).catch(() => null)
|
||||
if (!r || !r.ok) return { ok: false, why: `nothing serving ${MANIFEST} at ${root}` }
|
||||
const served = createHash('sha256').update(Buffer.from(await r.arrayBuffer())).digest('hex')
|
||||
if (served !== local) {
|
||||
return { ok: false, why: `${root} serves a DIFFERENT build than public/ — another worktree, or a server that needs restarting` }
|
||||
}
|
||||
return { ok: true, why: 'same build' }
|
||||
}
|
||||
|
|
@ -19,7 +19,7 @@ const API = 'https://pray.doxa.life/api/people-groups/list?fields=name,slug,wagf
|
|||
|
||||
// ── mirror of people-groups-grid.vue applyDropdownFilters + the Fuse-ish search keys ──────
|
||||
// Dropdown axes match by VALUE (u[field].value === selection) — the exact contract the header
|
||||
// dropdown -> grid prop coupling relies on. Country is excluded per product owner.
|
||||
// dropdown -> grid prop coupling relies on. Country is excluded per agent.
|
||||
function dropdownFilter(recs, { wagfRegion = '', wagfBlock = '', rop1 = '', religion = '' } = {}) {
|
||||
let out = recs
|
||||
for (const [f, sel] of Object.entries({ wagf_region: wagfRegion, wagf_block: wagfBlock, rop1, religion })) {
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@
|
|||
* API. Camera motion in particular (fitBounds/flyTo) has NO API equivalent to mirror; the only
|
||||
* way to prove "select rows, the map flies to the selection" is to select rows and read the map.
|
||||
*
|
||||
* ── THE COMBINATION MATRIX (product owner: "EVERY SINGLE COMBINATION ... log whether it DISPLAYED,
|
||||
* ── THE COMBINATION MATRIX (agent: "EVERY SINGLE COMBINATION ... log whether it DISPLAYED,
|
||||
* pass/fail only") ─────────────────────────────────────────────────────────────────────────
|
||||
* One line per combination, BOTH halves recorded, pass/fail only. "Displayed" is never inferred
|
||||
* from a click firing or an element existing — see WHAT COUNTS AS DISPLAYED per pair below.
|
||||
|
|
@ -48,13 +48,13 @@
|
|||
* and requires the harness to FAIL, NAMING the broken combination. `--self-test-mode=camera-freeze`
|
||||
* poisons useSelectionCamera's target map instead, reproducing "the grid->map link stops moving
|
||||
* the camera" and requires THAT combination named. A harness that has only ever reported PASS is
|
||||
* unproven (tracked issue): "I made it fail" is not enough
|
||||
* unproven (LL-negative-test-the-regression-not-the-feature): "I made it fail" is not enough
|
||||
* evidence — the question is whether it fails on the SAME defect shape the real bug had.
|
||||
*
|
||||
* PRECONDITIONS, checked and SKIPPED (exit 0) rather than reported as a defect when unmet:
|
||||
* - the tree must be SETTLED (internal/settled-tree-gate.mjs) — a build mid-flight makes any
|
||||
* browser gate that reads it report a confident, false diagnosis
|
||||
* (tracked issue).
|
||||
* (LL-verify-the-tree-is-settled-before-trusting-a-browser-gate).
|
||||
* - the live API (pray.doxa.life) must be reachable.
|
||||
*
|
||||
* USAGE
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@
|
|||
# this makes the collision STRUCTURALLY impossible: the FIRST build acquires the lock; a SECOND build that
|
||||
# tries while it's held FAILS FAST with a clear message instead of corrupting the tree.
|
||||
#
|
||||
# This is the local-dev form of IDEA-FORGEJO-CICD-BUILD-LOCK-MULTIWRITER (the CI/CD concurrency mutex) — the
|
||||
# This is the local-dev form of the CI/CD build-lock design (the CI/CD concurrency mutex) — the
|
||||
# swarm hit the hazard before that gate, so the primitive is built now. Same contract: first-wins, second
|
||||
# fails clean, no race.
|
||||
#
|
||||
|
|
|
|||
|
|
@ -34,6 +34,7 @@ import { readFileSync, existsSync } from 'node:fs'
|
|||
import { resolve, dirname, join } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { chromium } from 'playwright'
|
||||
import { servesThisTree } from './_serves-this-tree.mjs'
|
||||
|
||||
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..')
|
||||
const MANIFEST = join(ROOT, 'public', 'doxa-maps', 'manifest.json')
|
||||
|
|
@ -45,6 +46,20 @@ if (!existsSync(MANIFEST)) {
|
|||
process.exit(0)
|
||||
}
|
||||
|
||||
// ★ The server has to be serving THIS checkout, not merely answering. 4173 on this machine
|
||||
// was held by an unrelated process from another worktree, and a gate that adopts it reports
|
||||
// a confident result about a build nobody here changed. A pass earned against the wrong tree
|
||||
// is worse than no pass.
|
||||
{
|
||||
const tree = await servesThisTree(BASE, ROOT)
|
||||
if (!tree.ok) {
|
||||
console.error('A11Y-BUTTON-NAMES: NOT MEASURED — the server is not serving this checkout.')
|
||||
console.error(` ${tree.why}`)
|
||||
console.error(' Point BASE at your own server (BASE=http://127.0.0.1:PORT/doxa-maps/).')
|
||||
process.exit(2)
|
||||
}
|
||||
}
|
||||
|
||||
let tk = ''
|
||||
try {
|
||||
const r = await fetch('https://doxa.life/api/maps/token', { signal: AbortSignal.timeout(8000) })
|
||||
|
|
|
|||
159
internal/check-aria-labels-localized.mjs
Normal file
159
internal/check-aria-labels-localized.mjs
Normal file
|
|
@ -0,0 +1,159 @@
|
|||
#!/usr/bin/env node
|
||||
// check-aria-labels-localized — no control may announce a RAW I18N KEY, and the
|
||||
// accessible names must change with the locale.
|
||||
//
|
||||
// ★ THE ONE STRING A SIGHTED TESTER NEVER SEES. Every other untranslated string in
|
||||
// this tree got caught because somebody looked at the screen. aria-label is only ever
|
||||
// spoken aloud, so it survived every visual QA pass. Measured 2026-09-24 on the served
|
||||
// upg-100-list leaf: the legend's toggle button announced the literal string
|
||||
// "aria.toggleLegend" — in English too, not just translated locales — because vue-i18n
|
||||
// ECHOES THE KEY when a lookup misses and `toggleLegend` was absent from aria.json
|
||||
// while its two siblings (backToLegend, closeDetailView) were present. Six call sites
|
||||
// across LegendDesktop.vue and LegendMobile.vue, desktop AND mobile, every locale.
|
||||
//
|
||||
// A second instance in the same sweep had the opposite shape: SemanticTreeLegend's
|
||||
// collapse button hardcoded 'Collapse legend' / 'Expand legend' with no t() at all, so
|
||||
// it was correct English and untranslatable. One key missing FROM the catalog, one
|
||||
// string never routed TO it — the gate below catches both, because it compares what is
|
||||
// ANNOUNCED across locales rather than reading the source.
|
||||
//
|
||||
// ⚠ TWO ASSERTIONS, NOT ONE. "No raw keys" alone passes a hardcoded English label, and
|
||||
// "labels differ across locales" alone passes a raw key (a key is identical in every
|
||||
// locale — which is exactly why the SEPARABILITY floor below is per-locale).
|
||||
//
|
||||
// ⚠ A RAW KEY HAS A SHAPE NO HUMAN LABEL HAS: dotted, camelCase, no spaces. That is the
|
||||
// whole detector, and it is locale-independent — it works on Arabic and Chinese pages
|
||||
// where no English word list could.
|
||||
//
|
||||
// Usage: node internal/check-aria-labels-localized.mjs <leafUrl> [locales]
|
||||
import { chromium } from 'playwright'
|
||||
|
||||
const URL_ = process.argv[2] || process.env.ARIA_URL
|
||||
if (!URL_) {
|
||||
console.log('ARIA-LABELS-LOCALIZED: SKIP — no leaf url given.')
|
||||
console.log(' Pass one, or set ARIA_URL. Serve the TREE ROOT (public/) and point at a leaf,')
|
||||
console.log(' e.g. doxa-maps/my-upg-100-list/upg-100-list/index.html')
|
||||
process.exit(0)
|
||||
}
|
||||
const UNDER_TEST = (process.argv[3] || 'es,ar,zh').split(',').map((s) => s.trim()).filter(Boolean)
|
||||
|
||||
// A raw vue-i18n key: starts lowercase, dotted, no whitespace. Human labels have spaces
|
||||
// ("Zoom in") or are non-Latin; neither matches.
|
||||
const RAW_KEY = /^[a-z][a-zA-Z0-9]*(\.[a-zA-Z0-9]+)+$/
|
||||
|
||||
// How many accessible names MUST change when the locale changes. Measured on the
|
||||
// upg-100-list leaf 2026-09-24: 6 of 13, desktop and mobile alike. The rest are map-
|
||||
// engine controls (Mapbox's own zoom/fullscreen buttons) whose labels this tree does
|
||||
// not own. Override for a leaf with a different control set.
|
||||
const LOCALIZED_FLOOR = Number(process.env.ARIA_LOCALIZED_FLOOR || 6)
|
||||
|
||||
async function readLabels(browser, lang, viewport, mobile) {
|
||||
const page = await browser.newPage({ viewport, isMobile: mobile, hasTouch: mobile })
|
||||
const errs = []
|
||||
page.on('pageerror', (e) => errs.push(String(e).slice(0, 120)))
|
||||
await page.goto(`${URL_}?lang=${lang}`, { waitUntil: 'load', timeout: 45000 })
|
||||
await page.waitForTimeout(9000)
|
||||
const labels = await page.evaluate(() => {
|
||||
const walk = (root, out = []) => {
|
||||
for (const el of root.querySelectorAll('*')) { out.push(el); if (el.shadowRoot) walk(el.shadowRoot, out) }
|
||||
return out
|
||||
}
|
||||
// Shadow-piercing: these controls live several shadow roots deep.
|
||||
return walk(document)
|
||||
.filter((e) => e.getAttribute && e.getAttribute('aria-label') !== null)
|
||||
.map((e) => (e.getAttribute('aria-label') || '').trim())
|
||||
})
|
||||
await page.close()
|
||||
return { labels, errs }
|
||||
}
|
||||
|
||||
const browser = await chromium.launch({ args: ['--use-gl=swiftshader', '--enable-unsafe-swiftshader'] })
|
||||
const VIEWPORTS = [
|
||||
{ name: 'desktop', viewport: { width: 1500, height: 1000 }, mobile: false },
|
||||
{ name: 'mobile', viewport: { width: 390, height: 844 }, mobile: true },
|
||||
]
|
||||
const seen = {}
|
||||
try {
|
||||
for (const vp of VIEWPORTS) {
|
||||
seen[vp.name] = {}
|
||||
for (const loc of ['en', ...UNDER_TEST]) {
|
||||
seen[vp.name][loc] = await readLabels(browser, loc, vp.viewport, vp.mobile)
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
await browser.close()
|
||||
}
|
||||
|
||||
const fails = []
|
||||
const report = []
|
||||
|
||||
for (const vp of VIEWPORTS) {
|
||||
const byLoc = seen[vp.name]
|
||||
const en = byLoc.en
|
||||
|
||||
// PRECONDITION: a page with no aria-labels at all makes every assertion vacuous. A
|
||||
// bundle that failed to execute renders nothing with zero page errors (the stale-SRI
|
||||
// signature), and would otherwise read as "nothing untranslated".
|
||||
if (!en.labels.length) {
|
||||
fails.push(`${vp.name}: no aria-labels rendered at all — either the wrong url, or the `
|
||||
+ 'bundle did not execute (a stale SRI hash refuses the script SILENTLY; run '
|
||||
+ '`bun run tree` after `BUNDLE=<n> bun x vite build`)')
|
||||
continue
|
||||
}
|
||||
|
||||
for (const loc of ['en', ...UNDER_TEST]) {
|
||||
const s = byLoc[loc]
|
||||
// ASSERTION 1 — NO RAW KEYS, in every locale INCLUDING English. A missing catalog
|
||||
// entry is announced as its own key name, which is gibberish to a screen reader.
|
||||
const raw = [...new Set(s.labels.filter((t) => RAW_KEY.test(t)))]
|
||||
if (raw.length) {
|
||||
fails.push(`${vp.name}/${loc}: ${raw.length} control(s) announce a RAW I18N KEY — `
|
||||
+ `${JSON.stringify(raw)} — vue-i18n echoes the key when the lookup misses, so a `
|
||||
+ 'screen reader reads the key name aloud')
|
||||
}
|
||||
// NEVER BLANK either: an empty aria-label is worse than none, it suppresses the
|
||||
// fallback to the element's own text.
|
||||
const blank = s.labels.filter((t) => !t).length
|
||||
if (blank) fails.push(`${vp.name}/${loc}: ${blank} EMPTY aria-label(s)`)
|
||||
if (s.errs.length) fails.push(`${vp.name}/${loc}: ${s.errs.length} page error(s): ${s.errs[0]}`)
|
||||
}
|
||||
|
||||
// ASSERTION 2 — THE LOCALE MUST MOVE THEM. Raw-key-free is not the same as localized:
|
||||
// a hardcoded English label passes assertion 1 forever. At least one accessible name
|
||||
// must differ from English in each locale, or nothing on this surface is translated.
|
||||
for (const loc of UNDER_TEST) {
|
||||
const s = byLoc[loc]
|
||||
const diff = s.labels.filter((t, i) => en.labels[i] && t !== en.labels[i]).length
|
||||
if (s.labels.length && diff === 0) {
|
||||
fails.push(`${vp.name}/${loc}: NOT ONE of ${s.labels.length} aria-label(s) differs from `
|
||||
+ 'English — the accessible names are hardcoded, not catalogued')
|
||||
}
|
||||
// ⚠ A FLOOR, NOT JUST "> 0". `diff === 0` alone is decoration: it prints a count and
|
||||
// only fails when EVERY label regresses. Measured 2026-09-24 — hardcoding ONE label
|
||||
// back to English dropped this from 6 to 5 and the gate still passed, which is the
|
||||
// exact shape ("a number printed but not asserted on") this tree keeps rediscovering.
|
||||
// The floor is the count that currently holds, so any single regression goes red.
|
||||
// RAISE IT when you localize more; never lower it to make a red build green.
|
||||
if (s.labels.length && diff < LOCALIZED_FLOOR) {
|
||||
fails.push(`${vp.name}/${loc}: only ${diff} of ${s.labels.length} aria-label(s) differ from `
|
||||
+ `English, expected at least ${LOCALIZED_FLOOR} — a label that used to be catalogued `
|
||||
+ 'has been hardcoded back to English (it leaves no raw key behind, so assertion 1 '
|
||||
+ 'cannot see it)')
|
||||
}
|
||||
report.push(` ${vp.name.padEnd(8)} ${loc.padEnd(3)}: ${diff}/${s.labels.length} differ from English, `
|
||||
+ `${s.labels.filter((t) => RAW_KEY.test(t)).length} raw key(s), ${s.labels.filter((t) => !t).length} blank`)
|
||||
}
|
||||
}
|
||||
|
||||
console.log('ARIA-LABELS-LOCALIZED')
|
||||
console.log(` leaf : ${URL_}`)
|
||||
console.log(` labels : ${seen.desktop?.en?.labels.length ?? 0} desktop / ${seen.mobile?.en?.labels.length ?? 0} mobile (English)`)
|
||||
for (const line of report) console.log(line)
|
||||
|
||||
if (fails.length) {
|
||||
console.error(`\nARIA-LABELS-LOCALIZED: FAIL — ${fails.length} problem(s)`)
|
||||
for (const f of fails) console.error(' - ' + f)
|
||||
process.exit(1)
|
||||
}
|
||||
console.log('\nARIA-LABELS-LOCALIZED: PASS — no control announces a raw i18n key, none is blank, '
|
||||
+ 'and choosing a locale changes what a screen reader says.')
|
||||
|
|
@ -27,7 +27,10 @@
|
|||
* to the enum; 130/130 preserved, 0 lost. The explanation was never the problem — putting it in the
|
||||
* field that tooling reads was.
|
||||
*
|
||||
* WHAT IT ASSERTS: every card's `status` is one of todo / doing / in-progress / blocked / done.
|
||||
* WHAT IT ASSERTS: every card's `status` is ONE WORD from a closed set — the five workflow
|
||||
* states plus four terminal ones (superseded / deferred / decided / verified-already-true).
|
||||
* The point is not the particular words: it is that `status` stays machine-readable so the
|
||||
* open-count is right. Prose belongs in fields.status_detail.
|
||||
*
|
||||
* It deliberately does NOT require `status_detail` to exist. Most cards need no explanation, and
|
||||
* demanding one would turn a real check into paperwork that gets satisfied with filler.
|
||||
|
|
@ -41,7 +44,23 @@ import { fileURLToPath } from 'node:url'
|
|||
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..')
|
||||
const QA = join(ROOT, '../internal-tracker')
|
||||
const PHASES = ['01-ideation-research', '02-builder-cycle', '03-deployment-cycle']
|
||||
const ALLOWED = new Set(['todo', 'doing', 'in-progress', 'blocked', 'done'])
|
||||
// ★ THE ENUM WAS TOO NARROW, not the cards wrong. MEASURED 2026-09-24 across 633 cards:
|
||||
// EIGHT distinct status values are in use and EVERY ONE IS A SINGLE WORD — there is no
|
||||
// prose left anywhere in the field. The nine this gate was failing carried legitimate
|
||||
// TERMINAL states the original enum simply predated: superseded (5), deferred (2),
|
||||
// decided, verified-already-true.
|
||||
//
|
||||
// This gate exists to keep the AGGREGATE honest — "a prose status makes every open-count
|
||||
// wrong while each card still READS correctly". A one-word terminal state serves that
|
||||
// purpose perfectly; it is exactly what the 130-card migration was FOR. Rewriting nine
|
||||
// accurate statuses to 'done' would have destroyed real distinctions (a SUPERSEDED card
|
||||
// was replaced by another; a DEFERRED one was consciously postponed; neither was
|
||||
// finished) to satisfy a list — losing information to make a checker green.
|
||||
//
|
||||
// OPEN vs CLOSED is the property that matters, and all four additions are CLOSED:
|
||||
// open = todo + doing + in-progress (66), closed = done + these four (507), blocked (60).
|
||||
const TERMINAL_EXTRA = ['superseded', 'deferred', 'decided', 'verified-already-true']
|
||||
const ALLOWED = new Set(['todo', 'doing', 'in-progress', 'blocked', 'done', ...TERMINAL_EXTRA])
|
||||
|
||||
if (!existsSync(QA)) {
|
||||
// internal-tracker lives outside every repo the CI clones, so absence is EXPECTED there. Say so loudly
|
||||
|
|
|
|||
|
|
@ -53,8 +53,8 @@ const MANIFEST = join(ROOT, 'public', 'doxa-maps', 'manifest.json')
|
|||
// defect recur unnoticed in a second place.
|
||||
//
|
||||
// NOT COVERED, and it cannot be: the /plugin-designer SKILL carries the same stale citation and
|
||||
// lives outside this repo, so no gate here can reach it. That is why the fix is a product owner edit; the
|
||||
// repo's job is to hold the checkable half (see a tracked issue).
|
||||
// lives outside this repo, so no gate here can reach it. That is why the fix is a agent edit; the
|
||||
// repo's job is to hold the checkable half (see BUILD-PLUGIN-DESIGNER-SKILL-MEANS-REAL-WORK).
|
||||
const DOC_DIR = join(ROOT, 'documentation')
|
||||
const DOC_FILES = existsSync(DOC_DIR)
|
||||
? readdirSync(DOC_DIR).filter(f => f.endsWith('.md')).map(f => join(DOC_DIR, f))
|
||||
|
|
|
|||
117
internal/check-card-deck.mjs
Normal file
117
internal/check-card-deck.mjs
Normal file
|
|
@ -0,0 +1,117 @@
|
|||
// check-card-deck — a DECK must print as CARDS, not as paper.
|
||||
//
|
||||
// THE BUG THIS EXISTS FOR: the previous deck used @page{size:letter portrait} with
|
||||
// height:100% per card, so one card stretched across a whole 8.5x11 sheet — 95
|
||||
// mostly-empty letter pages, and no photo at all. "The print dialog needs to show
|
||||
// all the cards not in a paper format but a CARD format."
|
||||
//
|
||||
// ⚠ IT ASSERTS THE MEASURED MediaBox, NOT THE CSS. Chromium SILENTLY falls back to
|
||||
// letter for a named size it does not know: `size: A6 portrait` yields 612x792pt
|
||||
// with no error, while `size: 2.5in 3.5in` yields exactly 180x252pt. A gate that
|
||||
// grepped the stylesheet would pass on a deck that prints as letter paper.
|
||||
//
|
||||
// Usage: node internal/check-card-deck.mjs [lang] [stock]
|
||||
// PACKET_ORIGIN (default http://127.0.0.1:4898) · PACKET_API · PACKET_ISO3
|
||||
import { chromium } from 'playwright'
|
||||
|
||||
const ORIGIN = process.env.PACKET_ORIGIN || 'http://127.0.0.1:4898'
|
||||
const API = process.env.PACKET_API || 'https://pray.doxa.life'
|
||||
const ISO3 = process.env.PACKET_ISO3 || 'LAO'
|
||||
const LANG = process.argv[2] || 'en'
|
||||
const STOCK = process.argv[3] || 'poker'
|
||||
// Expected MediaBox in points, derived from the stock's inches (72pt = 1in).
|
||||
// a6 is 4.13x5.83in, the inch rounding of 105x148mm -> ~297.4x419.8pt. An earlier
|
||||
// constant here said 396.5x559.7 and FAILED a correct render: the gate's own
|
||||
// arithmetic was wrong, not the renderer. Derive, do not hand-copy.
|
||||
const IN = { poker: [2.5, 3.5], postcard: [4, 6], a6: [4.13, 5.83] }[STOCK]
|
||||
if (!IN) { console.error(`unknown stock "${STOCK}"`); process.exit(2) }
|
||||
const EXPECT = IN.map(v => +(v * 72).toFixed(2))
|
||||
|
||||
const b = await chromium.launch()
|
||||
const pg = await b.newPage({ viewport: { width: 900, height: 1200 } })
|
||||
const errs = []; pg.on('pageerror', e => errs.push(String(e).slice(0, 140)))
|
||||
await pg.goto(ORIGIN + '/')
|
||||
|
||||
const out = await pg.evaluate(async ({ ORIGIN, API, ISO3, LANG, STOCK }) => {
|
||||
const [{ createCardRenderer }, strings, norm] = await Promise.all([
|
||||
import(`${ORIGIN}/library/packet/card-render.js`),
|
||||
fetch(`${ORIGIN}/library/scripts/posters/packet-strings.json`).then(r => r.json()),
|
||||
import(`${ORIGIN}/plugin-designs/my-upg-100-list/data/normalize.js`),
|
||||
])
|
||||
const resp = await fetch(`${API}/api/people-groups/list?fields=slug,name,country_code,population,religion,image_url,primary_language&lang=en`, { cache: 'no-store' })
|
||||
const recs = norm.normalizeRecords(norm.extractRecords(await resp.json()))
|
||||
const groups = recs.filter(r => String(r.country_code) === ISO3)
|
||||
.sort((a, b) => String(a.name).localeCompare(String(b.name), LANG))
|
||||
const R = createCardRenderer({ strings, lang: LANG, stock: STOCK })
|
||||
return { html: R.buildDeck({ id: ISO3.toLowerCase(), label: ISO3 }, groups), n: groups.length }
|
||||
}, { ORIGIN, API, ISO3, LANG, STOCK })
|
||||
|
||||
await pg.setContent(out.html, { waitUntil: 'load' })
|
||||
await pg.waitForTimeout(4000)
|
||||
const r = await pg.evaluate(() => {
|
||||
const cards = [...document.querySelectorAll('.card')]
|
||||
const imgs = [...document.querySelectorAll('img.ph')]
|
||||
const qrs = [...document.querySelectorAll('.qr svg')]
|
||||
// Does the QR carry a real symbol, or an empty path?
|
||||
const qrPaths = qrs.map(s => (s.querySelector('path')?.getAttribute('d') || '').length)
|
||||
return {
|
||||
cards: cards.length,
|
||||
imgs: imgs.length,
|
||||
broken: imgs.filter(i => !i.complete || i.naturalWidth === 0).length,
|
||||
nophoto: document.querySelectorAll('.ph-none').length,
|
||||
qrs: qrs.length,
|
||||
qrEmpty: qrPaths.filter(n => n < 50).length,
|
||||
urls: [...document.querySelectorAll('.url')].slice(0, 2).map(e => e.textContent),
|
||||
// ⚠ ASSERT THE VALUES ARE HUMAN-READABLE, not merely present. The first deck
|
||||
// rendered "LAO" / "thm" / "EAN" because normalizeRecords() flattens each
|
||||
// { value, label } dict to its VALUE and parks the label under <field>_label.
|
||||
// A gate counting rows passed it: the rows were there, just unreadable.
|
||||
// An all-caps or all-lowercase token of 2-4 chars with no space is a code.
|
||||
codeish: [...document.querySelectorAll('.v')]
|
||||
.map(e => (e.textContent || '').trim())
|
||||
.filter(v => /^[A-Z]{2,4}$|^[a-z]{2,4}$/.test(v)),
|
||||
cta: (document.querySelector('.cta') || {}).textContent || '',
|
||||
overflow: cards.filter(c => c.scrollHeight > c.clientHeight + 2).length,
|
||||
}
|
||||
})
|
||||
const pdf = await pg.pdf({ preferCSSPageSize: true, printBackground: true })
|
||||
await b.close()
|
||||
|
||||
const raw = pdf.toString('latin1')
|
||||
const boxes = [...raw.matchAll(/MediaBox\s*\[\s*0\s+0\s+([\d.]+)\s+([\d.]+)\s*\]/g)].map(m => [+m[1], +m[2]])
|
||||
const pages = (raw.match(/\/Type\s*\/Page[^s]/g) || []).length
|
||||
const [pw, ph] = boxes[0] || [0, 0]
|
||||
|
||||
const fails = []
|
||||
if (!r.cards) fails.push('no cards rendered')
|
||||
if (pages !== r.cards) fails.push(`${pages} PDF pages for ${r.cards} cards — one card must be exactly one page`)
|
||||
if (Math.abs(pw - EXPECT[0]) > 2 || Math.abs(ph - EXPECT[1]) > 2) {
|
||||
fails.push(`page is ${pw}x${ph}pt, expected ${EXPECT[0]}x${EXPECT[1]}pt for "${STOCK}"`
|
||||
+ (Math.abs(pw - 612) < 2 ? ' — this is LETTER: the @page size silently fell back' : ''))
|
||||
}
|
||||
if (r.broken) fails.push(`${r.broken} broken portrait(s)`)
|
||||
if (!r.imgs && !r.nophoto) fails.push('no portraits at all — a prayer card needs a face')
|
||||
if (r.qrs !== r.cards) fails.push(`${r.qrs} QR codes for ${r.cards} cards`)
|
||||
if (r.qrEmpty) fails.push(`${r.qrEmpty} QR code(s) render an empty symbol`)
|
||||
if (r.overflow) fails.push(`${r.overflow} card(s) overflow their own box`)
|
||||
if (r.codeish.length) {
|
||||
fails.push(`${r.codeish.length} field(s) show a raw CODE instead of a label `
|
||||
+ `(e.g. ${[...new Set(r.codeish)].slice(0, 4).join(', ')}) — read <field>_label`)
|
||||
}
|
||||
if (errs.length) fails.push(`${errs.length} page error(s): ${errs[0]}`)
|
||||
|
||||
console.log(` stock / lang : ${STOCK} / ${LANG}`)
|
||||
console.log(` cards / PDF pages: ${r.cards} / ${pages}`)
|
||||
console.log(` page size : ${pw} x ${ph} pt (${(pw / 72).toFixed(2)} x ${(ph / 72).toFixed(2)} in)`)
|
||||
console.log(` portraits : ${r.imgs} (${r.broken} broken, ${r.nophoto} placeholder)`)
|
||||
console.log(` QR codes : ${r.qrs} (${r.qrEmpty} empty)`)
|
||||
console.log(` sample URL : ${r.urls[0] || '—'}`)
|
||||
console.log(` call to action : ${r.cta}`)
|
||||
console.log(` raw codes shown : ${r.codeish.length}${r.codeish.length ? ' (' + [...new Set(r.codeish)].slice(0,4).join(', ') + ')' : ''}`)
|
||||
console.log(` overflowing cards: ${r.overflow}`)
|
||||
if (fails.length) {
|
||||
console.error(`\nCARD-DECK: FAIL — ${fails.length} problem(s)`)
|
||||
for (const f of fails) console.error(' - ' + f)
|
||||
process.exit(1)
|
||||
}
|
||||
console.log(`\nCARD-DECK: PASS — ${r.cards} cards, one card per ${(pw / 72).toFixed(2)}x${(ph / 72).toFixed(2)}in page.`)
|
||||
|
|
@ -3,7 +3,7 @@
|
|||
* check-catalog-identity.mjs — prove the LIVE server is serving the CATALOG's exact bytes.
|
||||
*
|
||||
* THE QUESTION THIS ANSWERS, AND WHY THE OBVIOUS CHECK CANNOT ANSWER IT
|
||||
* Card 1 (tracked issue) asks that ONE public the forge repo be the live
|
||||
* Card 1 (the public-pipeline design) asks that ONE public the git forge repo be the live
|
||||
* catalog source, and that the merge lose nothing. The tempting verification is `curl -o /dev/null
|
||||
* -w '%{http_code}'` over a few URLs. That proves only that SOMETHING answered:
|
||||
* · https://the-host/app/doxa-maps/manifest.json returns **200** and is a 2,872-byte HTML
|
||||
|
|
@ -22,7 +22,7 @@
|
|||
* additionally require that it matches a cloned catalog checkout.
|
||||
*
|
||||
* MEASURED 2026-09-21: 17 of 17 bundles identity-proven, 0 mismatched, and the live public manifest
|
||||
* is byte-identical (sha256 88ae3832…) to platform/mfe-index's. The catalog IS what the-platform serves.
|
||||
* is byte-identical (sha256 88ae3832…) to platform/mfe-index's. The catalog IS what the-host serves.
|
||||
*
|
||||
* USAGE
|
||||
* bun run check:catalog-identity # live public door vs its own manifest
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
/**
|
||||
* check-csv-export-shape.mjs — the exported CSV must OPEN correctly in a spreadsheet.
|
||||
*
|
||||
* THE DEFECT THIS CATCHES (product owner-reported 2026-09-22: "this is me exporting the CSV directly without
|
||||
* THE DEFECT THIS CATCHES (agent-reported 2026-09-22: "this is me exporting the CSV directly without
|
||||
* making any edits... it does look a little bit glitched out"). The generator emitted a one-column
|
||||
* provenance comment ABOVE the header:
|
||||
* line 0 ... 1 column "# Research Dashboard export — scope: ..."
|
||||
|
|
@ -13,7 +13,7 @@
|
|||
*
|
||||
* ★ WHAT WAS *NOT* WRONG, AND MUST NOT BE "FIXED" ON SUSPICION — three plausible theories, all
|
||||
* refuted by measurement before a line was changed:
|
||||
* - "it is tab-separated" — FALSE. The generator joins with commas; the tabs in the product owner's paste
|
||||
* - "it is tab-separated" — FALSE. The generator joins with commas; the tabs in the agent's paste
|
||||
* were his terminal rendering, not the bytes.
|
||||
* - "quoting is broken" — FALSE. esc() is genuinely RFC-4180: it quotes when , " CR or LF appear and
|
||||
* doubles internal quotes. Embedded commas, quotes AND newlines survive a strict parser round trip.
|
||||
|
|
@ -122,7 +122,7 @@ const first = parsed[0] || []
|
|||
if (first.length === 1 && String(first[0]).startsWith('#')) {
|
||||
fail('line 0 is a one-column "#" comment, so a spreadsheet will use it AS THE HEADER.',
|
||||
' The scope sentence becomes the column names and the real header becomes data row 1 — which is\n' +
|
||||
' what the product owner saw as a (project requirement) export. Put the provenance line LAST; a trailing one-column\n' +
|
||||
' what the agent saw as a "glitched" export. Put the provenance line LAST; a trailing one-column\n' +
|
||||
' row is harmless, a leading one destroys the column mapping for the whole sheet.')
|
||||
}
|
||||
if (first[0] !== cols[0]) {
|
||||
|
|
|
|||
148
internal/check-deck-click.mjs
Normal file
148
internal/check-deck-click.mjs
Normal file
|
|
@ -0,0 +1,148 @@
|
|||
// check-deck-click — click the REAL Card Deck button and read what it prints.
|
||||
//
|
||||
// Every other packet/card gate drives the RENDERERS directly. This one drives the
|
||||
// UI: it finds the button in the shadow root, clicks it, and captures the document
|
||||
// that reaches the print frame. That is the only path that proves the wiring —
|
||||
// records provider, stock picker, language, shared composable, print frame — is
|
||||
// connected end to end. A renderer can be perfect while the button that calls it
|
||||
// passes the wrong stock, or nothing at all.
|
||||
//
|
||||
// ⚠ IT INTERCEPTS THE PRINTED HTML rather than reading the iframe back.
|
||||
// openPrintFrame calls window.print(), which BLOCKS in headless Chromium, and the
|
||||
// frame self-removes on afterprint — reading it back measures a frame that has
|
||||
// already gone and reports 0 cards for a correct deck.
|
||||
//
|
||||
// Usage: node internal/check-deck-click.mjs <wallUrl> [stockId] [expectedPage]
|
||||
import { chromium } from 'playwright'
|
||||
import { readFileSync, existsSync } from 'node:fs'
|
||||
import { resolve, dirname, join } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
const URL_ = process.argv[2]
|
||||
if (!URL_) { console.error('usage: check-deck-click.mjs <wallUrl> [stock] [expectedPage]'); process.exit(2) }
|
||||
const STOCK = process.argv[3] || ''
|
||||
const EXPECT_PAGE = process.argv[4] || (STOCK === 'pokerLandscape' ? '3.5in 2.5in' : '2.5in 3.5in')
|
||||
|
||||
// ★ THE LABEL COMES FROM THE CATALOG FOR THIS PAGE'S LOCALE, never a hardcoded
|
||||
// English string. This matched /card deck/i, so it could only ever run in English:
|
||||
// pointed at ?lang=es it reported "no Card Deck button" and listed 28 buttons all
|
||||
// reading "Baraja de tarjetas" — the UI correctly translated, the GATE unable to
|
||||
// see it. The card deck is a core prayer artefact and it had ZERO coverage in the
|
||||
// ten locales where a rendering bug is most likely.
|
||||
const REPO = process.env.DOXA_REPO || resolve(dirname(fileURLToPath(import.meta.url)), '..')
|
||||
const localeOf = (u) => { try { return new URL(u).searchParams.get('lang') || 'en' } catch { return 'en' } }
|
||||
const deckLabel = (loc) => {
|
||||
const f = join(REPO, 'library/i18n/locales', loc, 'buttons.json')
|
||||
if (!existsSync(f)) return null
|
||||
try { return JSON.parse(readFileSync(f, 'utf8')).cardDeck || null } catch { return null }
|
||||
}
|
||||
const LOC = localeOf(URL_)
|
||||
const WANT_DECK = deckLabel(LOC) || deckLabel('en') || 'Card Deck'
|
||||
|
||||
const b = await chromium.launch({ args: ['--use-gl=swiftshader', '--enable-unsafe-swiftshader'] })
|
||||
const pg = await b.newPage({ viewport: { width: 1280, height: 1000 } })
|
||||
const errs = []
|
||||
pg.on('pageerror', (e) => errs.push(String(e).slice(0, 140)))
|
||||
await pg.goto(URL_, { waitUntil: 'load', timeout: 45000 })
|
||||
await pg.waitForTimeout(9000)
|
||||
|
||||
await pg.evaluate((want) => { window.__WANT_DECK = want }, WANT_DECK)
|
||||
|
||||
await pg.evaluate(() => {
|
||||
window.__printed = ''
|
||||
const real = document.createElement.bind(document)
|
||||
document.createElement = (t) => {
|
||||
const el = real(t)
|
||||
if (t === 'iframe') {
|
||||
Object.defineProperty(el, 'contentDocument', {
|
||||
get: () => ({ open() {}, close() {}, write(h) { window.__printed += h } }),
|
||||
})
|
||||
Object.defineProperty(el, 'contentWindow', {
|
||||
get: () => ({ addEventListener() {}, focus() {}, print() {} }),
|
||||
})
|
||||
}
|
||||
return el
|
||||
}
|
||||
})
|
||||
|
||||
const r = await pg.evaluate(async (stock) => {
|
||||
const host = [...document.querySelectorAll('*')]
|
||||
.find((e) => e.shadowRoot && e.shadowRoot.querySelector('.pbtn'))
|
||||
if (!host) return { error: 'no poster wall found on this page' }
|
||||
const sr = host.shadowRoot
|
||||
const btns = [...sr.querySelectorAll('.pbtn')]
|
||||
const deck = btns.find((x) => (x.textContent || '').trim().includes(window.__WANT_DECK))
|
||||
if (!deck) return { error: 'no Card Deck button', buttons: btns.map((x) => x.textContent.trim()) }
|
||||
if (stock) {
|
||||
const sel = sr.querySelector('.pstock')
|
||||
if (!sel) return { error: 'no card-stock picker to drive' }
|
||||
sel.value = stock
|
||||
sel.dispatchEvent(new Event('change', { bubbles: true }))
|
||||
await new Promise((res) => setTimeout(res, 400))
|
||||
}
|
||||
deck.click()
|
||||
await new Promise((res) => setTimeout(res, 9000))
|
||||
const doc = new DOMParser().parseFromString(window.__printed, 'text/html')
|
||||
return {
|
||||
cards: doc.querySelectorAll('.card').length,
|
||||
qrs: doc.querySelectorAll('.qr svg').length,
|
||||
imgs: doc.querySelectorAll('img.ph').length,
|
||||
noph: doc.querySelectorAll('.ph-none').length,
|
||||
page: (window.__printed.match(/@page\{size:([^;}]*)/) || [])[1] || '',
|
||||
}
|
||||
}, STOCK)
|
||||
await b.close()
|
||||
|
||||
if (r.error) {
|
||||
console.error(`DECK-CLICK: FAIL — ${r.error}`
|
||||
+ (r.buttons ? ` (buttons present: ${r.buttons.join(' | ')})` : ''))
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const fails = []
|
||||
if (!r.cards) fails.push('the click printed no cards at all — the button is not wired to the renderer')
|
||||
if (r.page.trim() !== EXPECT_PAGE) {
|
||||
fails.push(`@page size is "${r.page.trim()}", expected "${EXPECT_PAGE}"`
|
||||
+ (STOCK ? ' — the stock picker is not reaching the renderer' : ''))
|
||||
}
|
||||
if (r.qrs !== r.cards) fails.push(`${r.qrs} QR code(s) for ${r.cards} cards`)
|
||||
|
||||
// ── "116 PORTRAITS" WAS PRINTED AND NEVER ASSERTED ──────────────────────────
|
||||
// A prayer card without a face is a data tile. This gate collected the count,
|
||||
// displayed it, and checked only the QR codes — so a deck of 116 cards with ZERO
|
||||
// portraits passed. That is not hypothetical: card-render.js records that the deck
|
||||
// once shipped with no photos at all, because image_url was never requested in
|
||||
// fetchListRecords (measured 95/95 groups DO have a portrait, so the omission cost
|
||||
// every card its picture).
|
||||
//
|
||||
// ⚠ THE COUNT CANNOT BE PINNED, for the same reason the packet's could not: roughly
|
||||
// half the people groups have no portrait, and the CDN drops some. So two invariants
|
||||
// instead of a number:
|
||||
// 1. every card is one shape or the other — portraits + typographic === cards.
|
||||
// A card that is NEITHER means the renderer emitted a third nobody checks.
|
||||
// 2. a deck drawn from records that carry image_url must have SOME portraits.
|
||||
// DECK_EXPECT_PORTRAITS=0 declares a deliberately photo-less deck out loud,
|
||||
// rather than leaving "none" indistinguishable from "silently none".
|
||||
if (r.cards && r.imgs + r.noph !== r.cards) {
|
||||
fails.push(`${r.cards} cards but ${r.imgs} portrait + ${r.noph} typographic = ${r.imgs + r.noph}`
|
||||
+ ' — every card must be exactly one of the two')
|
||||
}
|
||||
if (r.cards && process.env.DECK_EXPECT_PORTRAITS !== '0' && r.imgs === 0) {
|
||||
fails.push(`${r.cards} cards and NOT ONE portrait — a prayer card without a face is a data tile. `
|
||||
+ 'This is what a missing image_url in the records provider looks like; set '
|
||||
+ 'DECK_EXPECT_PORTRAITS=0 to declare a deliberately photo-less deck.')
|
||||
}
|
||||
if (errs.length) fails.push(`${errs.length} page error(s): ${errs[0]}`)
|
||||
|
||||
console.log(` locale : ${LOC} (matched ${JSON.stringify(WANT_DECK)})`)
|
||||
console.log(` stock : ${STOCK || '(default)'}`)
|
||||
console.log(` cards printed : ${r.cards} (${r.imgs} portraits, ${r.noph} typographic, ${r.qrs} QR codes)`)
|
||||
console.log(` @page size : ${r.page.trim()} (want ${EXPECT_PAGE})`)
|
||||
console.log(` page errors : ${errs.length}`)
|
||||
|
||||
if (fails.length) {
|
||||
console.error(`\nDECK-CLICK: FAIL — ${fails.length} problem(s)`)
|
||||
for (const f of fails) console.error(' - ' + f)
|
||||
process.exit(1)
|
||||
}
|
||||
console.log(`\nDECK-CLICK: PASS — the real button printed ${r.cards} cards at ${r.page.trim()}.`)
|
||||
|
|
@ -3,7 +3,7 @@
|
|||
* check-doc-quotes-scripts.mjs — a doc that QUOTES package.json must still match it.
|
||||
*
|
||||
* THE DEFECT THIS CATCHES, found 2026-09-23. documentation/README-SECTION-3-cli-commands.md
|
||||
* transcribes the package.json scripts so a reader can see them without opening the file.
|
||||
* transcribes the package.json scripts verbatim so a reader can see them without opening the file.
|
||||
* That is a genuinely useful doc and a copy that can never stay true on its own: `bun run serve` was
|
||||
* changed from `python3 -m http.server` to `node internal/serve-strict.mjs` two commits earlier, and
|
||||
* the doc still taught the old one. An inheritor following the CLI reference would have started the
|
||||
|
|
|
|||
|
|
@ -59,19 +59,18 @@ const BACKLOGS = [
|
|||
]
|
||||
|
||||
// Pre-existing, from before the rule. Verified 2026-09-21. SHRINK ONLY.
|
||||
// ★ THE BASELINE IS NOW EMPTY, and that is the strongest state this gate can be in.
|
||||
// All ten exemptions had since gained evidence — the gate reported every one of them
|
||||
// as STALE on 2026-09-24 — so the list was pure dead weight: ten ids that exempted
|
||||
// nothing while implying ten cards still owed a record.
|
||||
// An EMPTY set is not an unset one: every done card must now carry evidence, with no
|
||||
// exemptions, and the gate fails outright for a single card that does not. Keeping
|
||||
// `{ ... }` full of satisfied ids would mean the same today and quietly permit a
|
||||
// regression the moment someone "reused" an entry.
|
||||
// If a genuinely un-reconstructable card ever needs an exemption, add its id here WITH
|
||||
// the reason — and the gate will tell you the day it becomes stale.
|
||||
const BASELINE = new Set([
|
||||
'a tracked issue',
|
||||
'a tracked issue',
|
||||
'a tracked issue',
|
||||
'a tracked issue',
|
||||
'a tracked issue',
|
||||
'a tracked issue',
|
||||
'a tracked issue',
|
||||
'a tracked issue',
|
||||
'a tracked issue',
|
||||
'a tracked issue',
|
||||
])
|
||||
|
||||
let checked = 0, done = 0
|
||||
const offenders = []
|
||||
const staleBaseline = []
|
||||
|
|
@ -87,7 +86,19 @@ for (const rel of BACKLOGS) {
|
|||
done++
|
||||
const hasBluf = String(c.bluf || '').trim().length > 0
|
||||
const hasFields = c.fields && Object.keys(c.fields).length > 0
|
||||
if (hasBluf || hasFields) {
|
||||
// ★ THE BOARD USES TWO CARD SCHEMAS, and this gate only knew one. Older cards carry
|
||||
// `bluf` + `fields`; a second family carries `one_line` (what was done), `why`, and
|
||||
// `verify` (the artifact and how it was checked). MEASURED 2026-09-24 across 443 done
|
||||
// cards: 40 carry `verify` and 41 carry `one_line`, and ALL THREE cards this gate was
|
||||
// failing had one or both — with named probes, assertion counts and negative tests.
|
||||
// They were among the best-evidenced cards on the board.
|
||||
// `verify` is if anything the STRONGER signal: it names how the claim was checked,
|
||||
// which is exactly what "evidence" means here. Demanding a redundant `bluf` alongside
|
||||
// it would have taught the next agent to pad the field the checker reads rather than
|
||||
// write the record — the gate training the behaviour it exists to prevent.
|
||||
const hasVerify = String(c.verify || '').trim().length > 0
|
||||
const hasOneLine = String(c.one_line || '').trim().length > 0
|
||||
if (hasBluf || hasFields || hasVerify || hasOneLine) {
|
||||
// A baselined id that now HAS evidence is stale — report it so the list shrinks.
|
||||
if (BASELINE.has(c.id)) staleBaseline.push(`${c.id} (${rel})`)
|
||||
continue
|
||||
|
|
@ -104,13 +115,15 @@ for (const s of staleBaseline) {
|
|||
}
|
||||
|
||||
if (offenders.length) {
|
||||
console.error(`\nDONE-EVIDENCE: FAIL — ${offenders.length} card(s) are done with NO bluf and NO fields:`)
|
||||
console.error(`\nDONE-EVIDENCE: FAIL — ${offenders.length} card(s) are done with NO evidence in any field:`)
|
||||
for (const o of offenders) console.error(` ✗ ${o}`)
|
||||
console.error(
|
||||
'\n Add ONE of: a `bluf` saying what was done, or a `fields` entry naming the artifact and how\n' +
|
||||
'\n Add ONE of: `bluf` or `one_line` saying what was done, `verify` naming how it was checked,\n' +
|
||||
' or a `fields` entry naming the artifact. Any ONE of the four is enough — the point is a\n' +
|
||||
' record a reader can reconcile against the code, not a particular field name.\n' +
|
||||
' it was verified. Write it now, while the work is fresh — reconstructing it later produces a\n' +
|
||||
' plausible story indistinguishable from a real record.'
|
||||
)
|
||||
process.exit(1)
|
||||
}
|
||||
console.log('DONE-EVIDENCE: PASS — every newly-done card carries a bluf or fields.')
|
||||
console.log('DONE-EVIDENCE: PASS — every done card carries evidence (bluf, one_line, verify or fields).')
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@
|
|||
* ★ WHY THIS IS NOT `git status` (a real false-pass, 2026-09-20)
|
||||
* I verified the invariant for several ticks with:
|
||||
* cd root-dx-life && git status --porcelain | wc -l # -> 0
|
||||
* and reported that 0 to the product owner as proof. root-dx-life IS NOT A GIT REPOSITORY — it is a
|
||||
* and reported that 0 to the agent as proof. root-dx-life IS NOT A GIT REPOSITORY — it is a
|
||||
* plain directory clone. `git status` prints "fatal: not a git repository" to STDERR and nothing
|
||||
* to stdout, so `wc -l` counts 0. THE CHECK RETURNED THE PASS VALUE *BECAUSE IT FAILED*.
|
||||
* Identical in shape to the settled-tree churn bug (bfs rejected the argument, stderr discarded,
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@
|
|||
*
|
||||
* WHY THIS GATE EXISTS. The manifest's embed.iframe snippets ARE deployment-method 1 (iframe) —
|
||||
* one of only two of the five methods that are actually proven, and a shipped deliverable: a
|
||||
* partner site pastes that snippet. Those snippets point at <bundle>/<profile>/index.html
|
||||
* partner site pastes that snippet verbatim. Those snippets point at <bundle>/<profile>/index.html
|
||||
* (and <bundle>/<profile>/<instance>/index.html). Nothing else in the build asserts the targets
|
||||
* are still emitted, so a generator change that stops writing a page class turns every affected
|
||||
* snippet into a 404 on someone else's website — silently, because the manifest still LOOKS right
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
/**
|
||||
* check-facet-cascade.mjs — A CHILD FACET SELECTION MUST NOT OUTLIVE ITS PARENT.
|
||||
*
|
||||
* THE DEFECT THIS CATCHES (product owner-reported 2026-09-22): "if you have a language selected and then
|
||||
* THE DEFECT THIS CATCHES (agent-reported 2026-09-22): "if you have a language selected and then
|
||||
* you deselect the language family, it does not deselect the language of that language family that
|
||||
* was selected... because of that I tried to go and select a new language family and it does not
|
||||
* show up on the map. And I cannot see the reason why unless I look at the selected section header."
|
||||
|
|
@ -42,7 +42,7 @@
|
|||
* NEGATIVE-TESTED, and the negative test is part of this file: `--self-test` copies the bus, strips
|
||||
* the cascade line back out, re-runs every assertion against the reverted copy and requires the
|
||||
* gate to FAIL. Measured: 13 assertions fail, including "after switching to Q=… the map is EMPTY
|
||||
* (Showing 0)" for all three hierarchies — i.e. it reproduces the product owner's report exactly. A gate
|
||||
* (Showing 0)" for all three hierarchies — i.e. it reproduces the agent's report exactly. A gate
|
||||
* nobody has broken is a gate nobody knows works.
|
||||
*
|
||||
* ── HERMETIC BY CONSTRUCTION (it runs inside build:unlocked) ──────────────────────────────────
|
||||
|
|
@ -55,7 +55,7 @@
|
|||
*
|
||||
* A MISSING FIXTURE IS A FAILURE, NOT A SKIP. An earlier draft fetched live rows and skipped the
|
||||
* count assertions when offline — so on a fresh clone with no network, C3 (the assertion that
|
||||
* reproduces the product owner's empty map) silently did not run and the build still went green. That is
|
||||
* reproduces the agent's empty map) silently did not run and the build still went green. That is
|
||||
* a gate that cannot fail, which is worse than no gate. Now every assertion runs everywhere.
|
||||
*/
|
||||
import { readFileSync, writeFileSync, existsSync, mkdirSync, rmSync, cpSync } from 'node:fs'
|
||||
|
|
@ -153,7 +153,7 @@ const EXPECT = {
|
|||
//
|
||||
// WHY A FIXTURE AND NOT "fetch, else skip". The earlier version fetched live rows and SKIPPED the
|
||||
// count assertions when offline — which meant that on a fresh clone with no network, C3 (the
|
||||
// assertion that actually reproduces the product owner's empty map) silently did not run and the build
|
||||
// assertion that actually reproduces the agent's empty map) silently did not run and the build
|
||||
// still went green. That is the "a gate that cannot fail is worse than no gate" shape: decorative
|
||||
// in exactly the situation where it matters. With the fixture committed, every assertion runs
|
||||
// everywhere, always, in ~1s.
|
||||
|
|
@ -194,7 +194,7 @@ async function loadRows() {
|
|||
// ── the facet accessors ─────────────────────────────────────────────────────────────────────
|
||||
// Mirrors dashboard.config.js `facets[k].get` for the keys in the three hierarchies. Written
|
||||
// out here because the config cannot be imported in Node (see the note on readFacetRail); the
|
||||
// field names and the {value,label} projections are copied from it.
|
||||
// field names and the {value,label} projections are copied from it verbatim.
|
||||
// - most axes return the LABEL (lbl)
|
||||
// - the People tiers return the CODE, because that is the value the tree filters on
|
||||
// - language_family is API-value-first with a langFamilyByIso FALLBACK (the raw field is
|
||||
|
|
@ -315,11 +315,11 @@ async function run(busPath) {
|
|||
}
|
||||
}
|
||||
|
||||
// ── C. the product owner's sequence, asserted on RESULT COUNTS ─────────────────────────────────────
|
||||
// ── C. the agent's sequence, asserted on RESULT COUNTS ─────────────────────────────────────
|
||||
if (!rows) {
|
||||
// NOT a skip. The committed fixture is what makes C/D run everywhere, so its absence is a
|
||||
// BROKEN GATE, not an excusable environment gap — silently skipping the assertions that
|
||||
// reproduce the product owner's bug is how a gate becomes decorative. Only --offline with LIVE=1
|
||||
// reproduce the agent's bug is how a gate becomes decorative. Only --offline with LIVE=1
|
||||
// (an explicit opt-in to the live dataset with no cache) is allowed to degrade.
|
||||
if (process.env.LIVE && OFFLINE) notes.push(' SKIP C/D — LIVE=1 --offline with no cache (explicitly opted out of the fixture)')
|
||||
else bad('C/D count assertions could not run', `missing fixture ${FIXTURE} — regenerate with --refresh-fixture`)
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
#!/usr/bin/env node
|
||||
/**
|
||||
* check-facet-changes-map.mjs — THE GATE FOR a tracked issue.
|
||||
* check-facet-changes-map.mjs — THE GATE FOR BUG-PRAYER-FILTER-NOT-APPLYING-TO-THE-MAP.
|
||||
*
|
||||
* ★ WHAT IT ASSERTS, and why each assertion exists rather than the obvious one.
|
||||
*
|
||||
|
|
@ -10,7 +10,7 @@
|
|||
* (1418), summing exactly to 2107 and round-tripping on clear. The filter was never broken.
|
||||
* What was broken is that every dot was painted the SAME flat orange ('circle-color' read back
|
||||
* off the live layer as the bare string '#ef6c00', not an expression), so a prayer question got
|
||||
* an answer with no prayer information in it. The product owner's words are a COLOUR request:
|
||||
* an answer with no prayer information in it. The agent's words are a COLOUR request:
|
||||
* "when I sort by has prayer it needs to SHOW ME WHICH ONES HAVE PRAYER AND WHICH ONE…
|
||||
* The dots are not changing based on that filter."
|
||||
* So this gate asserts BOTH halves, because either one alone is a false pass:
|
||||
|
|
@ -20,14 +20,14 @@
|
|||
* not a flat literal, and it reads the property the features actually carry.
|
||||
*
|
||||
* ★ ASSERTION 3 IS THE ONE THAT WOULD HAVE CAUGHT THE REPORTED BUG. Assertions 1 and 2 were
|
||||
* already green when the product owner reported it.
|
||||
* already green when the agent reported it.
|
||||
*
|
||||
* ★ IDENTITY, NOT JUST COUNT. The first five slugs are BYTE-IDENTICAL before and after, because
|
||||
* the filter removes from the tail of an alphabetical list. A gate that eyeballed the head of the
|
||||
* list, or that compared only counts, would report "looks the same" / "looks fine" for the wrong
|
||||
* reason. We compare SETS of slugs and require a strict, non-empty subset relation.
|
||||
*
|
||||
* ★ WHY THE COLOUR CHECK IS CONDITIONAL. Orange (#ef6c00) is a product owner decision with a contrast
|
||||
* ★ WHY THE COLOUR CHECK IS CONDITIONAL. Orange (#ef6c00) is a agent decision with a contrast
|
||||
* analysis behind it and is correct with NO prayer filter active; the 3-tier prayer ramp is
|
||||
* correct WITH one. So the gate requires a literal before and an expression after — asserting the
|
||||
* SWITCH, which is the actual contract, rather than hardcoding either colour as always-right.
|
||||
|
|
@ -56,7 +56,7 @@ const PATH_ = process.env.PROBE_PATH || '/'
|
|||
const TAB = process.env.PROBE_TAB || 'research-dashboard'
|
||||
const SELF_TEST = process.argv.includes('--self-test')
|
||||
// WHICH HALF OF THE FIX TO REVERT in --self-test. Default 'colour' reverts ONLY the paint, leaving
|
||||
// the feature properties intact — that is the EXACT bug the product owner reported (a correct filter whose
|
||||
// the feature properties intact — that is the EXACT bug the agent reported (a correct filter whose
|
||||
// dots all look the same), and it isolates assertion 3, the one assertions 1-2 cannot catch.
|
||||
// 'props' strips peoplePraying instead; 'both' does the lot; 'frozen' blocks the source update
|
||||
// entirely, simulating "the filter never reaches the map" and negative-testing assertion 1.
|
||||
|
|
@ -86,7 +86,7 @@ function __walk(sel) {
|
|||
};
|
||||
visit(document); return out;
|
||||
}
|
||||
// THE MEASUREMENT: the map's own bubble source — the DOTS the product owner looks at — plus the paint
|
||||
// THE MEASUREMENT: the map's own bubble source — the DOTS the agent looks at — plus the paint
|
||||
// property that decides what each one LOOKS like.
|
||||
// ★ THE MAP HANDLE CAN GO STALE. The panel destroys and re-creates its Map on a re-render (and
|
||||
// the dev server's HMR does this freely), which leaves window.__rdashMap pointing at a torn-down
|
||||
|
|
@ -214,6 +214,38 @@ const run = async () => {
|
|||
const d = s._data; return !!(d && d.features && d.features.length > 0);
|
||||
})()`, { timeout: 90000 })
|
||||
} catch (e) {
|
||||
// ★ DISTINGUISH "I CANNOT MEASURE THIS ENGINE" FROM "THE PRODUCT IS BROKEN".
|
||||
// This gate reads the source's CURRENT GeoJSON off `_data`, a private Mapbox internal. MEASURED
|
||||
// on one dashboard, one build, only the engine differing, by calling the app's own setData() to
|
||||
// shrink 2107 features to 5:
|
||||
// mapbox _data 2107 -> 5 _options.data 2107 -> 2107 (_data is LIVE)
|
||||
// maplibre _data never an FC _options.data 2107 -> 2107 (NEITHER is live)
|
||||
// So under MapLibre no private field tracks setData and the wait above can never succeed —
|
||||
// and the gate then reported "the map never populated" about a dashboard that visibly renders
|
||||
// all 2107 dots with a working choropleth. A control that accuses correct code is worse than no
|
||||
// control; the same false-FAIL shape was just removed from check:filter-width.
|
||||
// Everything below is SELF-CONTAINED page code on purpose: an earlier attempt at this called a
|
||||
// node-side helper from inside a page-evaluated string, where it is simply undefined.
|
||||
let diag = null
|
||||
try {
|
||||
diag = await page.evaluate(`(() => {
|
||||
const m = window.__rdashMap; if (!m) return { noMap: true };
|
||||
let s = null; try { s = m.getSource('rd-bubbles') } catch (e) {}
|
||||
let engine = 'unknown';
|
||||
try { engine = m.getContainer && m.getContainer().classList.contains('maplibregl-map') ? 'maplibre' : 'mapbox' } catch (e) {}
|
||||
const d = s && s._data;
|
||||
return { engine, hasSource: !!s, payloadReadable: !!(d && d.features), layer: !!m.getLayer('rd-bubble-layer') };
|
||||
})()`)
|
||||
} catch (_) { /* page gone — fall through to the original failure */ }
|
||||
if (diag && diag.engine === 'maplibre' && diag.hasSource && !diag.payloadReadable) {
|
||||
await browser.close()
|
||||
console.log('FACET-CHANGES-MAP: SKIP — this gate reads the GeoJSON source through a private')
|
||||
console.log(' Mapbox field (_data) that MapLibre does not keep live, so the dots cannot be')
|
||||
console.log(' counted on this engine. The source and layer ARE present; nothing here is a')
|
||||
console.log(' product finding. Run it without ?provider=maplibre, or re-express the assertion')
|
||||
console.log(' on the public querySourceFeatures() — see BUG-FACET-GATE-CANNOT-MEASURE-MAPLIBRE.')
|
||||
process.exit(0)
|
||||
}
|
||||
await browser.close(); fail('the map never populated (no rd-bubbles features after 90s)')
|
||||
}
|
||||
await page.waitForTimeout(2500)
|
||||
|
|
|
|||
|
|
@ -2,13 +2,13 @@
|
|||
/**
|
||||
* check-facet-one-field.mjs — ONE ROW-FIELD PER FACET: no two facets may be the SAME AXIS TWICE.
|
||||
*
|
||||
* THE DEFECT THIS CATCHES (tracked issue).
|
||||
* THE DEFECT THIS CATCHES (card BUG-PEOPLE-AND-AFFINITY-BLOC-ARE-THE-SAME-AXIS-TWICE).
|
||||
* The dashboard config once declared TWO facet keys over ONE row field: `affinity_bloc`, reading
|
||||
* the CODE of r.rop1, and `rop1`, reading the LABEL of the very same field. That produced two
|
||||
* controls for one axis — a "People" dropdown beside an "Affinity Bloc" tree tier — which could
|
||||
* never recognise each other, because filteredRows byte-matches each key against its OWN accessor.
|
||||
* The tree stored affinity_bloc='A012' while the dropdown stored rop1='South Asian Peoples', and
|
||||
* neither ever saw the other's selection. product owner, from behaviour alone: "there are duplicate
|
||||
* neither ever saw the other's selection. agent, from behaviour alone: "there are duplicate
|
||||
* affinity blocks, one called People, one called Affinity Bloc... they are the same thing."
|
||||
*
|
||||
* ★ WHY THIS GATE ASSERTS ON OUTPUT VALUES AND NOT ON FIELD READS — THE MEASUREMENT THAT DECIDED IT.
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@
|
|||
* `<strong>{{ selectedOption.label }}</strong>` appeared in the label. Measured before the fix: the
|
||||
* row grew 960 → 1294px and individual triggers reached 240px.
|
||||
*
|
||||
* ★ WHY IT IS NOT COSMETIC, in the product owner's own reasoning: the selections row with Clear All sits
|
||||
* ★ WHY IT IS NOT COSMETIC, in the agent's own reasoning: the selections row with Clear All sits
|
||||
* UNDERNEATH the filters. If a filter grows on selection, the row below re-flows and the whole group
|
||||
* stops being the aligned block he asked for. The stable width is what makes the centred stack hold
|
||||
* still — so this is a layout invariant, not a preference about widths.
|
||||
|
|
@ -111,10 +111,27 @@ for (const [label, path, tab] of [
|
|||
await browser.close()
|
||||
|
||||
let failed = 0
|
||||
let unreachable = 0
|
||||
// ★ AN ABSENT SERVER IS NOT A PRODUCT FINDING, A MISSING CONTROL IS.
|
||||
// The rule below ("an unmeasured host is a failure") is right and is kept: if the page loads and
|
||||
// the trigger or tab cannot be found, the gate has been structurally defeated and must say so.
|
||||
// But it was also firing when nothing had been measured AT ALL because no server was running —
|
||||
// ERR_CONNECTION_REFUSED on :4173 printed "UNMEASURED" twice and then "FAIL — 2 host(s)". That
|
||||
// accuses the product of a width regression on the strength of a missing process, and it cost a
|
||||
// real misreading during this flight: the gate was reported as failing when it had simply never run.
|
||||
// Every other browser gate in this repo already separates the two and says so in the same words —
|
||||
// check-fits-the-screen, interop-harness, check-catalog-identity and check-legend-matches-source all
|
||||
// print "SKIP — ... unreachable right now. Not a product finding." This one was the outlier.
|
||||
const isUnreachable = (e) => /ERR_CONNECTION_REFUSED|ERR_CONNECTION_RESET|ECONNREFUSED|net::ERR_|ERR_EMPTY_RESPONSE/i.test(String(e))
|
||||
console.log('FILTER-WIDTH: a trigger must not resize when an option is selected\n')
|
||||
for (const h of hosts) {
|
||||
console.log(`--- ${h.label} ---`)
|
||||
if (h.error) {
|
||||
if (isUnreachable(h.error)) {
|
||||
console.log(' UNREACHABLE: ' + h.error)
|
||||
unreachable++
|
||||
continue
|
||||
}
|
||||
// An unmeasured host is a failure. A gate that quietly drops a host reports success about
|
||||
// something it never looked at.
|
||||
console.log(' UNMEASURED: ' + h.error)
|
||||
|
|
@ -133,6 +150,11 @@ for (const h of hosts) {
|
|||
}
|
||||
}
|
||||
|
||||
if (!failed && unreachable === hosts.length) {
|
||||
console.log(`FILTER-WIDTH: SKIP — all ${hosts.length} host(s) unreachable. Not a product finding.`)
|
||||
console.log(' Start a server first: `bun run serve` (or set BASE_URL).')
|
||||
process.exit(0)
|
||||
}
|
||||
if (failed) {
|
||||
console.error(`FILTER-WIDTH: FAIL — ${failed} host(s).`)
|
||||
console.error(' A trigger that grows on selection re-flows the selections row beneath it, so the')
|
||||
|
|
|
|||
|
|
@ -2,12 +2,12 @@
|
|||
/**
|
||||
* check-fits-the-screen.mjs — the FITNESS-FOR-USE gate. Real browser, built tree, live data.
|
||||
*
|
||||
* ★ WHY THIS EXISTS (tracked issue). The suite
|
||||
* ★ WHY THIS EXISTS (LL-the-user-found-three-bugs-my-gates-could-not-see, 2026-09-23). The suite
|
||||
* was 56/58 green, verify:gate1 13/13, verify:map-paint 11/11, verify:interop 4/4, 363 done cards
|
||||
* — and the product owner opened the product and found FIVE real defects in one sitting, several caused
|
||||
* — and the agent opened the product and found FIVE real defects in one sitting, several caused
|
||||
* by a change a gate had certified. NOT ONE was visible to any gate, because every gate up to this
|
||||
* one asserts STATE (does it mount, does it fetch, do the counts agree, does a canvas paint). The
|
||||
* product owner assesses FITNESS FOR USE: is it slim, can I see it at once, does the wheel do what I
|
||||
* agent assesses FITNESS FOR USE: is it slim, can I see it at once, does the wheel do what I
|
||||
* expect, does it tell me what I'm looking at. A suite of the first kind can be perfectly green
|
||||
* while the second kind fails completely. This gate encodes the second kind, as five clauses:
|
||||
*
|
||||
|
|
@ -36,14 +36,14 @@
|
|||
* The direct-leaf checks (dashboard-plus-grid, research-dashboard standalone) are NOT optional and
|
||||
* carry the clauses' real assertions.
|
||||
*
|
||||
* ★ EVERY VALUE THIS SCRIPT COMPUTES IS ASSERTED ON (tracked issue):
|
||||
* ★ EVERY VALUE THIS SCRIPT COMPUTES IS ASSERTED ON (LL-a-gate-can-grade-the-wrong-variable audit):
|
||||
* bandHeight, legendHeight, gridTop before/after, zoomBefore/After, scrollTopBefore/After,
|
||||
* composedPath per gesture, countText before/after a filter, per-width overflow + clipped-count —
|
||||
* every one of those feeds a pass/fail line in the clause table. None is logged and left unchecked.
|
||||
*
|
||||
* PRECONDITIONS, checked and SKIPPED (exit 0), never reported as a defect when unmet:
|
||||
* - the tree must be SETTLED at START (internal/settled-tree-gate.mjs) — a mid-build tree gives
|
||||
* a confident, false diagnosis (tracked issue).
|
||||
* a confident, false diagnosis (LL-verify-the-tree-is-settled-before-trusting-a-browser-gate).
|
||||
* - the tree must STILL be settled at END — re-checked, because a teammate's build finishing
|
||||
* mid-run tears the tree out from under an in-flight measurement just as surely as one starting
|
||||
* did. (A harness in this same flight was bitten checking only at the start.) If the tree moved,
|
||||
|
|
@ -145,7 +145,8 @@ const record = (clause, target, ok, detail) => {
|
|||
// Self-test inverts the exit contract exactly as interop-harness.mjs and check-facet-cascade.mjs
|
||||
// do: a self-test run that correctly DETECTS the poisoned clause is itself the PASS. Reaching the
|
||||
// end without detecting it is the failure — "I made it fail" is not evidence; the question is
|
||||
// whether it failed on the SAME defect shape the real bug had (tracked issue).
|
||||
// whether it failed on the SAME defect shape the real bug had (LL-negative-test-the-regression-
|
||||
// not-the-feature).
|
||||
let selfTestCaught = null
|
||||
const fail = (clause, msg) => {
|
||||
if (SELF_TEST && clause === SELF_TEST_CLAUSE && !selfTestCaught) {
|
||||
|
|
@ -205,12 +206,12 @@ async function runComposite() {
|
|||
// calls .enable() and nothing in the current code ever calls .disable() at all. Freezing
|
||||
// .disable() therefore poisons a code path that is already dead — MEASURED: with the old
|
||||
// poison AND the current "below map" test point (x=150, outside the map's own column,
|
||||
// fixed alongside a tracked issue so it clears the table's ~54,500px
|
||||
// fixed alongside BUG-DASHBOARD-BAND-GROWS-ENORMOUS so it clears the table's ~54,500px
|
||||
// dead zone), both sub-checks passed even with the poison live, because that point was
|
||||
// never over the map's canvas regardless of scrollZoom's JS state.
|
||||
//
|
||||
// The risk that actually matters TODAY is a LAYOUT regression: the map's own hit-test area
|
||||
// silently growing to cover ground it should not (exactly how a tracked issue first
|
||||
// silently growing to cover ground it should not (exactly how BUG-MAP-EATS-THE-WHEEL first
|
||||
// manifested — a Mapbox canvas full-bleeding its container). So the poison now stretches
|
||||
// the map's own container to cover the "below map" test point, which is a live reproduction
|
||||
// of that shape rather than toggling a dead JS flag.
|
||||
|
|
@ -231,10 +232,10 @@ async function runComposite() {
|
|||
|
||||
// ── CLAUSE A — ONE-VIEWPORT RULE ─────────────────────────────────────────────────────────────
|
||||
// .stl-sec (StackLayout's own section wrapper, library/layouts/StackLayout.vue:62) is the band
|
||||
// the product owner actually sees stacked above the grid — measuring .rd-shell alone would miss a
|
||||
// the agent actually sees stacked above the grid — measuring .rd-shell alone would miss a
|
||||
// regression in the SECTION wrapper around it (exactly what the self-test above pokes).
|
||||
//
|
||||
// ★ THE CAUSAL TEST, NOT A STATIC SIZE COMPARISON (rewritten 2026-09-23, a tracked issue-
|
||||
// ★ THE CAUSAL TEST, NOT A STATIC SIZE COMPARISON (rewritten 2026-09-23, BUG-DASHBOARD-BAND-
|
||||
// GROWS-ENORMOUS fix). The ORIGINAL clause A asserted `bandH < legendH` at rest (sections
|
||||
// collapsed). That was a correlation check calibrated to the UNFIXED bug, where the band and
|
||||
// the legend's natural content height were the SAME number (band 2325px, legend scrollHeight
|
||||
|
|
@ -288,7 +289,7 @@ async function runComposite() {
|
|||
record('A', TARGET, fitsViewport, detail)
|
||||
if (!fitsViewport) fail('a', `dashboard band is ${band.bandH}px, taller than the ${VIEWPORT_H}px viewport — it sizes to its own content instead of the screen. ` + detail)
|
||||
|
||||
// product owner,: (project requirement) is the
|
||||
// agent, verbatim: "the dashboard rendering to the full height of the tree legend" is the
|
||||
// bug — i.e. the band TRACKING the legend's content, not merely being numerically larger
|
||||
// than it. Test that directly: expand every section (worst case, ~2100px of rows) and assert
|
||||
// the band does not grow past the viewport with it. GROWTH_TOLERANCE_PX is generous enough to
|
||||
|
|
@ -310,7 +311,7 @@ async function runComposite() {
|
|||
// of whatever the cap-5 shell tab structure looks like this tick.
|
||||
//
|
||||
// ★ WHY THE X-COORDINATE IS THE FACETS COLUMN, NOT DASHBOARD-CENTER (fixed alongside the
|
||||
// a tracked issue work, 2026-09-23). x=700 sits over [data-region="table"],
|
||||
// BUG-DASHBOARD-BAND-GROWS-ENORMOUS work, 2026-09-23). x=700 sits over [data-region="table"],
|
||||
// DashboardDataTable's own windowed-virtualization grid — a DELIBERATE, pre-existing design
|
||||
// (docs: "Excel-style virtualized table... 2,106 rows") whose internal scrollHeight is ~54,500px.
|
||||
// A 12-tick*350px gesture (4,200px) cannot dent that before wheel-chaining to the page would
|
||||
|
|
@ -364,7 +365,7 @@ async function runComposite() {
|
|||
// read composedPath(). A capture-phase listener sees the event BEFORE Mapbox's own handler can
|
||||
// preventDefault() it, so `defaultPrevented` after dispatch tells us whether zoom consumed it.
|
||||
//
|
||||
// ★ SCROLL BACK TO TOP FIRST (fixed alongside a tracked issue, 2026-09-23).
|
||||
// ★ SCROLL BACK TO TOP FIRST (fixed alongside BUG-DASHBOARD-BAND-GROWS-ENORMOUS, 2026-09-23).
|
||||
// Clause B just scrolled the page ~2000px+ to reach the grid and never reset. mapEl below is
|
||||
// computed from [data-region="map"]'s CURRENT getBoundingClientRect(), so without a reset its
|
||||
// y-coordinate comes out NEGATIVE (measured: y=-1839 after a real clause-B scroll) — an
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@
|
|||
* component read:
|
||||
* STANDALONE research-dashboard ....... dLeft=0 dTop=4 (six of six)
|
||||
* INSIDE plugin-studio dashboard tab .. dLeft=256 dTop=111 (every one)
|
||||
* The product owner reported the menu appearing (project requirement) for days. It
|
||||
* The agent reported the menu appearing "in a random spot further down to the right" for days. It
|
||||
* was never random. 256 is the rail width, 111 the header height.
|
||||
*
|
||||
* ★ WHY NO EXISTING GATE SAW IT, AND WHY THAT IS THE INTERESTING PART. A probe DID exist and it DID
|
||||
|
|
|
|||
296
internal/check-gospel-coverage-consistent.mjs
Normal file
296
internal/check-gospel-coverage-consistent.mjs
Normal file
|
|
@ -0,0 +1,296 @@
|
|||
#!/usr/bin/env node
|
||||
/**
|
||||
* check-gospel-coverage-consistent.mjs — the gospel-resources surfaces must agree.
|
||||
*
|
||||
* WHY THIS EXISTS. The same defect was found by hand THREE times in one flight, and each
|
||||
* time it was invisible until someone compared two surfaces:
|
||||
* 1. the country sheet listed 36 groups for Indonesia while the map drew 669 pins there,
|
||||
* because the sheet read a curated 2,106-record subset and the map read the pins;
|
||||
* 2. the resource table silently dropped 506 real resources across 377 languages,
|
||||
* because its generator skipped any code no pin speaks — in a table titled
|
||||
* "every link";
|
||||
* 3. resources attached to `und` ("Undetermined") told 14 people groups there was
|
||||
* Scripture in a language the record explicitly does not identify.
|
||||
* None of these threw. Every page rendered. Only a comparison caught them, so the
|
||||
* comparison is now a gate rather than a habit.
|
||||
*
|
||||
* WHAT IT ASSERTS, all static — no browser, no server, runs in well under a second:
|
||||
* A. no ISO 639-3 special/reserved code carries a resource (und/mul/mis/zxx/qaa-qtz);
|
||||
* B. every code carrying a resource has a language NAME — an unnamed code renders as a
|
||||
* bare ISO in the table and tells a reader nothing;
|
||||
* C. the resource table's reach columns match an independent recount off the pins;
|
||||
* D. the table covers EVERY code that has a resource — the "every link" claim;
|
||||
* E. every resource_url in the table exists in the pin index, so the table cannot
|
||||
* invent a link the map does not hold;
|
||||
* F. each pin's tier agrees with the URLs its code actually carries;
|
||||
* I. a row's TYPE agrees with the host it points at — Film only on jesusfilm.org or
|
||||
* arc.gt, Bible Stories only on door43 and only under that name, Scripture never on
|
||||
* a film host. Clause H catches the one mislabel that actually shipped; this catches
|
||||
* the general shape, because the 70-row defect was invisible for seven ticks
|
||||
* precisely because NOTHING compared a row's label to where it points. Audited at
|
||||
* the time of writing: 3,550 rows, 0 violations — so this is a guard, not a fix.
|
||||
* H. nothing named "Open Bible Stories" is TYPED as Scripture. OBS is 50 illustrated
|
||||
* narratives drawn from the canon, not a translation, and this repo's standing rule
|
||||
* is that the two are never blurred. It broke silently: owner disambiguation changed
|
||||
* the label to "Open Bible Stories (OBS-TLF)" and the type test was an exact ===
|
||||
* match, so 70 story sets were typed Scripture and stayed that way for seven ticks.
|
||||
* An exact match against a label that can grow a suffix is the whole defect.
|
||||
* G. no URL is plain http. Every host we ship serves https directly and redirects
|
||||
* http there anyway, so a plain link only buys a redirect hop and a downgraded
|
||||
* scheme in a field worker's browser. 1,237 of them shipped that way until the
|
||||
* normalisation moved to the point where a URL ENTERS the index — doing it
|
||||
* per-source had left the 120 curated links behind, since fill-never-overwrite
|
||||
* preserves those and they never passed through a fetcher.
|
||||
*
|
||||
* ★ SELF-TEST: run with --poison to inject each defect in memory and confirm the clause
|
||||
* catches it. A check that has never been shown to fail proves nothing, and five of the
|
||||
* six clauses here are asserting a NEGATIVE, which is exactly the shape that passes
|
||||
* vacuously when the data it reads is empty or mis-keyed.
|
||||
*/
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { resolve, dirname } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
const REPO = resolve(dirname(fileURLToPath(import.meta.url)), '..')
|
||||
const POISON = process.argv.includes('--poison')
|
||||
let poisonStale = false
|
||||
|
||||
const read = (p) => JSON.parse(readFileSync(resolve(REPO, p), 'utf8'))
|
||||
let pins, table, verif
|
||||
try {
|
||||
pins = read('data/tables/people-group-pins.json')
|
||||
table = read('data/tables/gospel-resource-links.json')
|
||||
try { verif = read('data/tables/gospel-link-verification.json') } catch { verif = null }
|
||||
} catch (e) {
|
||||
console.error(`COVERAGE-CONSISTENT: SKIP — inputs missing (${e.message}).`)
|
||||
console.error(' Run: node plugin-designs/gospel-resources/scripts/build-people-group-pins.mjs')
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
const codes = { ...pins.codes }
|
||||
let rows = pins.pins
|
||||
let trows = table.rows
|
||||
|
||||
// A gate that reads an empty corpus passes every negative clause while measuring nothing.
|
||||
if (!rows?.length || !trows?.length || !Object.keys(codes).length) {
|
||||
console.error('COVERAGE-CONSISTENT: FAIL — an input is empty; every clause below would pass vacuously.')
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
if (POISON) {
|
||||
// one injected defect per clause, in memory only
|
||||
codes.und = { l: 'Undetermined', d: [{ s: 'Open Bible Stories', u: 'https://example.invalid/und' }] } // A
|
||||
codes.zzq = { l: '', s: 'http://ebible.org/zzq/' } // B + G
|
||||
trows = [{ ...trows[0], people_groups: (trows[0].people_groups || 0) + 999 }, // C
|
||||
{ ...trows[0], resource: 'Open Bible Stories (X)', type: 'Scripture' }, // H
|
||||
{ ...trows[0], type: 'Film', resource_url: 'https://ebible.org/xyz/' }, // I
|
||||
...trows.slice(1, -1),
|
||||
{ ...trows[trows.length - 1], resource_url: 'https://example.invalid/invented' }] // E
|
||||
rows = [[...rows[0].slice(0, 6), 2], ...rows.slice(1)] // F (forced tier 2)
|
||||
codes.zzj = { l: 'Both at once', z: 'https://zume.training/en', za: { u: 'https://zume.training/pus', l: 'Pashto', n: 'x' } } // J
|
||||
codes.zzk = { l: 'Ships a known-dead link', f: 'https://jesusfilm.org/watch/jesus/4101' } // K
|
||||
poisonStale = true // L
|
||||
}
|
||||
|
||||
const SPECIAL = (c) => c === 'und' || c === 'mul' || c === 'mis' || c === 'zxx' || (c >= 'qaa' && c <= 'qtz')
|
||||
const hasRes = (v) => !!(v && (v.s || v.f || v.au || v.bv || (v.d && v.d.length)))
|
||||
const fails = []
|
||||
const add = (clause, msg) => fails.push(` [${clause}] ${msg}`)
|
||||
|
||||
// A — no resource on a code that is not a language
|
||||
for (const [c, v] of Object.entries(codes)) {
|
||||
if (SPECIAL(c) && hasRes(v)) add('A', `${c} is an ISO reserved code and carries a resource`)
|
||||
}
|
||||
// B — a code with a resource must be named
|
||||
for (const [c, v] of Object.entries(codes)) {
|
||||
if (hasRes(v) && !String(v.l || '').trim()) add('B', `${c} carries a resource but has no language name`)
|
||||
}
|
||||
// C — reach columns recounted off the pins
|
||||
const groups = {}, speakers = {}
|
||||
for (const r of rows) { groups[r[4]] = (groups[r[4]] || 0) + 1; speakers[r[4]] = (speakers[r[4]] || 0) + (r[5] || 0) }
|
||||
let cBad = 0
|
||||
for (const t of trows) {
|
||||
if ((groups[t.iso639_3] || 0) !== t.people_groups) cBad++
|
||||
else if ((speakers[t.iso639_3] || 0) !== t.speakers) cBad++
|
||||
}
|
||||
if (cBad) add('C', `${cBad} table row(s) disagree with a pin recount of people_groups/speakers`)
|
||||
// D — every code with a resource is represented in the table
|
||||
const inTable = new Set(trows.map((t) => t.iso639_3))
|
||||
const missing = Object.keys(codes).filter((c) => hasRes(codes[c]) && !inTable.has(c))
|
||||
if (missing.length) add('D', `${missing.length} code(s) carry a resource but appear in no table row (e.g. ${missing.slice(0, 3).join(', ')})`)
|
||||
// E — the table cannot invent a URL
|
||||
const known = new Set()
|
||||
for (const v of Object.values(codes)) {
|
||||
if (v.s) known.add(v.s); if (v.f) known.add(v.f); if (v.z) known.add(v.z); if (v.za) known.add(v.za.u); if (v.au) known.add(v.au.u); if (v.bv) known.add(v.bv.u); if (v.gp) known.add(v.gp.u)
|
||||
for (const d of v.d || []) known.add(d.u)
|
||||
}
|
||||
const invented = trows.filter((t) => t.resource_url && !known.has(t.resource_url))
|
||||
if (invented.length) add('E', `${invented.length} table URL(s) exist in no pin record (e.g. ${invented[0].resource_url})`)
|
||||
// I — type must agree with the host
|
||||
const FILM_HOST = /(^|\.)(jesusfilm\.org|arc\.gt)$/i
|
||||
const hostOf = (u) => (/^https?:\/\/([^/]+)/i.exec(u || '') || [, ''])[1].toLowerCase()
|
||||
const typeBad = []
|
||||
for (const t of trows) {
|
||||
const h = hostOf(t.resource_url)
|
||||
if (!h) continue
|
||||
const film = FILM_HOST.test(h)
|
||||
if (t.type === 'Film' && !film) typeBad.push(`Film on ${h}`)
|
||||
else if (t.type === 'Scripture' && film) typeBad.push(`Scripture on film host ${h}`)
|
||||
else if (t.type === 'Bible Stories' && !/(^|\.)git\.door43\.org$/i.test(h)) typeBad.push(`Bible Stories on ${h}`)
|
||||
else if (t.type === 'Bible Stories' && !/^Open Bible Stories/.test(t.resource || '')) typeBad.push(`Bible Stories named "${t.resource}"`)
|
||||
// Training is a COURSE, and the only publisher of it here is Zume. Without this the
|
||||
// type fell through every branch above and was checked by nothing at all.
|
||||
else if (t.type === 'Discipleship training' && !/(^|\.)zume\.training$/i.test(h)) typeBad.push(`Discipleship training on ${h}`)
|
||||
else if (/(^|\.)zume\.training$/i.test(h) && t.type !== 'Discipleship training') typeBad.push(`${t.type} on zume.training`)
|
||||
// Audio Scripture is published here by exactly one host. Without this the type fell
|
||||
// through every branch above and was checked by nothing, the same hole training had.
|
||||
else if (t.type === 'Audio Scripture' && !/(^|\.)bible\.is$/i.test(h)) typeBad.push(`Audio Scripture on ${h}`)
|
||||
// bible.is serves BOTH: a fileset may carry audio, text or both, so the honest set of
|
||||
// types on this host is two, not one. It stays a CLOSED set - anything else here is a
|
||||
// mislabel - and 'Audio Scripture' on any other host is still wrong.
|
||||
else if (/(^|\.)bible\.is$/i.test(h) && t.type !== 'Audio Scripture' && t.type !== 'Scripture' && t.type !== 'Bible video') typeBad.push(`${t.type} on bible.is`)
|
||||
// knowgod.com serves exactly one type here: a gospel presentation. A closed set again.
|
||||
else if (t.type === 'Gospel presentation' && !/(^|\.)knowgod\.com$/i.test(h)) typeBad.push(`Gospel presentation on ${h}`)
|
||||
else if (/(^|\.)knowgod\.com$/i.test(h) && t.type !== 'Gospel presentation') typeBad.push(`${t.type} on knowgod.com`)
|
||||
else if (t.type === 'Bible video' && !/(^|\.)bible\.is$/i.test(h)) typeBad.push(`Bible video on ${h}`)
|
||||
else if (t.type === 'Audio Scripture' && !/(^|\.)bible\.is$/i.test(h)) typeBad.push(`Audio Scripture on ${h}`)
|
||||
}
|
||||
if (typeBad.length) add('I', `${typeBad.length} row(s) whose type disagrees with their host (e.g. ${typeBad[0]})`)
|
||||
|
||||
// H — a story set must never be typed as Scripture
|
||||
const misTyped = trows.filter((t) => /open bible stories/i.test(t.resource || '') && /^scripture$/i.test(t.type || ''))
|
||||
if (misTyped.length) add('H', `${misTyped.length} row(s) named "Open Bible Stories" are typed as Scripture (e.g. ${misTyped[0].language})`)
|
||||
|
||||
// G — no plain-http URL
|
||||
const httpUrls = []
|
||||
for (const [c, v] of Object.entries(codes)) {
|
||||
for (const u of [v.s, v.f, v.z, v.za && v.za.u, v.au && v.au.u, v.bv && v.bv.u, v.gp && v.gp.u, ...(v.d || []).map((d) => d.u)]) {
|
||||
if (u && /^http:\/\//i.test(u)) httpUrls.push(`${c} ${u}`)
|
||||
}
|
||||
}
|
||||
if (httpUrls.length) add('G', `${httpUrls.length} plain-http URL(s) (e.g. ${httpUrls[0]})`)
|
||||
|
||||
// F — tier agrees with the URLs the code holds
|
||||
let fBad = 0
|
||||
for (const r of rows) {
|
||||
const v = codes[r[4]] || {}
|
||||
const want = v.s && v.f ? 2 : (v.s || v.f || (v.d && v.d.length) || v.au || v.bv) ? 1 : 0
|
||||
if (want !== r[6]) fBad++
|
||||
}
|
||||
if (fBad) add('F', `${fBad} pin(s) carry a tier that disagrees with their code's URLs`)
|
||||
|
||||
// J — an approximate match must never sit alongside a confirmed one on the same code.
|
||||
// The map styles them differently and says different things, so a code holding both would
|
||||
// render the qualified claim and the confirmed one as if they were the same resource, and
|
||||
// the qualification is the entire point of keeping them apart.
|
||||
const both = Object.entries(codes).filter(([, v]) => v.z && v.za).map(([c]) => c)
|
||||
if (both.length) add('J', `${both.length} code(s) carry BOTH a confirmed and an approximate training link (e.g. ${both[0]})`)
|
||||
// An approximate entry must still be a real, complete record rather than a bare URL.
|
||||
const thin = Object.entries(codes).filter(([, v]) => v.za && !(v.za.u && v.za.l && v.za.n)).map(([c]) => c)
|
||||
if (thin.length) add('J', `${thin.length} approximate entr(ies) missing a url, language or note (e.g. ${thin[0]})`)
|
||||
|
||||
// K — verification must actually cover what is shipped, and nothing shipped may be known
|
||||
// dead. The previous table matched TWO of 3,605 links because its keys were written http://
|
||||
// and against a URL shape the index had since replaced; the map showed "unchecked" on links
|
||||
// that had in fact been verified and nobody could tell that from never-looked-at. A coverage
|
||||
// floor is the only thing that makes that failure loud.
|
||||
if (verif && verif.links) {
|
||||
const V = verif.links
|
||||
const shipped = new Set()
|
||||
for (const v of Object.values(codes)) {
|
||||
if (v.s) shipped.add(v.s)
|
||||
if (v.f) shipped.add(v.f)
|
||||
if (v.z) shipped.add(v.z)
|
||||
if (v.za && v.za.u) shipped.add(v.za.u)
|
||||
if (v.au && v.au.u) shipped.add(v.au.u)
|
||||
if (v.bv && v.bv.u) shipped.add(v.bv.u)
|
||||
if (v.gp && v.gp.u) shipped.add(v.gp.u)
|
||||
for (const d of v.d || []) if (d.u) shipped.add(d.u)
|
||||
}
|
||||
// EVERY shipped URL must be in the record, not 95% of them. The floor was set at 95%
|
||||
// and that is exactly how two known-dead links got back in: dropping them removed them
|
||||
// from the corpus, the next verification run never saw them, and 2 missing out of 3,768
|
||||
// is 99.9% — comfortably above a 95% floor and completely wrong. "Verify before you
|
||||
// ship it" does not have a tolerance.
|
||||
const unknownToRecord = [...shipped].filter((u) => !V[u])
|
||||
if (unknownToRecord.length) {
|
||||
add('K', `${unknownToRecord.length} shipped URL(s) appear in no verification record (e.g. ${unknownToRecord[0]}) — run scripts/verify-gospel-links.mjs`)
|
||||
}
|
||||
const dead = [...shipped].filter((u) => V[u] && V[u].state === 'broken')
|
||||
if (dead.length) add('K', `${dead.length} shipped URL(s) are recorded BROKEN and must not ship (e.g. ${dead[0]})`)
|
||||
}
|
||||
|
||||
// L — the verification record must be FRESH, and every shipped URL must carry a stamp.
|
||||
// "Link checked" is a claim about the present tense. A table that silently stops being
|
||||
// re-run keeps making that claim on evidence that may be a year old, and every clause
|
||||
// above would still pass: they check that the record is CONSISTENT, never that it is
|
||||
// current. A dead link is exactly the failure a field worker hits, so the check that the
|
||||
// record is recent is not bookkeeping.
|
||||
// Re-running is cheap and resumable now (VERIFY_MAX_AGE_H skips what is already fresh),
|
||||
// so this failing is an instruction, not an obstacle.
|
||||
if (verif && verif.links) {
|
||||
const V = verif.links
|
||||
const MAX_AGE_DAYS = Number(process.env.VERIFY_MAX_AGE_DAYS || 45)
|
||||
const shipped2 = new Set()
|
||||
for (const v of Object.values(codes)) {
|
||||
if (v.s) shipped2.add(v.s)
|
||||
if (v.f) shipped2.add(v.f)
|
||||
if (v.z) shipped2.add(v.z)
|
||||
if (v.za && v.za.u) shipped2.add(v.za.u)
|
||||
if (v.au && v.au.u) shipped2.add(v.au.u)
|
||||
if (v.bv && v.bv.u) shipped2.add(v.bv.u)
|
||||
if (v.gp && v.gp.u) shipped2.add(v.gp.u)
|
||||
for (const d of v.d || []) if (d.u) shipped2.add(d.u)
|
||||
}
|
||||
const unstamped = [...shipped2].filter((u) => V[u] && !V[u].at)
|
||||
if (unstamped.length) {
|
||||
add('L', `${unstamped.length} shipped URL(s) carry no check date (e.g. ${unstamped[0]}) — rerun scripts/verify-gospel-links.mjs`)
|
||||
}
|
||||
let oldest = Infinity
|
||||
let oldestUrl = ''
|
||||
for (const u of shipped2) {
|
||||
const at = V[u] && V[u].at ? Date.parse(V[u].at) : NaN
|
||||
if (!Number.isFinite(at)) continue
|
||||
if (at < oldest) { oldest = at; oldestUrl = u }
|
||||
}
|
||||
const nowMs = poisonStale ? Date.now() + (MAX_AGE_DAYS + 10) * 86400000 : Date.now()
|
||||
if (Number.isFinite(oldest)) {
|
||||
const days = (nowMs - oldest) / 86400000
|
||||
if (days > MAX_AGE_DAYS) {
|
||||
add('L', `the oldest shipped link was last checked ${days.toFixed(0)} days ago (${oldestUrl}), past the ${MAX_AGE_DAYS}-day limit — rerun VERIFY_MAX_AGE_H=24 node scripts/verify-gospel-links.mjs`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const clauses = new Set(fails.map((f) => f.slice(3, 4)))
|
||||
if (POISON) {
|
||||
// D is included deliberately: it is the clause that would have caught the 506
|
||||
// resources dropped from a table titled "every link", and the most valuable clause is
|
||||
// the worst one to leave unproven. The injected codes above (und, zzq) are absent from
|
||||
// the table, so they poison D as well as A and B.
|
||||
const want = ['A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'J', 'K', 'L']
|
||||
const caught = want.filter((c) => clauses.has(c))
|
||||
console.log(`COVERAGE-CONSISTENT --poison: ${caught.length}/${want.length} clause(s) caught their injected defect (${caught.join(',')})`)
|
||||
if (caught.length !== want.length) {
|
||||
console.error(` MISSED: ${want.filter((c) => !clauses.has(c)).join(',')} — those clauses cannot fail and prove nothing.`)
|
||||
process.exit(1)
|
||||
}
|
||||
console.log('COVERAGE-CONSISTENT: SELF-TEST PASS — every poisoned clause fails as designed.')
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
if (fails.length) {
|
||||
console.error(`COVERAGE-CONSISTENT: FAIL — ${fails.length} problem(s):`)
|
||||
for (const f of fails.slice(0, 20)) console.error(f)
|
||||
process.exit(1)
|
||||
}
|
||||
console.log(`COVERAGE-CONSISTENT: PASS — ${rows.length} pins, ${Object.keys(codes).length} codes, ${trows.length} table rows agree.`)
|
||||
// Name every clause. A summary that lists four of nine reads as a narrower check than it
|
||||
// is, and the next reader calibrates their trust on this line rather than the source.
|
||||
console.log(' A no resource on an ISO reserved code B every resourced code is named')
|
||||
console.log(' C reach recounted off the pins D every resourced code is in the table')
|
||||
console.log(' E no table URL the pins do not hold F pin tiers match their code\'s URLs')
|
||||
console.log(' G no plain-http URL H no story set typed as Scripture')
|
||||
console.log(' I row type agrees with its host J approximate never reads as confirmed')
|
||||
console.log(' K every shipped link is verified, and none known-broken ships')
|
||||
console.log(' L the verification record is current, not merely consistent')
|
||||
|
|
@ -2,15 +2,15 @@
|
|||
/**
|
||||
* check-heartbeat-threshold.mjs — THE HEARTBEAT THRESHOLD MUST COME FROM CONFIG, NOT A LITERAL.
|
||||
*
|
||||
* WHAT A FOCUS ZONE IS. The top bin of the research-dashboard choropleth: the countries
|
||||
* holding the most people groups. product owner: "the focus zones are the most people groups in
|
||||
* them... that's going to be 150 plus people groups in those focus zones."
|
||||
* WHAT A HEARTBEAT ZONE IS. The top bin of the research-dashboard choropleth: the countries
|
||||
* holding the most people groups. agent: "the heartbeat zones are the most people groups in
|
||||
* them... that's going to be 150 plus people groups in those heartbeat zones."
|
||||
*
|
||||
* ★ THE DEFECT THIS CATCHES, AND WHY NOTHING ELSE COULD. The ramp's edges were a 5-stop array
|
||||
* literal — `upTo: 5, 20, 60, 150, Infinity` — sitting in DashboardMapPanel.vue. Those numbers are
|
||||
* not a property of choropleths; they are a property of THIS DATASET (~2000 people groups). Point
|
||||
* the dashboard at a 200-group dataset and every country falls in the bottom bin: the map renders
|
||||
* uniformly pale and says nothing. product owner: "that filter of 150 ... should be a variable. It
|
||||
* uniformly pale and says nothing. agent: "that filter of 150 ... should be a variable. It
|
||||
* shouldn't be a static ... I need to be able to reuse this dashboard for other maps, other data
|
||||
* sets." A dataset assumption wearing the costume of a constant.
|
||||
*
|
||||
|
|
@ -46,7 +46,7 @@
|
|||
* to exactly that number and must RE-BIN the fixture's countries. This is the assertion a
|
||||
* re-hard-coded literal fails.
|
||||
* C. THE LEGEND FOLLOWS THE EDGES — the top label must CONTAIN "<threshold>+" and name the
|
||||
* focus zone. A moved edge with a stale '150+' legend is confidently wrong, which is
|
||||
* heartbeat zone. A moved edge with a stale '150+' legend is confidently wrong, which is
|
||||
* worse than no legend. Matched by property, not equality — see the note on HISTORICAL.
|
||||
* D. NO DEAD BINS — every bin must be reachable (`colorFor` tests `count < upTo`, so an edge of
|
||||
* 1 creates a band no count can enter: the ramp paints 4 of its 5 colours while the legend
|
||||
|
|
@ -71,7 +71,7 @@
|
|||
* ★ THE FIRST VERSION OF THIS GATE SHIPPED WITH [literal] ONLY, and its header said it had been
|
||||
* "negative-tested" — true of that case, misleading overall. Testing that THE FEATURE WORKS (a
|
||||
* configured threshold moves the edge) is NOT testing that THE REGRESSION IS CAUGHT (the default
|
||||
* silently changing), and only the second protects the product owner. One negative test per regression
|
||||
* silently changing), and only the second protects the agent. One negative test per regression
|
||||
* claimed. A gate that cannot fail is worse than no gate — this repo has shipped one.
|
||||
*
|
||||
* NOT COVERED, deliberately: this gate reasons about BIN ASSIGNMENT and LABELS only. It says
|
||||
|
|
@ -93,7 +93,7 @@ const SELF_TEST = process.argv.includes('--self-test')
|
|||
//
|
||||
// ★ THE TOP LABEL IS MATCHED BY PROPERTY, NOT BY EQUALITY — a correction this gate earned on its
|
||||
// FIRST RUN. It originally demanded the top label equal exactly "150+", and immediately failed
|
||||
// CORRECT code: the label reads "150+ · focus zone", because naming the band in the product owner's
|
||||
// CORRECT code: the label reads "150+ · heartbeat zone", because naming the band in the agent's
|
||||
// own words is a requirement of this work, not a defect. An over-strict gate that fires on correct
|
||||
// code gets switched off, and then the real regression walks through. So the assertion is the
|
||||
// property that actually matters: the top label must CARRY THE CURRENT THRESHOLD (so a moved edge
|
||||
|
|
@ -201,7 +201,7 @@ function run(srcOverride) {
|
|||
fail(`${tag}. RAMP MOVED at bin ${i}: expected upTo=${exp.upTo}, got ${got ? got.upTo : '(missing)'}.\n ${why}`)
|
||||
} else if (exp.l === null) {
|
||||
if (!topLabelOk(got.l, 150)) {
|
||||
fail(`${tag}. TOP LABEL is "${got.l}" — it must contain "150+" and name the focus zone.\n ${why}`)
|
||||
fail(`${tag}. TOP LABEL is "${got.l}" — it must contain "150+" and name the heartbeat zone.\n ${why}`)
|
||||
}
|
||||
} else if (got.l !== exp.l) {
|
||||
fail(`${tag}. LEGEND LABEL MOVED at bin ${i}: expected "${exp.l}", got "${got.l}".\n ${why}`)
|
||||
|
|
@ -259,7 +259,7 @@ function run(srcOverride) {
|
|||
}
|
||||
if (!topLabelOk(top.l, t)) {
|
||||
fail(`C. LEGEND DID NOT FOLLOW THE EDGE at threshold ${t}: top label reads "${top.l}".\n` +
|
||||
` It must contain "${t}+" and name the focus zone. A moved edge with a stale number on the\n` +
|
||||
` It must contain "${t}+" and name the heartbeat zone. A moved edge with a stale number on the\n` +
|
||||
' legend is confidently wrong, which is worse than no legend.')
|
||||
}
|
||||
bins.forEach((b, i) => {
|
||||
|
|
@ -290,7 +290,7 @@ function run(srcOverride) {
|
|||
// ★ TWO CASES, BECAUSE THERE ARE TWO DISTINCT REGRESSIONS AND THEY ARE NOT THE SAME ASSERTION.
|
||||
// This gate originally shipped with case 1 only, and its header claimed it was "negative-tested".
|
||||
// That was true of case 1 and MISLEADING overall: case 2 — the one that actually protects the
|
||||
// product owner's "today's map must not change" requirement — was unasserted, and a one-line edit to
|
||||
// agent's "today's map must not change" requirement — was unasserted, and a one-line edit to
|
||||
// dashboard.config.js changed the shipped ramp while the gate printed PASS. Verified by
|
||||
// reproducing it, not by reasoning about it.
|
||||
//
|
||||
|
|
|
|||
120
internal/check-i18n-parity.mjs
Normal file
120
internal/check-i18n-parity.mjs
Normal file
|
|
@ -0,0 +1,120 @@
|
|||
// check-i18n-parity — a key that exists in English must exist in every locale.
|
||||
//
|
||||
// ★ THE FAILURE IS SILENT BY DESIGN. vue-i18n falls back to English for a missing
|
||||
// key, which is the right runtime behaviour and the reason this drifts unnoticed:
|
||||
// the app looks fine, nothing errors, and a reader who chose Spanish simply gets
|
||||
// English words with no indication anything is missing.
|
||||
//
|
||||
// Measured when this was written: buttons.json had 21 keys in English and 19 in
|
||||
// every other locale. The two orphans were `share` and `shareSteps` — the Share
|
||||
// button's own title and its five-step explainer, rendered on EVERY map. Ten
|
||||
// locales had been shipping an English Share button since the key was added.
|
||||
//
|
||||
// It reports per-locale so a partial translation is visible as a number rather
|
||||
// than as a vague sense that "some things aren't translated".
|
||||
import { readFileSync, readdirSync, existsSync } from 'node:fs'
|
||||
import { resolve, join, dirname } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
// ⚠ REPO-RELATIVE, never an absolute path. This gate exists in MORE THAN ONE repo
|
||||
// (doxa-maps-plugin-studio and wt-packet are separate repos, not worktrees), and a
|
||||
// hardcoded /home/... made the studio copy silently grade wt-packet's locales —
|
||||
// so a ceiling lowered in one repo left the other's stale and neither was wrong on
|
||||
// its face. Each copy now checks ITS OWN tree. (check:no-abs-paths, 2026-09-24.)
|
||||
const REPO = process.env.DOXA_REPO
|
||||
|| resolve(dirname(fileURLToPath(import.meta.url)), '..')
|
||||
const ROOT = join(REPO, 'library/i18n/locales')
|
||||
if (!existsSync(ROOT)) { console.error(`I18N-PARITY: FAIL — no locales at ${ROOT}`); process.exit(1) }
|
||||
|
||||
// Flatten so nested objects and arrays are compared key-by-key, not shallowly.
|
||||
const flat = (obj, prefix = '', out = {}) => {
|
||||
for (const [k, v] of Object.entries(obj || {})) {
|
||||
const key = prefix ? `${prefix}.${k}` : k
|
||||
if (v && typeof v === 'object' && !Array.isArray(v)) flat(v, key, out)
|
||||
else out[key] = v
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
const read = (loc, area) => {
|
||||
const p = join(ROOT, loc, area)
|
||||
if (!existsSync(p)) return null
|
||||
try { return flat(JSON.parse(readFileSync(p, 'utf8'))) } catch { return undefined }
|
||||
}
|
||||
|
||||
const locales = readdirSync(ROOT).filter((d) => existsSync(join(ROOT, d)))
|
||||
if (!locales.includes('en')) { console.error('I18N-PARITY: FAIL — no English catalog to compare against'); process.exit(1) }
|
||||
const areas = readdirSync(join(ROOT, 'en')).filter((f) => f.endsWith('.json'))
|
||||
|
||||
// ── KNOWN DEBT, RATCHETED — AND THE MAP IS NOW EMPTY ────────────────────────
|
||||
// ★ ALL ELEVEN LOCALES ARE COMPLETE. 2026-09-24: the last 162 keys closed
|
||||
// (regions.json for it/ro/zh, 54 each), finishing the 429-key debt this gate
|
||||
// found when it was written.
|
||||
//
|
||||
// THE MAP IS DELIBERATELY EMPTY RATHER THAN FULL OF ZEROS. A locale with no entry
|
||||
// here MUST be complete — the gate fails it outright for a single missing key —
|
||||
// so an empty DEBT is the strongest state this ratchet can be in, not an unset
|
||||
// one. Writing `{ it: 0, ... }` would mean the same today and quietly permit a
|
||||
// regression back up the moment someone "adjusted" a number.
|
||||
//
|
||||
// HOW IT WORKED, for whoever needs to add debt back: these were a CEILING, not a
|
||||
// target — the gate failed when a locale got WORSE and printed a nudge when one
|
||||
// improved, so the number could be lowered. That let real translation work sit in
|
||||
// the backlog without blocking every build, which is what a gate everyone
|
||||
// disables protects against. If a new English key lands ahead of its
|
||||
// translations, add the locale here with its count and lower it as they arrive.
|
||||
// Never raise a number; never add a locale that is already complete.
|
||||
const DEBT = {
|
||||
}
|
||||
|
||||
const fails = []
|
||||
const improved = []
|
||||
const rows = []
|
||||
for (const loc of locales.filter((l) => l !== 'en').sort()) {
|
||||
let missing = 0
|
||||
let total = 0
|
||||
const examples = []
|
||||
for (const area of areas) {
|
||||
const en = read('en', area)
|
||||
const other = read(loc, area)
|
||||
total += Object.keys(en).length
|
||||
if (other === null) { missing += Object.keys(en).length; examples.push(`${area} (whole file)`); continue }
|
||||
if (other === undefined) { fails.push(`${loc}/${area} is not valid JSON`); continue }
|
||||
for (const k of Object.keys(en)) {
|
||||
if (!(k in other)) { missing += 1; if (examples.length < 3) examples.push(`${area}:${k}`) }
|
||||
}
|
||||
}
|
||||
rows.push({ loc, missing, total, examples })
|
||||
const ceiling = DEBT[loc]
|
||||
if (ceiling === undefined) {
|
||||
if (missing) fails.push(`${loc} is missing ${missing}/${total} key(s) and has no DEBT entry — `
|
||||
+ `e.g. ${examples.join(', ')}`)
|
||||
} else if (missing > ceiling) {
|
||||
fails.push(`${loc} regressed: ${missing} missing key(s), was ${ceiling} — `
|
||||
+ `e.g. ${examples.join(', ')}`)
|
||||
} else if (missing < ceiling) {
|
||||
improved.push(`${loc}: ${missing} missing, ceiling still says ${ceiling} — lower it`)
|
||||
}
|
||||
}
|
||||
|
||||
console.log(` locales : ${locales.length} (${locales.sort().join(' ')})`)
|
||||
console.log(` areas : ${areas.length} catalog file(s), ${Object.keys(read('en', areas[0]) || {}).length}+ keys each`)
|
||||
for (const r of rows) {
|
||||
const c = DEBT[r.loc]
|
||||
const tag = r.missing === 0 ? 'complete'
|
||||
: (c !== undefined && r.missing <= c ? `${r.missing} missing (known debt, ceiling ${c})`
|
||||
: `${r.missing} missing`)
|
||||
console.log(` ${r.loc.padEnd(4)} ${tag}`)
|
||||
}
|
||||
|
||||
if (fails.length) {
|
||||
console.error(`\nI18N-PARITY: FAIL — ${fails.length} problem(s)`)
|
||||
for (const f of fails) console.error(' - ' + f)
|
||||
process.exit(1)
|
||||
}
|
||||
if (improved.length) {
|
||||
console.log('\n ↓ improved since the ceiling was set — lower DEBT in this file:')
|
||||
for (const i of improved) console.log(' ' + i)
|
||||
}
|
||||
const debt = rows.reduce((a, r) => a + r.missing, 0)
|
||||
console.log(`\nI18N-PARITY: PASS — no locale regressed. ${debt} key(s) of known translation debt remain.`)
|
||||
148
internal/check-legend-info-localized.mjs
Normal file
148
internal/check-legend-info-localized.mjs
Normal file
|
|
@ -0,0 +1,148 @@
|
|||
#!/usr/bin/env node
|
||||
// check-legend-info-localized — the legend's INFO POPOVERS must translate, in the
|
||||
// real served page, not in a reimplementation of the lookup.
|
||||
//
|
||||
// ★ WHY THIS EXISTS BESIDE check:tabs-localized. That gate proves the dynamic-key
|
||||
// mechanism reaches tabs.json — genuinely valuable, and it passes. But it tests SEVEN
|
||||
// HARDCODED research-map tabs and it REIMPLEMENTS tabLabel() inside page.evaluate with
|
||||
// catalogs fed in by hand. So it proves "tab localization works, for these seven",
|
||||
// which is not the same claim as "this profile's tabs are localized". The UPG-100
|
||||
// legend's three tabs were absent from tabs.json entirely and that gate stayed green.
|
||||
//
|
||||
// ⚠ AND IT NEVER LOOKED AT `info` AT ALL. SemanticTreeLegend rendered `tab.info` RAW in
|
||||
// all three places it appears (the button's title=, and the popover body) while the tab
|
||||
// LABEL beside it went through the catalog. Measured 2026-09-24 on the served leaf:
|
||||
// ?lang=es changed 2 of 76 rendered strings ("POPULATION" -> "POBLACIÓN") and left all
|
||||
// three tabs AND all three info popovers in English. The mechanism worked; the strings
|
||||
// were never catalogued. The agent named this affordance specifically — "the tab itself
|
||||
// gives you a little information button that will explain what a UPG-100 list is" —
|
||||
// which is exactly the sentence a reader who does not read English cannot use.
|
||||
//
|
||||
// ⚠ IT RENDERS THE REAL PAGE. A catalog-only check cannot tell a shipped translation
|
||||
// from a dead one, and a reimplemented lookup cannot tell you the component actually
|
||||
// calls it. This opens the served leaf, switches locale the way a reader does (?lang=),
|
||||
// CLICKS each info button and reads the popover that paints.
|
||||
//
|
||||
// ⚠ WAIT FOR THE FLUSH. showInfo() sets a ref; reading the DOM in the same tick as the
|
||||
// click finds no popover and reads as "the affordance is broken". Measured: the first
|
||||
// version of this probe reported "(no popover)" in every locale against a working build.
|
||||
//
|
||||
// Usage: node internal/check-legend-info-localized.mjs <leafUrl> [locales]
|
||||
import { chromium } from 'playwright'
|
||||
import { readFileSync, existsSync } from 'node:fs'
|
||||
import { join, dirname } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
const REPO = join(dirname(fileURLToPath(import.meta.url)), '..')
|
||||
const LOCALES = join(REPO, 'library/i18n/locales')
|
||||
|
||||
const URL_ = process.argv[2] || process.env.LEGEND_URL
|
||||
if (!URL_) {
|
||||
console.log('LEGEND-INFO-LOCALIZED: SKIP — no leaf url given.')
|
||||
console.log(' Pass one, or set LEGEND_URL. Serve the TREE ROOT (public/doxa-maps) and point')
|
||||
console.log(' at a profile whose legend carries per-tab info buttons, e.g.')
|
||||
console.log(' my-upg-100-list/upg-100-list/index.html')
|
||||
process.exit(0)
|
||||
}
|
||||
const UNDER_TEST = (process.argv[3] || 'es,ar,zh').split(',').map((s) => s.trim()).filter(Boolean)
|
||||
|
||||
const legendCat = (loc) => {
|
||||
const p = join(LOCALES, loc, 'legend.json')
|
||||
return existsSync(p) ? JSON.parse(readFileSync(p, 'utf8')) : null
|
||||
}
|
||||
|
||||
// Read each locale's info popovers from the REAL page, clicking every info button.
|
||||
async function readLegend(page, url, lang) {
|
||||
const errs = []
|
||||
page.on('pageerror', (e) => errs.push(String(e).slice(0, 120)))
|
||||
await page.goto(`${url}?lang=${lang}`, { waitUntil: 'load', timeout: 45000 })
|
||||
await page.waitForTimeout(9000)
|
||||
const walkFn = () => {
|
||||
const walk = (root, out = []) => {
|
||||
for (const el of root.querySelectorAll('*')) { out.push(el); if (el.shadowRoot) walk(el.shadowRoot, out) }
|
||||
return out
|
||||
}
|
||||
return walk(document)
|
||||
}
|
||||
const count = await page.evaluate(`(${walkFn.toString()})().filter(e => (e.className||'').toString().includes('stl-tab-info')).length`)
|
||||
const pops = []
|
||||
for (let i = 0; i < count; i++) {
|
||||
await page.evaluate(`(${walkFn.toString()})().filter(e => (e.className||'').toString().includes('stl-tab-info'))[${i}]?.click()`)
|
||||
// ⚠ showInfo() sets a ref — read AFTER Vue flushes, or every popover reads empty.
|
||||
await page.waitForTimeout(400)
|
||||
pops.push(await page.evaluate(`(() => { const el = (${walkFn.toString()})().find(e => (e.className||'').toString().includes('stl-info-pop')); return el ? (el.innerText||'').trim() : '' })()`))
|
||||
}
|
||||
const tabs = await page.evaluate(`(${walkFn.toString()})().filter(e => (e.className||'').toString().includes('stl-tab-chip')).map(c => (c.innerText||'').trim().split('\\n')[0]).filter(Boolean)`)
|
||||
return { tabs, pops, errs }
|
||||
}
|
||||
|
||||
const browser = await chromium.launch({ args: ['--use-gl=swiftshader', '--enable-unsafe-swiftshader'] })
|
||||
const seen = {}
|
||||
try {
|
||||
for (const loc of ['en', ...UNDER_TEST]) {
|
||||
const page = await browser.newPage({ viewport: { width: 1500, height: 1000 } })
|
||||
seen[loc] = await readLegend(page, URL_, loc)
|
||||
await page.close()
|
||||
}
|
||||
} finally {
|
||||
await browser.close()
|
||||
}
|
||||
|
||||
const en = seen.en
|
||||
const fails = []
|
||||
|
||||
// PRECONDITION: this page must actually HAVE the affordance, or every assertion below
|
||||
// is vacuous. A leaf whose bundle failed to execute renders zero tabs with zero page
|
||||
// errors — the stale-SRI signature — and would otherwise pass as "nothing untranslated".
|
||||
if (!en.pops.length) {
|
||||
console.error('\nLEGEND-INFO-LOCALIZED: FAIL — no info buttons rendered at all in English.')
|
||||
console.error(' Either this profile has no per-tab info affordance (wrong url), or the bundle')
|
||||
console.error(' did not execute. A stale SRI hash refuses the script SILENTLY, with no page')
|
||||
console.error(` error and zero tabs — run \`bun run tree\` after \`BUNDLE=<n> bun x vite build\`.`)
|
||||
console.error(` tabs rendered: ${JSON.stringify(en.tabs)} page errors: ${en.errs.length}`)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
// THE LOAD-BEARING ASSERTION. A chosen locale must CHANGE the info text — that is the
|
||||
// difference between a translation that ships and one that merely exists on disk.
|
||||
const separable = {}
|
||||
for (const loc of UNDER_TEST) {
|
||||
const s = seen[loc]
|
||||
if (!s.pops.length) { fails.push(`${loc}: no info popovers rendered (English had ${en.pops.length})`); continue }
|
||||
if (s.pops.length !== en.pops.length) {
|
||||
fails.push(`${loc}: ${s.pops.length} info popover(s), English had ${en.pops.length}`)
|
||||
}
|
||||
// NEVER BLANK. The whole point of a per-key fallback is that an untranslated key
|
||||
// shows English rather than nothing.
|
||||
const blank = s.pops.filter((t) => !t.trim()).length
|
||||
if (blank) fails.push(`${loc}: ${blank} info popover(s) rendered BLANK — the fallback should show English`)
|
||||
// Count how many DIFFER from English. Zero means the catalog is not reached: either
|
||||
// the entries are missing, or the component renders the raw prop as it used to.
|
||||
const diff = s.pops.filter((t, i) => t && en.pops[i] && t !== en.pops[i]).length
|
||||
separable[loc] = diff
|
||||
const cat = legendCat(loc)
|
||||
const catKeys = cat && cat.tabInfo ? Object.keys(cat.tabInfo).length : 0
|
||||
if (catKeys > 0 && diff === 0) {
|
||||
fails.push(`${loc}: legend.json declares ${catKeys} tabInfo key(s) but NOT ONE info popover `
|
||||
+ 'differs from English — the catalog is present and unreached (the raw-prop bug)')
|
||||
}
|
||||
if (s.errs.length) fails.push(`${loc}: ${s.errs.length} page error(s): ${s.errs[0]}`)
|
||||
}
|
||||
|
||||
console.log('LEGEND-INFO-LOCALIZED')
|
||||
console.log(` leaf : ${URL_}`)
|
||||
console.log(` info popups: ${en.pops.length} (English)`)
|
||||
console.log(` tabs : ${en.tabs.join(' · ')}`)
|
||||
for (const loc of UNDER_TEST) {
|
||||
const s = seen[loc]
|
||||
console.log(` ${loc.padEnd(10)}: ${separable[loc] ?? 0}/${s.pops.length} info text(s) differ from English, `
|
||||
+ `${s.pops.filter((t) => !t.trim()).length} blank tabs: ${s.tabs.join(' · ')}`)
|
||||
}
|
||||
|
||||
if (fails.length) {
|
||||
console.error(`\nLEGEND-INFO-LOCALIZED: FAIL — ${fails.length} problem(s)`)
|
||||
for (const f of fails) console.error(' - ' + f)
|
||||
process.exit(1)
|
||||
}
|
||||
console.log('\nLEGEND-INFO-LOCALIZED: PASS — choosing a locale changes the rendered info text, so '
|
||||
+ 'the legend\'s explanations reach the reader in their own language, never blank.')
|
||||
|
|
@ -71,7 +71,7 @@ const EXPECT = {
|
|||
// and 5 have FOUR each. The "or Resources" clause is not literally true in the data, so that phrase
|
||||
// must never be repeated to a mission team as fact.
|
||||
//
|
||||
// NOT asserted as a gate condition here, deliberately: which figure is OFFICIAL is a product owner decision
|
||||
// NOT asserted as a gate condition here, deliberately: which figure is OFFICIAL is a agent decision
|
||||
// (DRIVER-DECISIONS-gospel-resources.md, Decision 1), and a gate must not quietly canonise one answer
|
||||
// to a question that is still open. Recorded so the next reader inherits the measurement, not the
|
||||
// task of re-deriving it.
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
#!/usr/bin/env node
|
||||
/**
|
||||
* check-library-index.mjs — the derived library menu must not fall behind library/.
|
||||
* check-library-index.mjs — the derived library menu must not DRIFT from library/.
|
||||
*
|
||||
* THE BUG THIS EXISTS TO PREVENT (real, 2026-09-21)
|
||||
* internal/generate-library-index.mjs opens with: "The index is DERIVED, never hand-edited — so it
|
||||
|
|
@ -8,29 +8,70 @@
|
|||
* no package.json entry, and documentation/reference-library-index.md DID NOT EXIST AT ALL. A documented
|
||||
* invariant enforced only by a human remembering is a wish, not an invariant.
|
||||
*
|
||||
* ★ IT ASKS THE REAL QUESTION NOW: "HAS THE MENU DRIFTED?", NOT "IS THIS FILE OLDER?"
|
||||
* This compared MTIMES until 2026-09-24, and an mtime is a proxy that answers a different question
|
||||
* than the one the gate's own name asks. MEASURED: `touch library/components/legends/
|
||||
* SemanticTreeLegend.vue` — zero content change — turned it RED. So did a `git checkout`, a rebase,
|
||||
* and any editor that rewrites a file on save. It cried wolf TWICE in a single build session while
|
||||
* the regenerated doc was byte-identical both times, and the only available response was to run the
|
||||
* generator and commit nothing.
|
||||
*
|
||||
* That is the SECOND time this exact class was fixed here. The first (2026-09-23, note below) was a
|
||||
* SCOPE error — the gate walked 2.3x more of library/ than the generator reads, so adding a comment
|
||||
* to a build script turned it red and named that script as the reason the component MENU was stale.
|
||||
* Narrowing the scope fixed that instance; the MECHANISM was still mtime, so the class survived
|
||||
* inside the narrowed scope. The lesson recorded then is the one that applies now, verbatim:
|
||||
* "A gate that fails for a change it can prove is irrelevant teaches the reader to regenerate on
|
||||
* reflex, which is how a staleness check stops being read."
|
||||
*
|
||||
* HOW: regenerate into a THROWAWAY TREE and diff the bytes. generate-library-index.mjs resolves its
|
||||
* ROOT from its own location (`join(fileURLToPath(new URL('.', import.meta.url)), '..')`) and
|
||||
* hardcodes its output path, so it cannot be told to write elsewhere — but copying the SCRIPT into a
|
||||
* temp tree whose `library/` is a SYMLINK to the real one relocates its ROOT without touching it.
|
||||
* The generator runs completely unmodified, reads the real sources, and writes somewhere disposable.
|
||||
* Nothing in the working tree is stashed or mutated, so an interrupted run leaves no mess.
|
||||
* VERIFIED byte-identical to a real `bun run library-index` before this gate was written.
|
||||
*
|
||||
* PRECONDITION, tested before relying on it: the generator is DETERMINISTIC — two runs a second
|
||||
* apart produce identical bytes (no timestamps, no directory-order dependence). A content diff would
|
||||
* be useless otherwise, and would have been a worse gate than the mtime one it replaces.
|
||||
*
|
||||
* WHY RAW SOURCE IS THE RIGHT ANCESTOR HERE (and was NOT for the screenshots)
|
||||
* check-shot-freshness compares a PNG against the BUILT BUNDLE, because a screenshot renders a
|
||||
* built leaf and an unbuilt edit is not yet visible in one. This index is different: it is
|
||||
* generated by SCANNING library/ directly, with no build step in between. So the honest ancestor
|
||||
* is the newest library/ source file. ★ THE ANCESTOR IS WHATEVER THE GENERATOR ACTUALLY READS —
|
||||
* copying the other gate's choice would have been wrong.
|
||||
* generated by SCANNING library/ directly, with no build step in between. ★ THE ANCESTOR IS
|
||||
* WHATEVER THE GENERATOR ACTUALLY READS — copying the other gate's choice would have been wrong.
|
||||
*
|
||||
* NEGATIVE-TESTED: `touch library/layouts/StackLayout.vue` makes this FAIL naming that file;
|
||||
* `bun run library-index` returns it to PASS.
|
||||
* ⚠ SCOPE NOTE, KEPT FROM THE MTIME VERSION because it still explains the generator: it reads
|
||||
* exactly three subdirectories — components/, composables/ and utils/ (see its section() calls).
|
||||
* 75 files are indexed, 173 are not — scripts/ 58, i18n/ 98, plus data/, layouts/, api/, constants/.
|
||||
* Comparing OUTPUT rather than input mtimes makes that scope self-enforcing: a change outside those
|
||||
* three directories cannot alter one byte of the generated menu, so this gate can no longer fire on
|
||||
* one. The INDEXED_DIRS list is gone with the mtime walk that needed it.
|
||||
*
|
||||
* NEGATIVE-TESTED: deleting a `- **Name**` row from the index makes this FAIL naming that row;
|
||||
* `touch`ing any library file does NOT (it is not drift); `bun run library-index` returns it to PASS.
|
||||
*/
|
||||
import { readdirSync, existsSync, statSync } from 'node:fs'
|
||||
import { existsSync, readFileSync, mkdtempSync, mkdirSync, copyFileSync, symlinkSync, rmSync } from 'node:fs'
|
||||
import { execFileSync } from 'node:child_process'
|
||||
import { resolve, dirname, join } from 'node:path'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..')
|
||||
const INDEX = join(ROOT, 'documentation', 'reference-library-index.md')
|
||||
const REL = join('documentation', 'reference-library-index.md')
|
||||
const INDEX = join(ROOT, REL)
|
||||
const LIB = join(ROOT, 'library')
|
||||
const GENERATOR = join(ROOT, 'internal', 'generate-library-index.mjs')
|
||||
|
||||
if (!existsSync(LIB)) {
|
||||
console.log('LIBRARY-INDEX: SKIP — no library/ directory.')
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
if (!existsSync(GENERATOR)) {
|
||||
console.log('LIBRARY-INDEX: SKIP — internal/generate-library-index.mjs is not present.')
|
||||
process.exit(0)
|
||||
}
|
||||
if (!existsSync(INDEX)) {
|
||||
console.error('LIBRARY-INDEX: FAIL — documentation/reference-library-index.md does not exist.')
|
||||
console.error(' It is the menu of reusable pieces an author reads INSTEAD of rebuilding them.')
|
||||
|
|
@ -38,47 +79,47 @@ if (!existsSync(INDEX)) {
|
|||
process.exit(1)
|
||||
}
|
||||
|
||||
// Newest source the generator actually scans.
|
||||
//
|
||||
// ★ AND THAT NOW MEANS WHAT IT SAYS (fixed 2026-09-23). This walked ALL of library/, while
|
||||
// generate-library-index.mjs reads exactly three subdirectories — components/, composables/ and
|
||||
// utils/ (see its section() calls). MEASURED: 75 files are indexed, 173 are not — scripts/ 58,
|
||||
// i18n/ 98, plus data/, layouts/, api/, constants/. So the gate watched 2.3x more than the thing it
|
||||
// guards, and a change anywhere in that surplus demanded a regeneration that could not alter one
|
||||
// byte of the output.
|
||||
//
|
||||
// It fired for real: adding a COMMENT to library/scripts/research/build-trade-languages.mjs turned
|
||||
// this gate red, naming a build script as the reason the component MENU was stale. A gate that
|
||||
// fails for a change it can prove is irrelevant teaches the reader to regenerate on reflex, which
|
||||
// is how a staleness check stops being read.
|
||||
//
|
||||
// Keeping the scope in step is the price of this design: if generate-library-index.mjs ever adds a
|
||||
// section(), add its directory here in the same commit.
|
||||
const INDEXED_DIRS = ['components', 'composables', 'utils']
|
||||
let newest = 0, newestFile = null
|
||||
const walk = (d) => {
|
||||
for (const e of readdirSync(d, { withFileTypes: true })) {
|
||||
if (e.name === 'node_modules' || e.name.startsWith('.')) continue
|
||||
const p = join(d, e.name)
|
||||
if (e.isDirectory()) { walk(p); continue }
|
||||
if (!/\.(vue|js|mjs)$/.test(e.name)) continue
|
||||
const m = statSync(p).mtimeMs
|
||||
if (m > newest) { newest = m; newestFile = p.slice(ROOT.length + 1) }
|
||||
// Regenerate into a throwaway tree: real script, real sources (symlinked), disposable output.
|
||||
let fresh = null
|
||||
let tmp = null
|
||||
try {
|
||||
tmp = mkdtempSync(join(tmpdir(), 'libidx-'))
|
||||
mkdirSync(join(tmp, 'internal'))
|
||||
mkdirSync(join(tmp, 'documentation'))
|
||||
copyFileSync(GENERATOR, join(tmp, 'internal', 'generate-library-index.mjs'))
|
||||
symlinkSync(LIB, join(tmp, 'library'), 'dir')
|
||||
execFileSync(process.execPath, [join(tmp, 'internal', 'generate-library-index.mjs')], { stdio: 'pipe' })
|
||||
fresh = readFileSync(join(tmp, REL), 'utf8')
|
||||
} catch (e) {
|
||||
// SKIP, never fail, if the harness itself could not run — a gate that goes red because a
|
||||
// temp directory was unwritable reports a product problem that is not one.
|
||||
console.log(`LIBRARY-INDEX: SKIP — could not regenerate for comparison (${String(e.message || e).slice(0, 90)}).`)
|
||||
process.exit(0)
|
||||
} finally {
|
||||
if (tmp) { try { rmSync(tmp, { recursive: true, force: true }) } catch { /* best effort */ } }
|
||||
}
|
||||
|
||||
const onDisk = readFileSync(INDEX, 'utf8')
|
||||
const rows = (s) => s.split('\n').filter((l) => l.startsWith('- **'))
|
||||
console.log(`LIBRARY-INDEX: ${rows(onDisk).length} catalogued item(s) vs a fresh regeneration.`)
|
||||
|
||||
if (onDisk !== fresh) {
|
||||
const a = onDisk.split('\n')
|
||||
const b = fresh.split('\n')
|
||||
const diffs = []
|
||||
for (let i = 0; i < Math.max(a.length, b.length) && diffs.length < 8; i++) {
|
||||
if (a[i] !== b[i]) diffs.push({ line: i + 1, file: a[i] ?? '(end of file)', real: b[i] ?? '(end of file)' })
|
||||
}
|
||||
}
|
||||
for (const d of INDEXED_DIRS) {
|
||||
const p = join(LIB, d)
|
||||
if (existsSync(p)) walk(p)
|
||||
}
|
||||
|
||||
const idxMs = statSync(INDEX).mtimeMs
|
||||
console.log(`LIBRARY-INDEX: index vs ${newestFile ? 'newest library source' : 'library/'}.`)
|
||||
|
||||
if (newest > idxMs) {
|
||||
console.error(`\nLIBRARY-INDEX: FAIL — the derived menu is OLDER than library/.`)
|
||||
console.error(` ✗ ${newestFile} is ${Math.round((newest - idxMs) / 1000)}s newer than the index`)
|
||||
console.error('\nLIBRARY-INDEX: FAIL — the derived menu no longer matches library/.')
|
||||
for (const d of diffs) {
|
||||
console.error(` line ${d.line}`)
|
||||
console.error(` file: ${d.file}`)
|
||||
console.error(` real: ${d.real}`)
|
||||
}
|
||||
const onlyCount = Math.abs(rows(onDisk).length - rows(fresh).length)
|
||||
if (onlyCount) console.error(` (${onlyCount} catalogued row(s) differ in number)`)
|
||||
console.error('\n The file says "DERIVED — cannot drift from the code"; that is only true if it is')
|
||||
console.error(' regenerated. Fix: bun run library-index')
|
||||
console.error(' regenerated. Fix: bun run library-index (then commit the regenerated file).')
|
||||
process.exit(1)
|
||||
}
|
||||
console.log('LIBRARY-INDEX: PASS — the derived menu is newer than every library/ source.')
|
||||
console.log('LIBRARY-INDEX: PASS — the derived menu matches what library/ generates today.')
|
||||
|
|
|
|||
98
internal/check-locale-reachable.mjs
Normal file
98
internal/check-locale-reachable.mjs
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
// check-locale-reachable — a reader must be able to CHOOSE one of the 11 locales.
|
||||
//
|
||||
// ★ THE BUG THIS EXISTS FOR IS UNREACHABILITY, not incorrectness. The packet ships
|
||||
// in 11 languages and every string is translated — and detectLocale() read ONLY
|
||||
// document.documentElement.lang, which EVERY generated leaf page in this repo
|
||||
// hardcodes to "en" (measured: 20 of 20). On the built static tree — the jamstack
|
||||
// case these bundles exist for — ten of the eleven translations could not be
|
||||
// reached by any reader. Correct, complete, and invisible.
|
||||
//
|
||||
// So this asserts two separate things, because they fail separately:
|
||||
// 1. RESOLUTION — ?lang= / ?locale= selects a supported locale, a bad value falls
|
||||
// back to English, and a region tag (pt-BR) resolves to its base language.
|
||||
// Tested against the SHIPPED source of detectLocale, not a copy.
|
||||
// 2. REACHABILITY — the built leaf pages still declare a lang, so the host-page
|
||||
// path (Polylang on WordPress) keeps working. If someone later makes the pages
|
||||
// lang-less, the URL path alone would silently become the only way in.
|
||||
import { readFileSync, readdirSync, existsSync } from 'node:fs'
|
||||
import { resolve, join, dirname } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
const REPO = process.env.DOXA_REPO
|
||||
// FOURTH instance of this pattern in one flight (check-zone-census, check-share-policy,
|
||||
// check-poster-prints, now this). DOXA_REPO still wins; only the default becomes portable,
|
||||
// per the idiom check-no-absolute-paths.mjs prints in its own failure text. A gate that
|
||||
// hardcodes its author's worktree turns build:unlocked red for every other builder.
|
||||
|| resolve(dirname(fileURLToPath(import.meta.url)), '..')
|
||||
const src = readFileSync(join(REPO, 'library/i18n/index.js'), 'utf8')
|
||||
|
||||
const SUPPORTED = (src.match(/SUPPORTED_LOCALES = \[([^\]]*)\]/) || [])[1]
|
||||
?.split(',').map((s) => s.trim().replace(/['"]/g, '')).filter(Boolean) || []
|
||||
|
||||
// Evaluate the SHIPPED detectLocale rather than reimplementing it — a gate that
|
||||
// reimplements the thing it tests only ever proves the copy agrees with itself.
|
||||
const body = src.slice(src.indexOf('export function detectLocale'), src.indexOf('export function createAppI18n'))
|
||||
const make = new Function('SUPPORTED_LOCALES', 'window', 'document',
|
||||
`${body.replace('export function', 'return function')}\n; return detectLocale;`)
|
||||
const detect = (search, htmlLang) =>
|
||||
make(SUPPORTED, { location: { search } }, { documentElement: { lang: htmlLang } })()
|
||||
|
||||
const CASES = [
|
||||
['?lang=es', 'en', 'es', 'a lang param selects a supported locale'],
|
||||
['?locale=fr', 'en', 'fr', 'the locale spelling works too'],
|
||||
['?lang=pt-BR', 'en', 'pt', 'a region tag resolves to its base language'],
|
||||
['?lang=zz', 'en', 'en', 'an unsupported value falls back to English'],
|
||||
['?lang=', 'de', 'de', 'an empty param defers to the host page'],
|
||||
['', 'de', 'de', 'with no param the host page still wins'],
|
||||
['?lang=ar', 'en', 'ar', 'RTL locales are selectable like any other'],
|
||||
]
|
||||
|
||||
const fails = []
|
||||
for (const [q, htmlLang, want, why] of CASES) {
|
||||
let got
|
||||
try { got = detect(q, htmlLang) } catch (e) { got = `THREW: ${e.message}` }
|
||||
if (got !== want) fails.push(`${q || '(no param)'} with html lang="${htmlLang}" -> "${got}", expected "${want}" (${why})`)
|
||||
}
|
||||
|
||||
// Every packet language must be reachable: the two lists cannot drift apart.
|
||||
const strings = JSON.parse(readFileSync(join(REPO, 'library/scripts/posters/packet-strings.json'), 'utf8'))
|
||||
const packetLangs = Object.keys(strings).filter((k) => !k.startsWith('_'))
|
||||
const unreachable = packetLangs.filter((l) => !SUPPORTED.includes(l))
|
||||
if (unreachable.length) {
|
||||
fails.push(`the packet ships ${unreachable.join(', ')} but SUPPORTED_LOCALES does not list them — `
|
||||
+ 'those translations cannot be selected')
|
||||
}
|
||||
|
||||
// The host-page path must survive: leaf pages still declare a lang.
|
||||
const leafRoot = join(REPO, 'public/doxa-maps')
|
||||
let leaves = 0
|
||||
let withLang = 0
|
||||
if (existsSync(leafRoot)) {
|
||||
for (const bundle of readdirSync(leafRoot)) {
|
||||
const bdir = join(leafRoot, bundle)
|
||||
let subs = []
|
||||
try { subs = readdirSync(bdir) } catch { continue }
|
||||
for (const sub of subs) {
|
||||
const page = join(bdir, sub, 'index.html')
|
||||
if (!existsSync(page)) continue
|
||||
leaves += 1
|
||||
if (/<html[^>]*\blang=/.test(readFileSync(page, 'utf8'))) withLang += 1
|
||||
}
|
||||
}
|
||||
}
|
||||
if (leaves && withLang !== leaves) {
|
||||
fails.push(`${leaves - withLang} of ${leaves} leaf pages declare no lang — `
|
||||
+ 'the host-page detection path is broken for them')
|
||||
}
|
||||
|
||||
console.log(` supported locales : ${SUPPORTED.length} (${SUPPORTED.join(' ')})`)
|
||||
console.log(` packet languages : ${packetLangs.length} (${packetLangs.join(' ')})`)
|
||||
console.log(` resolution cases : ${CASES.length - fails.filter((f) => f.includes('->')).length}/${CASES.length} correct`)
|
||||
console.log(` leaf pages w/ lang: ${withLang}/${leaves}`)
|
||||
|
||||
if (fails.length) {
|
||||
console.error(`\nLOCALE-REACHABLE: FAIL — ${fails.length} problem(s)`)
|
||||
for (const f of fails) console.error(' - ' + f)
|
||||
process.exit(1)
|
||||
}
|
||||
console.log(`\nLOCALE-REACHABLE: PASS — all ${packetLangs.length} packet languages are selectable.`)
|
||||
|
|
@ -8,7 +8,7 @@
|
|||
* discovered but MISSING .... 1 key-cities
|
||||
* in manifest, not real ..... 1 template-bundle
|
||||
* `key-cities` had its compiled key-cities.js on disk but ZERO profile leaf directories, so its four
|
||||
* profiles vanished from manifest.json and from the studio's tab list. The product owner was reviewing a tree
|
||||
* profiles vanished from manifest.json and from the studio's tab list. The agent was reviewing a tree
|
||||
* that silently omitted a whole plugin.
|
||||
*
|
||||
* ★ WHY EVERY EXISTING GATE PASSED — this is the interesting part, and the reason for a new file:
|
||||
|
|
|
|||
136
internal/check-maplibre-layer-drops.mjs
Normal file
136
internal/check-maplibre-layer-drops.mjs
Normal file
|
|
@ -0,0 +1,136 @@
|
|||
#!/usr/bin/env node
|
||||
/**
|
||||
* check-maplibre-layer-drops.mjs — MapLibre must not SILENTLY DROP a layer.
|
||||
*
|
||||
* THE DEFECT CLASS, found twice in one flight and invisible to every other gate:
|
||||
* MapLibre validates a layer spec and, if any property is unknown or misplaced, REJECTS THE WHOLE
|
||||
* LAYER. addLayer() does NOT throw — it accepts the call, emits an `error` EVENT on the map, and
|
||||
* silently omits the layer. getLayer() then returns undefined and the map renders without it. So
|
||||
* the symptom is "a thing is missing", with no exception, no failed request, and no console error
|
||||
* from the app's own code. Three ticks of instrumenting addLayer() found nothing, because the
|
||||
* failure never travels through addLayer's return path.
|
||||
*
|
||||
* MEASURED instances, both real and both shipped:
|
||||
* layers.regions-fill.paint.fill-emissive-strength: unknown property
|
||||
* -> *-emissive-strength is a Mapbox GL v3 LIGHTING property. The WAGF region fills were
|
||||
* absent on every MapLibre map while Mapbox showed them.
|
||||
* layers.apg-pins.paint.circle-sort-key: unknown property "circle-sort-key"
|
||||
* -> circle-sort-key is a LAYOUT property, written into paint. Mapbox tolerated the
|
||||
* misplacement; MapLibre dropped the layer, so ALL 12,379 pins on all-people-groups
|
||||
* vanished under the keyless engine.
|
||||
*
|
||||
* WHAT IT ASSERTS: load every isMap leaf in the built manifest with ?provider=maplibre, listen to
|
||||
* the map's own `error` event, and fail on any message in the layer/source-dropping family
|
||||
* (unknown property / does not exist / is required / Expected / invalid). Other errors are
|
||||
* REPORTED but not failed — a tile 404 is a different problem and should not be smuggled in here.
|
||||
*
|
||||
* ★ IT IS A REAL CONTROL, NOT DECORATION: run against the tree before the fixes it reports
|
||||
* 1 of 17 leaves dropping, and 0 of 17 after. It has been seen both red and green on the same
|
||||
* codebase, which is the only thing that makes a green meaningful.
|
||||
*
|
||||
* SKIPS CLEANLY (exit 0) when the server is unreachable. An UNMEASURED check is not a FAILED one —
|
||||
* check:filter-width prints "FAIL" when it cannot reach its server, and that is how a gate teaches
|
||||
* people to ignore it.
|
||||
*
|
||||
* Usage: bun run serve (or any server on BASE_URL), then:
|
||||
* node internal/check-maplibre-layer-drops.mjs
|
||||
* BASE_URL=http://127.0.0.1:4399 node internal/check-maplibre-layer-drops.mjs
|
||||
*/
|
||||
import { chromium } from 'playwright'
|
||||
import { readFileSync, existsSync } from 'node:fs'
|
||||
import { resolve, join, dirname } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
// Portable by construction — see check-no-absolute-paths.mjs. Never hardcode a worktree.
|
||||
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..')
|
||||
const BASE = String(process.env.BASE_URL || 'http://127.0.0.1:4173').replace(/\/+$/, '')
|
||||
const MANIFEST = join(ROOT, 'public', 'doxa-maps', 'manifest.json')
|
||||
|
||||
const DROP_RE = /unknown property|does not exist|is required|Expected|invalid/i
|
||||
|
||||
if (!existsSync(MANIFEST)) {
|
||||
console.log('MAPLIBRE-LAYER-DROPS: SKIP — no built manifest (run `bun run tree` first).')
|
||||
process.exit(0)
|
||||
}
|
||||
const manifest = JSON.parse(readFileSync(MANIFEST, 'utf8'))
|
||||
const leaves = []
|
||||
for (const b of manifest.bundles || []) {
|
||||
for (const p of b.profiles || []) if (p.isMap) leaves.push([b.bundle, p.profile])
|
||||
}
|
||||
if (!leaves.length) {
|
||||
console.log('MAPLIBRE-LAYER-DROPS: SKIP — the manifest declares no isMap leaf.')
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
// Capture the map's error events by wrapping whichever engine global the bundle loads at runtime.
|
||||
const INIT = () => {
|
||||
window.__mapErrs = []
|
||||
const wrap = (ns) => {
|
||||
if (!ns || ns.__wrappedForDrops) return ns
|
||||
const Orig = ns.Map
|
||||
function Patched(...a) {
|
||||
const m = new Orig(...a)
|
||||
try {
|
||||
m.on('error', (ev) => {
|
||||
const t = String((ev && ev.error && ev.error.message) || (ev && ev.message) || '')
|
||||
if (t) window.__mapErrs.push(t.slice(0, 160))
|
||||
})
|
||||
} catch (_) { /* an engine without .on must not break the page */ }
|
||||
return m
|
||||
}
|
||||
Patched.prototype = Orig.prototype
|
||||
ns.Map = Patched
|
||||
ns.__wrappedForDrops = true
|
||||
return ns
|
||||
}
|
||||
for (const name of ['maplibregl', 'mapboxgl']) {
|
||||
let v
|
||||
Object.defineProperty(window, name, {
|
||||
configurable: true, get() { return v }, set(nv) { v = wrap(nv) },
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`MAPLIBRE-LAYER-DROPS: ${leaves.length} isMap leaf/leaves at ${BASE}`)
|
||||
const browser = await chromium.launch({
|
||||
args: ['--use-gl=swiftshader', '--enable-unsafe-swiftshader', '--ignore-gpu-blocklist'],
|
||||
})
|
||||
const offenders = []
|
||||
let unreachable = 0
|
||||
|
||||
for (const [bundle, profile] of leaves) {
|
||||
const page = await browser.newPage({ viewport: { width: 1100, height: 760 } })
|
||||
await page.addInitScript(INIT)
|
||||
const url = `${BASE}/doxa-maps/${bundle}/${profile}/index.html?provider=maplibre`
|
||||
try {
|
||||
await page.goto(url, { waitUntil: 'networkidle', timeout: 60000 })
|
||||
await page.waitForTimeout(9000)
|
||||
const errs = await page.evaluate(() => [...new Set(window.__mapErrs || [])])
|
||||
const drops = errs.filter((e) => DROP_RE.test(e))
|
||||
if (drops.length) {
|
||||
offenders.push({ leaf: `${bundle}/${profile}`, drops })
|
||||
console.log(` ✗ ${bundle}/${profile} — ${drops[0]}`)
|
||||
} else {
|
||||
console.log(` ok ${bundle}/${profile}`)
|
||||
}
|
||||
} catch (e) {
|
||||
unreachable++
|
||||
console.log(` · ${bundle}/${profile} — UNMEASURED (${String(e.message).slice(0, 60)})`)
|
||||
}
|
||||
await page.close()
|
||||
}
|
||||
await browser.close()
|
||||
|
||||
if (unreachable === leaves.length) {
|
||||
console.log('MAPLIBRE-LAYER-DROPS: SKIP — nothing was reachable at ' + BASE + '. Start `bun run serve`.')
|
||||
process.exit(0)
|
||||
}
|
||||
if (offenders.length) {
|
||||
console.error(`\nMAPLIBRE-LAYER-DROPS: FAIL — ${offenders.length} leaf/leaves silently drop a layer under MapLibre.`)
|
||||
for (const o of offenders) for (const d of o.drops) console.error(` ${o.leaf}: ${d}`)
|
||||
console.error('\n MapLibre rejects a whole layer for ONE unknown or misplaced property, without throwing.')
|
||||
console.error(' Engine-only paint (e.g. *-emissive-strength) belongs behind emissivePaint(map, kind);')
|
||||
console.error(' layout properties (e.g. circle-sort-key) belong in `layout`, not `paint`.')
|
||||
process.exit(1)
|
||||
}
|
||||
console.log(`\nMAPLIBRE-LAYER-DROPS: PASS — ${leaves.length - unreachable} leaf/leaves, no layer dropped by MapLibre.`)
|
||||
91
internal/check-mfe-two-tab.mjs
Normal file
91
internal/check-mfe-two-tab.mjs
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
// check-mfe-two-tab — a two-tab MFE must actually MOUNT BOTH PANES and switch.
|
||||
//
|
||||
// The failure this catches is BUG-UPG100-NESTED-ELEMENTS-ZERO-HEIGHT: every layer
|
||||
// inside these composites is position:absolute/height:100%, so the whole chain
|
||||
// resolves to ZERO unless :host is sized. The header still renders and the tab
|
||||
// strip still paints — the element looks alive and every pane is 0px tall. That is
|
||||
// the "elements are not embedding" report, and a DOM-presence check passes it.
|
||||
// So this asserts MEASURED pane height, not mere existence.
|
||||
//
|
||||
// It also asserts ONE share affordance (two on one surface shipped once as
|
||||
// BUG-UPG100-DOUBLE-SHARE) and that clicking tab 2 actually changes the pressed
|
||||
// tab — a strip that paints but does not switch is the other silent half.
|
||||
//
|
||||
// Usage: node internal/check-mfe-two-tab.mjs <url> [expectedTabCount]
|
||||
import { chromium } from 'playwright'
|
||||
|
||||
const URL = process.argv[2]
|
||||
if (!URL) { console.error('usage: check-mfe-two-tab.mjs <url> [tabs]'); process.exit(2) }
|
||||
const WANT_TABS = Number(process.argv[3] || 2)
|
||||
|
||||
const b = await chromium.launch({ args: ['--use-gl=swiftshader', '--enable-unsafe-swiftshader'] })
|
||||
const pg = await b.newPage({ viewport: { width: 1280, height: 900 } })
|
||||
const errs = []
|
||||
pg.on('pageerror', (e) => errs.push(String(e).slice(0, 140)))
|
||||
await pg.goto(URL, { waitUntil: 'load', timeout: 45000 })
|
||||
await pg.waitForTimeout(9000)
|
||||
|
||||
// Everything lives in a shadow root, so reach through it explicitly.
|
||||
const r = await pg.evaluate(() => {
|
||||
// Find the host by SHADOW ROOT, not by rendered content: an element that
|
||||
// collapsed to 0px still HAS its shadow root, and reporting "not found" would
|
||||
// misdiagnose the zero-height collapse as a missing element. Measured — removing
|
||||
// :host produced exactly that misleading message.
|
||||
const host = [...document.querySelectorAll('*')].find((e) => e.shadowRoot
|
||||
&& (e.shadowRoot.querySelector('.viewsel, .pane-col, [role="tab"]')
|
||||
|| /mfe/i.test(e.tagName)))
|
||||
if (!host) return { error: 'no custom element with a shadow root found on this page' }
|
||||
const sr = host.shadowRoot
|
||||
// ⚠ Select the TABS, not their container. '.viewsel .seg' matches the tablist
|
||||
// WRAPPER as well as its buttons, which counted 3 tabs for a 2-tab strip and
|
||||
// concatenated their text ("Country PostersMap"). role=tab is the semantic
|
||||
// anchor ViewSelect already sets, so it cannot drift with the class names.
|
||||
const tabs = [...sr.querySelectorAll('[role="tab"]')]
|
||||
.map((t) => (t.textContent || '').trim()).filter(Boolean)
|
||||
const panes = [...sr.querySelectorAll('.pane')].map((p) => {
|
||||
const box = p.getBoundingClientRect()
|
||||
const mounted = !!p.querySelector('.pane-mount > *')
|
||||
return { h: Math.round(box.height), w: Math.round(box.width), mounted }
|
||||
})
|
||||
const shares = sr.querySelectorAll('[aria-label*="hare"], .share-btn, .sharebtn').length
|
||||
const hostBox = host.getBoundingClientRect()
|
||||
return { tabs, panes, shares, hostH: Math.round(hostBox.height) }
|
||||
})
|
||||
await b.close()
|
||||
|
||||
if (r.error) { console.error(`MFE-TWO-TAB: FAIL — ${r.error}`); process.exit(1) }
|
||||
|
||||
const fails = []
|
||||
if (r.tabs.length !== WANT_TABS) fails.push(`${r.tabs.length} tab(s) in the strip, expected ${WANT_TABS}`)
|
||||
// ★ THE HOST HEIGHT IS THE LOAD-BEARING ASSERTION, not the pane heights.
|
||||
// MEASURED: squashing the host to 0px leaves its panes still reporting 843px,
|
||||
// because they are position:absolute and escape the collapsed box. So a
|
||||
// pane-height check alone does NOT catch BUG-UPG100-NESTED-ELEMENTS-ZERO-HEIGHT —
|
||||
// the panes look fine while the element occupies no space and the reader sees
|
||||
// nothing. Only the host measurement reveals it.
|
||||
if (r.hostH < 100) {
|
||||
fails.push(`the host element measured ${r.hostH}px tall — :host is not sized, so the `
|
||||
+ 'element occupies no space even though its panes still measure normally')
|
||||
}
|
||||
if (r.panes.length !== WANT_TABS) fails.push(`${r.panes.length} pane(s) mounted, expected ${WANT_TABS}`)
|
||||
for (const [i, p] of r.panes.entries()) {
|
||||
if (!p.mounted) fails.push(`pane ${i} has no child element mounted`)
|
||||
// Kept as a secondary signal only — see the note on hostH above for why this
|
||||
// one cannot be trusted to catch the collapse on its own.
|
||||
if (p.h < 100) fails.push(`pane ${i} measured ${p.h}px tall`)
|
||||
}
|
||||
if (r.shares > 1) fails.push(`${r.shares} share affordances on one surface — exactly one belongs here`)
|
||||
if (errs.length) fails.push(`${errs.length} page error(s): ${errs[0]}`)
|
||||
|
||||
console.log(` host height : ${r.hostH}px`)
|
||||
console.log(` tabs : ${r.tabs.join(' | ') || '(none)'}`)
|
||||
console.log(` panes : ${r.panes.map((p) => `${p.w}x${p.h}${p.mounted ? '' : ' UNMOUNTED'}`).join(', ')}`)
|
||||
console.log(` share buttons : ${r.shares}`)
|
||||
console.log(` page errors : ${errs.length}`)
|
||||
|
||||
if (fails.length) {
|
||||
console.error(`\nMFE-TWO-TAB: FAIL — ${fails.length} problem(s)`)
|
||||
for (const f of fails) console.error(' - ' + f)
|
||||
process.exit(1)
|
||||
}
|
||||
console.log(`\nMFE-TWO-TAB: PASS — ${WANT_TABS} tabs, both panes mounted and sized, one share affordance.`)
|
||||
|
|
@ -4,7 +4,7 @@
|
|||
*
|
||||
* THE BUG THIS EXISTS TO PREVENT (real, found 2026-09-22 by resizing the cap-5 SPA to 414px).
|
||||
* VerticalTabsLayout renders its drawer toggle inside `<header v-if="header">`. The cap-5 DX-Maps
|
||||
* SPA runs header-OFF by default (the product owner's no-stacked-headers rule for embedding). At mobile
|
||||
* SPA runs header-OFF by default (the agent's no-stacked-headers rule for embedding). At mobile
|
||||
* width the rail becomes an off-canvas drawer at translateX(-100%) with navOpen=false. So: the
|
||||
* drawer was off screen, the header holding the toggle did not exist, and the only other control
|
||||
* (.vtl-collapse) lives INSIDE the drawer — measured off screen at x:-244.
|
||||
|
|
|
|||
|
|
@ -3,10 +3,10 @@
|
|||
* check-no-orphan-gates.mjs — a gate that nothing can run is not a gate.
|
||||
*
|
||||
* THE DEFECT THIS CATCHES. `internal/verify-shell-scroll.mjs` was written, negative-tested, and
|
||||
* proved a real product owner-reported bug with hard numbers (2586px of mounted grid, a real wheel gesture
|
||||
* proved a real agent-reported bug with hard numbers (2586px of mounted grid, a real wheel gesture
|
||||
* moving 0px). Then it sat unreachable: no `package.json` entry, no importer. It would never have
|
||||
* run again. The repo has already carded this once —
|
||||
* a tracked issue — and it recurred anyway, because
|
||||
* BUILD-BROWSER-GATES-WERE-ALL-ORPHANS-ONE-COMMAND-RUNS-THEM — and it recurred anyway, because
|
||||
* writing a gate and REGISTERING a gate are two separate acts and only the first feels like work.
|
||||
*
|
||||
* ★ THE DISTINCTION THIS GATE HAS TO GET RIGHT, or it becomes noise nobody reads: not every
|
||||
|
|
@ -101,12 +101,45 @@ if (orphans.length) {
|
|||
console.error(' array of internal/verify-browser-all.mjs so the one-command suite runs it);')
|
||||
console.error(' 2. or add `@one-shot` to its header if it was a diagnostic for a single bug and')
|
||||
console.error(' is kept only as a reproduction recipe.')
|
||||
console.error('\n This already happened once (tracked issue). It recurs')
|
||||
console.error('\n This already happened once (card BUILD-BROWSER-GATES-WERE-ALL-ORPHANS). It recurs')
|
||||
console.error(' because writing a gate and registering it are separate acts, and only the first')
|
||||
console.error(' one feels like the work.')
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
// ── SECOND, DISTINCT QUESTION: does it RUN? ────────────────────────────────
|
||||
// Reachable and wired are not the same property, and conflating them is how 70 of
|
||||
// 100 check:* scripts came to sit outside every gate wave without anyone noticing —
|
||||
// including this gate itself. A script entry means a human CAN run it; membership in
|
||||
// gates:pre/gates:post means it runs on every build.
|
||||
//
|
||||
// This REPORTS rather than fails, deliberately. Most unwired gates are correctly
|
||||
// hand-run: they need a served tree (check:mobile, check:shots), a device, a token,
|
||||
// or an argument (check:packet-render takes a path). Failing the build on those
|
||||
// would be wrong and the gate would be disabled within a day.
|
||||
//
|
||||
// ⚠ AND SOME MUST NEVER BE WIRED, which "it passes when I run it" cannot tell you.
|
||||
// check:settled asserts the tree is QUIET — no builder running, no recent churn — so
|
||||
// it fails inside a build BY DESIGN. Measured 2026-09-24: it passed twice standalone,
|
||||
// was wired on that evidence, and failed the very next build (29/30). The test for
|
||||
// wave membership is not "does it pass" but "is its assertion compatible with running
|
||||
// mid-build". What it must not do is
|
||||
// stay silent — a gate that could run and does not is indistinguishable from one
|
||||
// that is broken, which is the exact failure verify-shell-scroll had.
|
||||
const pkg = JSON.parse(pkgRaw)
|
||||
const waves = `${pkg.scripts['gates:pre'] || ''} ${pkg.scripts['gates:post'] || ''}`
|
||||
const checks = Object.keys(pkg.scripts).filter((k) => /^(check|verify|smoke):/.test(k)
|
||||
&& !k.endsWith(':self-test'))
|
||||
const unwired = checks.filter((k) => !waves.includes(k))
|
||||
console.log(` wave membership : ${checks.length - unwired.length}/${checks.length} `
|
||||
+ `check/verify script(s) run in gates:pre or gates:post`)
|
||||
if (unwired.length) {
|
||||
console.log(` ${unwired.length} run only by hand — expected for gates needing a served tree, a`)
|
||||
console.log(' device, a token or an argument. If one of these needs no precondition, wire it:')
|
||||
for (const k of unwired.slice(0, 8)) console.log(` · ${k}`)
|
||||
if (unwired.length > 8) console.log(` …and ${unwired.length - 8} more`)
|
||||
}
|
||||
|
||||
console.log(`NO-ORPHAN-GATES: PASS — ${checked} gate-shaped file(s); all reachable (${tagged} tagged @one-shot).`)
|
||||
console.log(' Reachable means named in package.json or imported by something that is. The @one-shot')
|
||||
console.log(' tag exists so a dated diagnostic can be KEPT as a reproduction recipe without either')
|
||||
|
|
|
|||
|
|
@ -15,8 +15,8 @@
|
|||
* instructions to an agent. The actual behaviour lived in a FLAG in package.json. Prose cannot
|
||||
* constrain a flag. The rule was never violated by a decision — it was violated by a default that
|
||||
* nobody re-read, and it stayed violated because nothing ever asked the question out loud. The
|
||||
* product owner stated it plainly: "we don't need any open ports on this box because you push your changes
|
||||
* to the forge and I pull them from my laptop... use Playwright for yourself internally for testing,
|
||||
* agent stated it plainly: "we don't need any open ports on this box because you push your changes
|
||||
* to the git forge and I pull them from my laptop... use Playwright for yourself internally for testing,
|
||||
* not for public access."
|
||||
*
|
||||
* Loopback is sufficient for the entire test suite: verify-browser-all, check:a11y-button-names,
|
||||
|
|
@ -82,7 +82,7 @@ if (errs.length) {
|
|||
console.error('NO-PUBLIC-BINDS: FAIL — a repo script would open a port to the network.\n')
|
||||
errs.forEach((e) => console.error(e))
|
||||
console.error('\n The whole test suite reaches its server over 127.0.0.1. Nothing here needs a public')
|
||||
console.error(' bind: code moves between machines through the forge, not through an open port. Use')
|
||||
console.error(' bind: code moves between machines through the git forge, not through an open port. Use')
|
||||
console.error(' --bind 127.0.0.1, and pass BASE_URL/BASE if some caller genuinely needs another host.')
|
||||
process.exit(1)
|
||||
}
|
||||
|
|
|
|||
146
internal/check-packet-browser.mjs
Normal file
146
internal/check-packet-browser.mjs
Normal file
|
|
@ -0,0 +1,146 @@
|
|||
// check-packet-browser — the packet the READER gets, not the one Node writes.
|
||||
//
|
||||
// WHY A SECOND PACKET GATE. check-packet-render verifies a packet FILE produced by
|
||||
// the build-time generator. This one verifies the BROWSER path: the legend's
|
||||
// Export > Prayer packet action imports library/packet/packet-render.js, fetches
|
||||
// live records, and prints in-place. Both callers share that one renderer, and this
|
||||
// gate is what proves the shared module actually runs in a browser — a module can
|
||||
// be perfectly correct under Node and still fail in a tab (a bare `process.env`
|
||||
// reference, a Node-only import, a JSON import assertion).
|
||||
//
|
||||
// It also covers I18N, which is a product requirement rather than a nicety: pass a
|
||||
// language and the gate reports which headings came back translated. Fallback is
|
||||
// PER KEY, so a half-finished translation must render its finished keys and leave
|
||||
// the rest in English — never blank.
|
||||
//
|
||||
// Usage: node internal/check-packet-browser.mjs [lang]
|
||||
// PACKET_ORIGIN http origin serving the repo root (default http://127.0.0.1:4898)
|
||||
// PACKET_API records API (default https://pray.doxa.life)
|
||||
// PACKET_ISO3 country to build a test packet for (default LAO)
|
||||
// Needs a static server over the repo root, e.g.: python3 -m http.server 4898
|
||||
|
||||
import { chromium } from 'playwright'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { resolve, dirname } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
const b = await chromium.launch()
|
||||
const pg = await b.newPage({ viewport:{width:1500,height:1941} })
|
||||
const errs=[]; pg.on('pageerror',e=>errs.push(String(e).slice(0,140)))
|
||||
|
||||
const ORIGIN = process.env.PACKET_ORIGIN || 'http://127.0.0.1:4898'
|
||||
const API = process.env.PACKET_API || 'https://pray.doxa.life'
|
||||
const ISO3 = process.env.PACKET_ISO3 || 'LAO'
|
||||
const RENDER = `${ORIGIN}/library/packet/packet-render.js`
|
||||
const STRINGS = `${ORIGIN}/library/scripts/posters/packet-strings.json`
|
||||
// The SAME catalog, read from disk for the assertions below. STRINGS is the URL the
|
||||
// browser fetches; this is the local path, so the gate compares against the file the
|
||||
// page actually loaded rather than a second copy.
|
||||
const STRINGS_PATH = resolve(dirname(fileURLToPath(import.meta.url)),
|
||||
'..', 'library/scripts/posters/packet-strings.json')
|
||||
const LANG = process.argv[2] || 'en'
|
||||
|
||||
await pg.goto(ORIGIN + '/')
|
||||
const html = await pg.evaluate(async ({RENDER, STRINGS, LANG, ORIGIN, API, ISO3}) => {
|
||||
const [{ createPacketRenderer }, strings, norm] = await Promise.all([
|
||||
import(RENDER),
|
||||
fetch(STRINGS).then(r=>r.json()),
|
||||
import(`${ORIGIN}/plugin-designs/my-upg-100-list/data/normalize.js`),
|
||||
])
|
||||
const R = createPacketRenderer({ strings, lang: LANG })
|
||||
const resp = await fetch(`${API}/api/people-groups/list?fields=slug,name,country_code,population,religion,image_url&lang=en`, {cache:'no-store'})
|
||||
// Use the SAME normalizer the app uses — the API returns { posts, total }, and
|
||||
// hand-rolling the extraction is how a probe ends up testing something the
|
||||
// product never does.
|
||||
const recs = norm.normalizeRecords(norm.extractRecords(await resp.json()))
|
||||
const groups = recs.filter(r=>String(r.country_code)===ISO3)
|
||||
.sort((a,b)=>String(a.name).localeCompare(String(b.name), LANG))
|
||||
const photos = new Map(groups.filter(g=>g.image_url).map(g=>[g.slug,g.image_url]))
|
||||
return R.buildPacket({id:ISO3.toLowerCase(),label:ISO3}, groups, photos, '')
|
||||
}, {RENDER, STRINGS, LANG, ORIGIN, API, ISO3})
|
||||
|
||||
await pg.setContent(html, { waitUntil:'load' })
|
||||
await pg.waitForTimeout(3000)
|
||||
// ⚠ READ THE HEADINGS BY STRUCTURE, NOT BY ENGLISH WORDS. These were greps for
|
||||
// /Wheel/ and /Prayer Points|Puntos/, so they returned EMPTY for every language
|
||||
// whose heading contains neither — Spanish reported a blank wheel heading and
|
||||
// Hindi blanked BOTH, while the catalog holds 'Rueda de Oración' and
|
||||
// 'प्रार्थना चक्र'. Nothing asserted them, so the gate PASSED on what looked
|
||||
// exactly like a product i18n failure. It was the harness. The renderer marks
|
||||
// these two blocks, so select them and compare to the catalog below.
|
||||
const r = await pg.evaluate(()=>({
|
||||
sheets: document.querySelectorAll('.sheet').length,
|
||||
cards: document.querySelectorAll('.card').length,
|
||||
wheel: document.querySelectorAll('svg path[fill^="hsl"]').length,
|
||||
// The passage block, not the English proper noun 'Gadarenes': that word would
|
||||
// vanish the moment point4_passage is translated, so the assertion would go RED
|
||||
// on a CORRECTLY translated packet (the same trap already fixed in
|
||||
// check-packet-render).
|
||||
mark5Len: (document.querySelector('.p4-passage')||{textContent:''}).textContent.trim().length,
|
||||
metacamp: (document.body.innerText.match(/metacamp\.org/g)||[]).length,
|
||||
firstCard: (document.querySelector('.card')||{}).innerText||'',
|
||||
wheelTitle: (document.querySelector('.wheel-title')||{textContent:''}).textContent.trim(),
|
||||
pointsTitle: (document.querySelector('.points-title')||{textContent:''}).textContent.trim(),
|
||||
}))
|
||||
const pdf = await pg.pdf({ preferCSSPageSize:true })
|
||||
const t = pdf.toString('latin1')
|
||||
const pages=(t.match(/\/Type\s*\/Page[^s]/g)||[]).length
|
||||
const boxes=[...t.matchAll(/MediaBox\s*\[\s*0\s+0\s+([\d.]+)\s+([\d.]+)\s*\]/g)].map(m=>[+m[1],+m[2]])
|
||||
await b.close()
|
||||
console.log(` lang : ${LANG}`)
|
||||
console.log(` sheets/PDF pages : ${r.sheets} / ${pages}`)
|
||||
console.log(` people cards : ${r.cards}`)
|
||||
console.log(` wheel segments : ${r.wheel}`)
|
||||
console.log(` Mark 5 in full : ${r.mark5}`)
|
||||
console.log(` metacamp.org : ${r.metacamp}`)
|
||||
console.log(` page size : ${boxes[0]?boxes[0].join(' x '):'?'} pt`)
|
||||
console.log(` points heading : ${r.pointsTitle.trim()}`)
|
||||
console.log(` wheel heading : ${r.wheelTitle.trim()}`)
|
||||
console.log(` page errors : ${errs.length} ${errs[0]||''}`)
|
||||
// ── NAMED ASSERTIONS, NOT ONE COLLAPSED BOOLEAN ─────────────────────────────
|
||||
// This was `const bad = a || b || c || ...`, so a failure printed "FAIL" without
|
||||
// saying WHICH of six conditions tripped. Three further metrics were printed and
|
||||
// never checked at all: page size, and the two headings — which are the very
|
||||
// strings this gate's own header says it covers ("the gate reports which headings
|
||||
// came back translated"). Reports, not asserts.
|
||||
const fails = []
|
||||
if (!r.sheets) fails.push('no .sheet elements — the browser packet did not render')
|
||||
if (pages !== r.sheets) fails.push(`${pages} PDF pages for ${r.sheets} sheets — one sheet must be one page`)
|
||||
if (!r.cards) fails.push('no people-group cards')
|
||||
if (r.wheel !== 12) fails.push(`prayer wheel has ${r.wheel} segments, expected 12`)
|
||||
if (r.metacamp !== 2) fails.push(`${r.metacamp} metacamp.org attributions, expected exactly 2`)
|
||||
// Mark 5:1-20 in full, by LENGTH rather than an English proper noun.
|
||||
if (r.mark5Len < 1200) {
|
||||
fails.push(r.mark5Len === 0
|
||||
? 'Mark 5:1-20 is NOT printed (no .p4-passage block)'
|
||||
: `Mark 5:1-20 looks truncated: ${r.mark5Len} chars`)
|
||||
}
|
||||
// PAGE SIZE was printed and never compared. `@page{size:letter portrait}` is
|
||||
// 612x792pt; a wrong PORTRAIT size (A4 is 595x842) is taller-than-wide too, so
|
||||
// nothing else here would notice it.
|
||||
if (!boxes.length) fails.push('the PDF declares no MediaBox — page geometry unreadable')
|
||||
else {
|
||||
const wrong = boxes.filter(([w,h]) => Math.abs(w-612)>1 || Math.abs(h-792)>1)
|
||||
if (wrong.length) fails.push(`${wrong.length}/${boxes.length} page(s) are ${wrong[0].join(' x ')}pt, expected 612 x 792pt`)
|
||||
}
|
||||
// ── I18N, ASSERTED AGAINST THE CATALOG ──────────────────────────────────────
|
||||
// The heading a reader sees must equal the catalog entry for THIS packet's
|
||||
// language — per-key fallback to English when the key is absent, exactly as the
|
||||
// renderer resolves it. Never a hardcoded expectation, which is what made these
|
||||
// two unassertable in the first place.
|
||||
const cat = JSON.parse(readFileSync(STRINGS_PATH, 'utf8'))
|
||||
const expect = (key) => ((cat[LANG]||{})[key] ?? cat.en[key])
|
||||
for (const [sel, key, got] of [['points-title','points_title',r.pointsTitle],
|
||||
['wheel-title','wheel_title',r.wheelTitle]]) {
|
||||
const want = expect(key)
|
||||
if (!got) fails.push(`.${sel} is EMPTY — a blank heading is worse than an English one`)
|
||||
else if (got !== want) fails.push(`.${sel} reads ${JSON.stringify(got)}, catalog says ${JSON.stringify(want)}`)
|
||||
}
|
||||
if (errs.length) fails.push(`${errs.length} page error(s): ${errs[0]}`)
|
||||
|
||||
if (fails.length) {
|
||||
console.error(`\nPACKET-BROWSER: FAIL — ${fails.length} problem(s)`)
|
||||
for (const f of fails) console.error(' - ' + f)
|
||||
process.exit(1)
|
||||
}
|
||||
console.log('\nPACKET-BROWSER: PASS — the browser-rendered packet is correct, and its headings '
|
||||
+ `match the ${LANG} catalog.`)
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue