@bongos/core 1.24.32 → 1.24.34
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.bongos-core.json +44 -19
- package/docs/copy-inventory.md +7 -7
- package/docs/copy-registry.json +7 -7
- package/docs/module-api-changelog.md +4 -0
- package/docs/page-readings.json +264 -264
- package/docs/recipes/hall-sitemap.md +52 -0
- package/modules/hall-ui/public/craft-mode.js +67 -5
- package/modules/hall-ui/public/style.css +23 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/release-notes.json +16 -0
- package/scripts/bongos/gen-hall-sitemap.js +368 -0
- package/scripts/gds/gen-hall-sitemap.js +7 -0
- package/src/module-api.js +1 -1
- package/tests/craft_mode_home.mjs +114 -0
- package/tests/craft_mode_looks.mjs +16 -6
- package/tests/hall_sitemap.mjs +70 -0
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# The builders hall sitemap — reading, regenerating and auditing it
|
|
2
|
+
|
|
3
|
+
[`docs/hall-sitemap.md`](../hall-sitemap.md) is the whole hall on one page: every page, where the sidebar lists it, who may open it, which crafts show it, which crafts can click their way to it, and what links in and out. It is **generated** (task 1005068), so it is never edited by hand and never goes stale quietly.
|
|
4
|
+
|
|
5
|
+
## Where it comes from
|
|
6
|
+
|
|
7
|
+
`node scripts/bongos/gen-hall-sitemap.js` reads the real sources and nothing hand-kept:
|
|
8
|
+
|
|
9
|
+
| What | Read from |
|
|
10
|
+
|---|---|
|
|
11
|
+
| The pages | every `modules/hall-ui/public/*.html` (`index.html` is Home, at `/`) |
|
|
12
|
+
| The sidebar | `shell.js` `NAV` — group, label, link, and the cosmetic nav gate |
|
|
13
|
+
| The crafts | `craft-mode.js`, **run**, asked `tabsFor(craft)` — the same answer the hall uses |
|
|
14
|
+
| Who may open a page | `src/bongos/serve-internal.js` page gates (the parse `tests/hall_page_gate_map.mjs` uses) |
|
|
15
|
+
| The links | every in-hall link in a page's HTML and in each script it loads |
|
|
16
|
+
|
|
17
|
+
Scripts nearly every page loads (the shell, the palette, the craft switch) are the *chrome*: their links are the sidebar, so they are not counted against each page.
|
|
18
|
+
|
|
19
|
+
## When to regenerate
|
|
20
|
+
|
|
21
|
+
Whenever you add, remove or rename a hall page, a nav item, a craft's tabs, or a link between pages:
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
node scripts/bongos/gen-hall-sitemap.js
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
Commit the changed `docs/hall-sitemap.md` with your change. `tests/hall_sitemap.mjs` fails until you do.
|
|
28
|
+
|
|
29
|
+
## Auditing it
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
node scripts/bongos/gen-hall-sitemap.js --audit
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
prints one line per finding and exits 1 if any link is broken. What each finding asks of you:
|
|
36
|
+
|
|
37
|
+
| Finding | Means | Do |
|
|
38
|
+
|---|---|---|
|
|
39
|
+
| **broken** | a link to a URL the hall does not serve | fix the link; if the server really does serve it (a redirect, a `/x/:id` route), add it to `ROUTES` in the generator with where it is served |
|
|
40
|
+
| **orphan** | a page with no nav item that no page links to | link it from where a reader would look for it, or retire the page |
|
|
41
|
+
| **unclickable** | no craft reaches the page by clicking from its sidebar | it is only found by typing its URL — usually an orphan's cousin |
|
|
42
|
+
| **hidden** | a nav item no craft lists | only the hall with no craft chosen shows it; add it to a craft in `craft-mode.js` `MODE_TABS`, or to `ALWAYS_TABS` |
|
|
43
|
+
| **dangling** | a craft lists a tab id with no nav item | the craft's list has drifted from `shell.js` `NAV`; fix the id |
|
|
44
|
+
|
|
45
|
+
Broken and dangling fail the test. The other three are judgement calls, which is why they are reported rather than enforced: read them on each regenerate and decide.
|
|
46
|
+
|
|
47
|
+
## Reading it for "does this make sense?"
|
|
48
|
+
|
|
49
|
+
- **The crafts table** is each craft's slice of the hall and its Home. Choosing a craft on a page only other crafts show goes to that craft's Home (task 1005067).
|
|
50
|
+
- **Shown in vs reach** on a page row: *shown in* is the crafts whose sidebar lists it; *reach* adds the crafts that get there through a link. A page reached by every craft but shown by one is a page the others find only by accident.
|
|
51
|
+
- **Nav gate vs server gate**: the nav gate only hides the item; the server gate is the wall. A page whose nav gate is narrower than its server gate is visible to more people than the sidebar suggests; `null` on the server means the shell is served to anyone and the data behind it holds the line.
|
|
52
|
+
- **In 0** beside a nav item means the sidebar is the only way there.
|
|
@@ -209,6 +209,55 @@
|
|
|
209
209
|
else head.removeAttribute('data-craft-off');
|
|
210
210
|
}
|
|
211
211
|
}
|
|
212
|
+
// ---------- each craft's home (task 1005067) ----------
|
|
213
|
+
// Choosing a craft used to re-skin the page you were on and leave you there, so a
|
|
214
|
+
// governor on the city who picked Building stayed in the governor's city under the
|
|
215
|
+
// builder's nav (owner, 2026-11-04). Now a page that belongs ONLY to other crafts —
|
|
216
|
+
// in their tabs and not the chosen one's — is left for Home, which draws each
|
|
217
|
+
// craft's own dashboard (home-city.js: the city, the gallery, the sky, and the
|
|
218
|
+
// street for Governing, its dashboard for now). A shared page (ALWAYS_TABS, or one
|
|
219
|
+
// in both crafts' lists) and a page no craft owns (a task, an idea) stay put; so
|
|
220
|
+
// does Home, which redraws itself in place. The street has no nav item, so it is
|
|
221
|
+
// named here as Governing's. Tests swap the hop for a recorder (OTBCraftHop).
|
|
222
|
+
var OWN_PAGES = { governing: ['street'] };
|
|
223
|
+
function pageKeys() {
|
|
224
|
+
var keys = [];
|
|
225
|
+
var nav = document.getElementById('app-sidebar');
|
|
226
|
+
var active = nav && nav.querySelector ? nav.querySelector('.nav-item--active') : null;
|
|
227
|
+
if (active && active.id) keys.push(String(active.id).replace(/^nav-/, ''));
|
|
228
|
+
var b = document.body;
|
|
229
|
+
var page = b && b.getAttribute ? b.getAttribute('data-page') : null;
|
|
230
|
+
if (page) keys.push(page);
|
|
231
|
+
return keys;
|
|
232
|
+
}
|
|
233
|
+
function ownersOf(key) {
|
|
234
|
+
if (ALWAYS_TABS.indexOf(key) !== -1) return null;
|
|
235
|
+
var owners = [];
|
|
236
|
+
IDS.forEach(function (m) {
|
|
237
|
+
if ((MODE_TABS[m] || []).indexOf(key) !== -1 || (OWN_PAGES[m] || []).indexOf(key) !== -1) owners.push(m);
|
|
238
|
+
});
|
|
239
|
+
return owners.length ? owners : null;
|
|
240
|
+
}
|
|
241
|
+
// Does choosing `mode` strand the reader on another craft's page?
|
|
242
|
+
function strandedBy(mode) {
|
|
243
|
+
var keys = pageKeys();
|
|
244
|
+
if (keys.indexOf('home') !== -1) return false;
|
|
245
|
+
for (var i = 0; i < keys.length; i++) {
|
|
246
|
+
var owners = ownersOf(keys[i]);
|
|
247
|
+
if (owners) return owners.indexOf(mode) === -1;
|
|
248
|
+
}
|
|
249
|
+
return false;
|
|
250
|
+
}
|
|
251
|
+
function homeHref(mode) {
|
|
252
|
+
var street = mode === 'governing' && window.GovCity && window.GovCity.STREET_HREF;
|
|
253
|
+
return street || '/';
|
|
254
|
+
}
|
|
255
|
+
function goHome(mode) {
|
|
256
|
+
if (!strandedBy(mode)) return false;
|
|
257
|
+
(window.OTBCraftHop || function (h) { if (window.location) window.location.assign(h); })(homeHref(mode));
|
|
258
|
+
return true;
|
|
259
|
+
}
|
|
260
|
+
|
|
212
261
|
// ---------- each mode's look (task 1004495) ----------
|
|
213
262
|
// Artistry, Ideating and Governing each wear a look: the colour tokens in
|
|
214
263
|
// style.css (keyed on html[data-craft]), plus two things only this script can
|
|
@@ -216,20 +265,29 @@
|
|
|
216
265
|
// under the brand (artHtml, the picker's own art, so the two never drift).
|
|
217
266
|
// Building and no mode have no entry here and wear nothing.
|
|
218
267
|
var LOOKS = { artistry: 'the studio · night', ideating: 'the sky', governing: 'the city' };
|
|
268
|
+
// The plate is the Home button in every mode (owner, 2026-11-04, task 1005065): the
|
|
269
|
+
// mode's scene with a home mark over it. Building wears no look, so its plate shows
|
|
270
|
+
// the city, the scene its Home is.
|
|
271
|
+
var PLATE_ART = { building: 'governing', artistry: 'artistry', ideating: 'ideating', governing: 'governing' };
|
|
272
|
+
var HOME_MARK = '<span class="craft-plate__home" aria-hidden="true"><svg viewBox="0 0 18 18" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linejoin="round">' +
|
|
273
|
+
'<path d="M3 8.5 9 3l6 5.5V15a1 1 0 0 1-1 1h-3.5v-4h-3v4H4a1 1 0 0 1-1-1V8.5Z"/></svg></span>';
|
|
219
274
|
function remove(el) { if (el && el.parentNode) el.parentNode.removeChild(el); }
|
|
220
275
|
function applyLook(mode) {
|
|
221
276
|
var look = Object.prototype.hasOwnProperty.call(LOOKS, mode) ? LOOKS[mode] : null;
|
|
277
|
+
var art = Object.prototype.hasOwnProperty.call(PLATE_ART, mode) ? PLATE_ART[mode] : null;
|
|
222
278
|
var plate = document.getElementById('craft-plate');
|
|
223
|
-
if (plate && plate.getAttribute('data-plate') !== (
|
|
279
|
+
if (plate && plate.getAttribute('data-plate') !== (art ? mode : null)) { remove(plate); plate = null; }
|
|
224
280
|
var nav = document.getElementById('app-sidebar');
|
|
225
|
-
var brand =
|
|
281
|
+
var brand = art && nav && nav.querySelector ? nav.querySelector('.app-sidebar__brand') : null;
|
|
226
282
|
if (!plate && brand && brand.parentNode) {
|
|
227
|
-
plate = document.createElement('
|
|
283
|
+
plate = document.createElement('a');
|
|
228
284
|
plate.id = 'craft-plate';
|
|
229
285
|
plate.className = 'craft-plate';
|
|
286
|
+
plate.setAttribute('href', '/');
|
|
287
|
+
plate.setAttribute('aria-label', 'Home');
|
|
288
|
+
plate.setAttribute('title', 'Home');
|
|
230
289
|
plate.setAttribute('data-plate', mode);
|
|
231
|
-
plate.
|
|
232
|
-
plate.innerHTML = artHtml(mode);
|
|
290
|
+
plate.innerHTML = artHtml(art) + HOME_MARK;
|
|
233
291
|
brand.parentNode.insertBefore(plate, brand.nextSibling);
|
|
234
292
|
}
|
|
235
293
|
var mark = document.getElementById('craft-eyebrow');
|
|
@@ -570,6 +628,7 @@
|
|
|
570
628
|
else if (keep === false) drop('localStorage', KEEP_KEY);
|
|
571
629
|
else if (get('localStorage', KEEP_KEY)) set('localStorage', KEEP_KEY, mode);
|
|
572
630
|
apply(mode);
|
|
631
|
+
goHome(mode);
|
|
573
632
|
}
|
|
574
633
|
|
|
575
634
|
// ---------- the header switch ----------
|
|
@@ -790,6 +849,9 @@
|
|
|
790
849
|
ALWAYS_TABS: ALWAYS_TABS.slice(),
|
|
791
850
|
tabsFor: tabsFor,
|
|
792
851
|
applyNav: applyNav,
|
|
852
|
+
// task 1005067: whether choosing a craft leaves this page for its Home, and where Home is.
|
|
853
|
+
strandedBy: strandedBy,
|
|
854
|
+
homeHref: homeHref,
|
|
793
855
|
LOOKS: LOOKS,
|
|
794
856
|
artHtml: artHtml,
|
|
795
857
|
tallyHtml: tallyHtml,
|
|
@@ -1614,6 +1614,9 @@ html:is([data-craft="artistry"], [data-craft="ideating"], [data-craft="governing
|
|
|
1614
1614
|
never redrawn: each scene's pieces are placed from its edges or its centre. */
|
|
1615
1615
|
.craft-plate {
|
|
1616
1616
|
position: relative;
|
|
1617
|
+
display: block;
|
|
1618
|
+
color: inherit;
|
|
1619
|
+
text-decoration: none;
|
|
1617
1620
|
height: 40px;
|
|
1618
1621
|
margin: 10px 8px 0;
|
|
1619
1622
|
border-radius: var(--radius-sm);
|
|
@@ -1631,6 +1634,26 @@ html:is([data-craft="artistry"], [data-craft="ideating"], [data-craft="governing
|
|
|
1631
1634
|
transform-origin: 0 0;
|
|
1632
1635
|
}
|
|
1633
1636
|
.craft-plate .craft-art__city { top: 0; }
|
|
1637
|
+
/* At the plate's size the studio's houses vanished into the night (owner, 2026-11-04):
|
|
1638
|
+
lift them off the sky so the scene reads. The picker's larger door is unchanged. */
|
|
1639
|
+
.craft-plate .craft-art__house { background: #232b4d; }
|
|
1640
|
+
/* The plate is the Home button (task 1005065): a home mark sits over the scene. */
|
|
1641
|
+
.craft-plate__home {
|
|
1642
|
+
position: absolute;
|
|
1643
|
+
left: 50%;
|
|
1644
|
+
top: 50%;
|
|
1645
|
+
width: 24px;
|
|
1646
|
+
height: 24px;
|
|
1647
|
+
margin: -12px 0 0 -12px;
|
|
1648
|
+
display: grid;
|
|
1649
|
+
place-items: center;
|
|
1650
|
+
border-radius: 999px;
|
|
1651
|
+
color: #fff;
|
|
1652
|
+
background: var(--scrim);
|
|
1653
|
+
}
|
|
1654
|
+
.craft-plate__home svg { width: 14px; height: 14px; }
|
|
1655
|
+
.craft-plate:hover .craft-plate__home { background: rgba(0, 0, 0, .8); }
|
|
1656
|
+
.craft-plate:focus-visible { outline: 2px solid var(--focus); outline-offset: 2px; }
|
|
1634
1657
|
.app-sidebar:hover .craft-plate,
|
|
1635
1658
|
.app-sidebar:focus-within .craft-plate { height: 64px; margin: 10px 16px 0; }
|
|
1636
1659
|
.app-sidebar:hover .craft-plate .craft-art,
|
package/package-lock.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.24.
|
|
3
|
+
"version": "1.24.34",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "@bongos/core",
|
|
9
|
-
"version": "1.24.
|
|
9
|
+
"version": "1.24.34",
|
|
10
10
|
"license": "AGPL-3.0-or-later",
|
|
11
11
|
"dependencies": {
|
|
12
12
|
"express": "^4.21.2",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.24.
|
|
3
|
+
"version": "1.24.34",
|
|
4
4
|
"description": "Cloud Bongos — the AI-first build platform core (GDS + platform surfaces + module system), installed as a versioned dependency (ADR 0108).",
|
|
5
5
|
"license": "AGPL-3.0-or-later",
|
|
6
6
|
"main": "src/platform-server.js",
|
package/release-notes.json
CHANGED
|
@@ -11151,5 +11151,21 @@
|
|
|
11151
11151
|
"id": "1005064",
|
|
11152
11152
|
"text": "The governor's Home is now the street: choosing Owner on Home takes you straight to it, so each of the four crafts has its own Home."
|
|
11153
11153
|
}
|
|
11154
|
+
],
|
|
11155
|
+
"1.24.33": [
|
|
11156
|
+
{
|
|
11157
|
+
"id": "1005065",
|
|
11158
|
+
"text": "The small picture at the top of the sidebar is now the Home button in every mode, with a home icon on it."
|
|
11159
|
+
}
|
|
11160
|
+
],
|
|
11161
|
+
"1.24.34": [
|
|
11162
|
+
{
|
|
11163
|
+
"id": "1005067",
|
|
11164
|
+
"text": "Switching mode now takes you to that mode's own home page instead of leaving you stuck on the governor's city."
|
|
11165
|
+
},
|
|
11166
|
+
{
|
|
11167
|
+
"id": "1005068",
|
|
11168
|
+
"text": "The builders hall now has a generated map of every page and link, with a check that flags broken links and pages nobody can reach."
|
|
11169
|
+
}
|
|
11154
11170
|
]
|
|
11155
11171
|
}
|
|
@@ -0,0 +1,368 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
// gen-hall-sitemap.js — the builders hall's sitemap, generated, and the audit of its links
|
|
4
|
+
// (task 1005068, goal 1000095).
|
|
5
|
+
//
|
|
6
|
+
// WHY. The hall is ~70 pages, a nav grouped by audience (shell.js NAV), four crafts that each
|
|
7
|
+
// show a slice of that nav (craft-mode.js), and links written into every page's markup and
|
|
8
|
+
// script. Each of those is tested on its own (hall_nav: every nav href is a real page;
|
|
9
|
+
// hall_page_gate_map: every page has a server gate), but nothing drew the whole picture, so
|
|
10
|
+
// "how does everything link, and does it make sense?" had no answer short of reading 300 files.
|
|
11
|
+
// The owner asked for one on 2026-11-04.
|
|
12
|
+
//
|
|
13
|
+
// WHAT IT READS — the real sources, never a hand-kept list:
|
|
14
|
+
// • the pages: every modules/hall-ui/public/*.html (index.html is Home, at /);
|
|
15
|
+
// • the nav: shell.js NAV, parsed statically (group, id, label, href, gate);
|
|
16
|
+
// • the crafts: craft-mode.js RUN in a vm, asked tabsFor(mode) — the same answer the hall uses;
|
|
17
|
+
// • the server's page gates: serve-internal.js's *_PAGE_RE literals and their gdsAuth
|
|
18
|
+
// dispatch, the static parse tests/hall_page_gate_map.mjs established;
|
|
19
|
+
// • the links: every in-hall href in a page's HTML and in each script the page loads.
|
|
20
|
+
// Scripts nearly every page loads (the shell, the palette, the craft switch) are the CHROME: their
|
|
21
|
+
// links are the nav, reported once, never charged to every page.
|
|
22
|
+
//
|
|
23
|
+
// WHAT IT WRITES — docs/hall-sitemap.md: the crafts and their homes, the findings, one row per
|
|
24
|
+
// page (nav place, gates, crafts that show it, crafts that can click their way to it, links in and
|
|
25
|
+
// out), and the server routes that are not page files. Deterministic (sorted, no timestamps), so
|
|
26
|
+
// --check is a clean freshness gate; tests/hall_sitemap.mjs runs it and fails on a broken link.
|
|
27
|
+
//
|
|
28
|
+
// THE FINDINGS, and what each one asks of you (docs/recipes/hall-sitemap.md has the long form):
|
|
29
|
+
// broken a link to a URL the hall does not serve — fix the link, or add the route to ROUTES.
|
|
30
|
+
// orphan a page nothing links to and no nav item shows — link it, or retire it.
|
|
31
|
+
// unclickable a page no craft can reach by clicking — a page only a typed URL finds.
|
|
32
|
+
// hidden a nav item no craft shows — only the no-craft hall sees it.
|
|
33
|
+
// dangling a craft tab id with no nav item — the craft lists a tab that does not exist.
|
|
34
|
+
//
|
|
35
|
+
// Usage:
|
|
36
|
+
// node scripts/bongos/gen-hall-sitemap.js # regenerate docs/hall-sitemap.md
|
|
37
|
+
// node scripts/bongos/gen-hall-sitemap.js --check # write nothing; exit 2 if it WOULD change
|
|
38
|
+
// node scripts/bongos/gen-hall-sitemap.js --audit # print the findings; exit 1 on a broken link
|
|
39
|
+
// node scripts/bongos/gen-hall-sitemap.js --json # print the model (debug)
|
|
40
|
+
|
|
41
|
+
const fs = require('fs');
|
|
42
|
+
const path = require('path');
|
|
43
|
+
const vm = require('vm');
|
|
44
|
+
|
|
45
|
+
const ROOT = path.resolve(__dirname, '..', '..');
|
|
46
|
+
const HALL = 'modules/hall-ui/public';
|
|
47
|
+
const OUT = 'docs/hall-sitemap.md';
|
|
48
|
+
const CRAFTS = ['building', 'artistry', 'ideating', 'governing'];
|
|
49
|
+
|
|
50
|
+
// URLs the server answers that are not a page file in the hall. A link to one is not broken.
|
|
51
|
+
// A NEW route a page links to belongs here, with where the server serves it; the audit fails
|
|
52
|
+
// until it is named, which is the point: an unknown target is a broken link until proven not.
|
|
53
|
+
const ROUTES = {
|
|
54
|
+
'/task': { to: 'task.html', why: '/task/:id, the task page (serve-internal.js)' },
|
|
55
|
+
'/idea': { to: 'idea.html', why: '/idea/:id, the idea page (serve-internal.js)' },
|
|
56
|
+
'/people': { to: '/roster', why: 'retired directory, redirects (task 1003444)' },
|
|
57
|
+
'/scouting': { to: '/roster', why: 'retired directory, redirects (task 1003440)' },
|
|
58
|
+
'/board': { to: '/work', why: 'old name of the Work board, redirects' },
|
|
59
|
+
'/sky': { to: '/thinking', why: 'old name, redirects' },
|
|
60
|
+
'/market-watch-settings': { to: '/market-watch', why: 'now a Settings tab, redirects (task 1005045)' },
|
|
61
|
+
};
|
|
62
|
+
// Targets that leave the hall on purpose: sign-in, the hub's /account (platform-server.js;
|
|
63
|
+
// settings.js points the link at the hub), the API, module assets, and any file with an extension.
|
|
64
|
+
const EXTERNAL = [/^\/api\//, /^\/auth\b/, /^\/login\b/, /^\/logout\b/, /^\/signin\b/, /^\/status\b/,
|
|
65
|
+
/^\/account\b/, /^\/modules\//, /^\/docs\b/, /^\/diagrams\//, /^\/healthz\b/, /^\/version\b/, /\.[a-z0-9]{2,5}$/i];
|
|
66
|
+
|
|
67
|
+
function read(rel) { return fs.readFileSync(path.join(ROOT, rel), 'utf8'); }
|
|
68
|
+
|
|
69
|
+
// ---- the pages ----
|
|
70
|
+
function readPages() {
|
|
71
|
+
return fs.readdirSync(path.join(ROOT, HALL)).filter((f) => f.endsWith('.html')).sort().map((f) => {
|
|
72
|
+
const name = f.replace(/\.html$/, '');
|
|
73
|
+
const html = read(`${HALL}/${f}`);
|
|
74
|
+
const title = (/<title>([^<]*)<\/title>/.exec(html) || [])[1] || '';
|
|
75
|
+
const scripts = [...html.matchAll(/<script[^>]*\ssrc="\/builders\/([a-z0-9-]+\.js)/g)].map((m) => m[1]);
|
|
76
|
+
return {
|
|
77
|
+
name, file: f, url: name === 'index' ? '/' : `/${name}`, html,
|
|
78
|
+
title: title.replace(/\s*[—-]\s*\{\{worldName\}\}\s*$/, '').trim(),
|
|
79
|
+
scripts: [...new Set(scripts)],
|
|
80
|
+
};
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// ---- the nav (shell.js NAV, read statically) ----
|
|
85
|
+
function readNav() {
|
|
86
|
+
const src = read(`${HALL}/shell.js`);
|
|
87
|
+
const start = src.indexOf('var NAV = [');
|
|
88
|
+
const body = src.slice(start, src.indexOf('\n ];', start));
|
|
89
|
+
const items = [];
|
|
90
|
+
let group = '';
|
|
91
|
+
for (const line of body.split('\n')) {
|
|
92
|
+
const g = /\{ group: '([^']*)'/.exec(line);
|
|
93
|
+
if (g) group = g[1];
|
|
94
|
+
const it = /\{ id: '([a-z0-9-]+)', label: '((?:\\'|[^'])*)'[^}]*?href: '([^']*)'[^}]*?gate: '([^']*)'/.exec(line);
|
|
95
|
+
if (it) items.push({ id: it[1], label: it[2].replace(/\\'/g, "'"), href: it[3], gate: it[4], group: group || '(project)', module: (/module: '([^']*)'/.exec(line) || [])[1] || null });
|
|
96
|
+
}
|
|
97
|
+
return items;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// ---- the crafts (craft-mode.js, run) ----
|
|
101
|
+
function readCrafts() {
|
|
102
|
+
const noop = () => {};
|
|
103
|
+
const el = () => ({ setAttribute: noop, getAttribute: () => null, removeAttribute: noop, hasAttribute: () => false, appendChild: noop, querySelector: () => null, querySelectorAll: () => [], classList: { toggle: noop, add: noop, remove: noop }, dataset: {} });
|
|
104
|
+
const window = { OTB: { escapeHtml: String }, matchMedia: () => ({ matches: false }), location: { search: '' } };
|
|
105
|
+
window.window = window;
|
|
106
|
+
const document = { body: el(), head: el(), documentElement: el(), getElementById: () => null, querySelector: () => null, querySelectorAll: () => [], createElement: el, addEventListener: noop };
|
|
107
|
+
vm.runInNewContext(read(`${HALL}/craft-mode.js`), { window, document, CustomEvent: noop, Promise, console });
|
|
108
|
+
const CM = window.OTBCraftMode;
|
|
109
|
+
const tabs = {};
|
|
110
|
+
for (const c of CRAFTS) tabs[c] = CM.tabsFor(c);
|
|
111
|
+
return { tabs, always: CM.ALWAYS_TABS };
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// ---- the server's page gates (the hall_page_gate_map.mjs parse) ----
|
|
115
|
+
function readGates() {
|
|
116
|
+
const src = read('src/bongos/serve-internal.js');
|
|
117
|
+
const rx = new Map();
|
|
118
|
+
const re = /const\s+([A-Z0-9_]+_RE)\s*=\s*(\/(?:\\.|\[(?:\\.|[^\]])*\]|[^/\\])+\/[gimsuy]*)\s*;/g;
|
|
119
|
+
// Its only input is this repo's own serve-internal.js, read from a fixed path: never a
|
|
120
|
+
// caller's string. Keep it that way (the same note hall_page_gate_map.mjs carries).
|
|
121
|
+
for (const m of src.matchAll(re)) rx.set(m[1], eval(m[2])); // eslint-disable-line no-eval
|
|
122
|
+
const dispatch = [];
|
|
123
|
+
for (const m of src.matchAll(/if\s*\(([^{};]*?)\)\s*\{\s*return\s+gdsAuth\.(\w+)\(req,\s*res,\s*next\);/g)) {
|
|
124
|
+
const names = [...m[1].matchAll(/([A-Z0-9_]+_RE)/g)].map((x) => x[1]);
|
|
125
|
+
if (names.length) dispatch.push({ names, gate: m[2] });
|
|
126
|
+
}
|
|
127
|
+
return (url) => {
|
|
128
|
+
const full = url === '/' ? '/builders/' : `/builders${url}`;
|
|
129
|
+
for (const { names, gate } of dispatch) for (const n of names) if (rx.get(n) && rx.get(n).test(full)) return gate;
|
|
130
|
+
return null;
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// ---- the links ----
|
|
135
|
+
// An in-hall link, written any of the ways the hall writes one. Root-relative only; a link
|
|
136
|
+
// built from a variable is not a fact this file can read, so it is not guessed at.
|
|
137
|
+
const LINK_RES = [
|
|
138
|
+
/href=\\?["'](\/[^"'\\\s$`{]*)/g, // markup, in HTML or in a script's strings
|
|
139
|
+
// href="${hallBase()}/x", the base resolved at run time; a ${hub…} base is the hub's page, not ours
|
|
140
|
+
/href=\\?["']\$\{(?![^}]*\bhub)[^}]*\}(\/[a-z0-9-][^"'\\\s$`{]*)/g,
|
|
141
|
+
/Href\([^)]*\)\s*\{\s*return\s+'(\/[^'\s$]*)'/g, // function itemHref(id) { return '/x?id=' + … }
|
|
142
|
+
/\bhref\s*:\s*'(\/[^'\s$]*)'/g, // { href: '/x' }
|
|
143
|
+
/\.href\s*=\s*'(\/[^'\s$]*)'/g, // a.href = '/x'
|
|
144
|
+
/setAttribute\(\s*'href'\s*,\s*'(\/[^'\s$]*)'/g, // setAttribute('href', '/x')
|
|
145
|
+
/location\.(?:assign|replace)\(\s*'(\/[^'\s$]*)'/g, // location.assign('/x')
|
|
146
|
+
/\b[A-Z_]*HREF\s*=\s*'(\/[^'\s$]*)'/g, // const STREET_HREF = '/x'
|
|
147
|
+
/'(\/builders\/[a-z0-9-]+(?:[/?#][^'\s$]*)?)'/g, // any '/builders/<page>' literal
|
|
148
|
+
];
|
|
149
|
+
function linksIn(src) {
|
|
150
|
+
const out = new Set();
|
|
151
|
+
for (const re of LINK_RES) for (const m of src.matchAll(re)) out.add(m[1]);
|
|
152
|
+
return [...out];
|
|
153
|
+
}
|
|
154
|
+
// '/builders/goals?x#y' → '/goals'; '/task/12' → '/task'; '/#standing' → '/'
|
|
155
|
+
function normalise(href) {
|
|
156
|
+
let p = href.replace(/[?#].*$/, '').replace(/^\/builders(?=\/|$)/, '') || '/';
|
|
157
|
+
p = p.replace(/\.html$/, '');
|
|
158
|
+
if (p.length > 1) p = p.replace(/\/+$/, '');
|
|
159
|
+
const seg = p.split('/').filter(Boolean);
|
|
160
|
+
return seg.length ? '/' + seg[0] : '/';
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// Where a link lands: the page's URL, null when it leaves the hall on purpose, false when broken.
|
|
164
|
+
function classify(href, pageUrls) {
|
|
165
|
+
const t = normalise(href);
|
|
166
|
+
if (pageUrls.has(t)) return t;
|
|
167
|
+
if (ROUTES[t]) return ROUTES[t].to.endsWith('.html') ? '/' + ROUTES[t].to.replace(/\.html$/, '') : ROUTES[t].to;
|
|
168
|
+
if (EXTERNAL.some((re) => re.test(href.replace(/[?#].*$/, '').replace(/^\/builders/, '')))) return null;
|
|
169
|
+
return false;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function build() {
|
|
173
|
+
const pages = readPages();
|
|
174
|
+
const nav = readNav();
|
|
175
|
+
const crafts = readCrafts();
|
|
176
|
+
const gateOf = readGates();
|
|
177
|
+
const byUrl = new Map(pages.map((p) => [p.url, p]));
|
|
178
|
+
|
|
179
|
+
// the chrome: scripts at least three quarters of the pages load
|
|
180
|
+
const loads = {};
|
|
181
|
+
for (const p of pages) for (const s of p.scripts) loads[s] = (loads[s] || 0) + 1;
|
|
182
|
+
const chrome = Object.keys(loads).filter((s) => loads[s] >= pages.length * 0.75).sort();
|
|
183
|
+
|
|
184
|
+
const scriptLinks = {};
|
|
185
|
+
const linksOf = (s) => {
|
|
186
|
+
if (!(s in scriptLinks)) {
|
|
187
|
+
const f = path.join(ROOT, HALL, s);
|
|
188
|
+
scriptLinks[s] = fs.existsSync(f) ? linksIn(fs.readFileSync(f, 'utf8')) : [];
|
|
189
|
+
}
|
|
190
|
+
return scriptLinks[s];
|
|
191
|
+
};
|
|
192
|
+
|
|
193
|
+
const broken = [];
|
|
194
|
+
const resolve = (href, from, via) => {
|
|
195
|
+
const t = classify(href, byUrl);
|
|
196
|
+
if (t === false) broken.push({ from, via, href });
|
|
197
|
+
return t || null;
|
|
198
|
+
};
|
|
199
|
+
|
|
200
|
+
// links each page makes itself (its HTML and its own scripts), chrome left out
|
|
201
|
+
const out = new Map(pages.map((p) => [p.url, new Map()]));
|
|
202
|
+
for (const p of pages) {
|
|
203
|
+
const add = (href, via) => {
|
|
204
|
+
const t = resolve(href, p.url, via);
|
|
205
|
+
if (t && t !== p.url) {
|
|
206
|
+
const vias = out.get(p.url).get(t) || new Set();
|
|
207
|
+
vias.add(via);
|
|
208
|
+
out.get(p.url).set(t, vias);
|
|
209
|
+
}
|
|
210
|
+
};
|
|
211
|
+
for (const h of linksIn(p.html)) add(h, p.file);
|
|
212
|
+
for (const s of p.scripts) if (!chrome.includes(s)) for (const h of linksOf(s)) add(h, s);
|
|
213
|
+
}
|
|
214
|
+
// the chrome's own links that are not nav items are checked too (the palette, the shell)
|
|
215
|
+
for (const s of chrome) for (const h of linksOf(s)) resolve(h, '(every page)', s);
|
|
216
|
+
|
|
217
|
+
const navById = new Map(nav.map((n) => [n.id, n]));
|
|
218
|
+
const navByUrl = new Map();
|
|
219
|
+
for (const n of nav) {
|
|
220
|
+
const t = resolve(n.href, '(nav)', 'shell.js NAV');
|
|
221
|
+
if (t) navByUrl.set(t, [...(navByUrl.get(t) || []), n]);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// what each craft's nav shows, and what it can click its way to from there
|
|
225
|
+
const shows = {}, reach = {};
|
|
226
|
+
for (const c of CRAFTS) {
|
|
227
|
+
const ids = crafts.tabs[c];
|
|
228
|
+
shows[c] = new Set(ids.filter((id) => navById.has(id)).map((id) => normalise(navById.get(id).href)));
|
|
229
|
+
shows[c].add('/'); // the Home plate, in every craft
|
|
230
|
+
const seen = new Set(shows[c]);
|
|
231
|
+
const queue = [...seen];
|
|
232
|
+
while (queue.length) {
|
|
233
|
+
const u = queue.shift();
|
|
234
|
+
for (const t of (out.get(u) || new Map()).keys()) if (!seen.has(t)) { seen.add(t); queue.push(t); }
|
|
235
|
+
}
|
|
236
|
+
reach[c] = seen;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
const inbound = new Map(pages.map((p) => [p.url, new Set()]));
|
|
240
|
+
for (const [from, ts] of out) for (const t of ts.keys()) if (inbound.has(t)) inbound.get(t).add(from);
|
|
241
|
+
|
|
242
|
+
const rows = pages.map((p) => {
|
|
243
|
+
const items = navByUrl.get(p.url) || [];
|
|
244
|
+
return {
|
|
245
|
+
url: p.url, file: p.file, title: p.title,
|
|
246
|
+
nav: items.map((n) => `${n.group} › ${n.label}`),
|
|
247
|
+
navGate: [...new Set(items.map((n) => n.gate))],
|
|
248
|
+
serverGate: gateOf(p.url),
|
|
249
|
+
shownIn: CRAFTS.filter((c) => shows[c].has(p.url)),
|
|
250
|
+
reachIn: CRAFTS.filter((c) => reach[c].has(p.url)),
|
|
251
|
+
out: [...out.get(p.url).keys()].sort(),
|
|
252
|
+
in: [...inbound.get(p.url)].sort(),
|
|
253
|
+
};
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
const inAnyCraft = new Set(CRAFTS.flatMap((c) => crafts.tabs[c]));
|
|
257
|
+
const findings = {
|
|
258
|
+
broken: dedupe(broken).sort((a, b) => (a.from + a.href).localeCompare(b.from + b.href)),
|
|
259
|
+
orphan: rows.filter((r) => r.url !== '/' && !r.nav.length && !r.in.length).map((r) => r.url),
|
|
260
|
+
unclickable: rows.filter((r) => !r.reachIn.length).map((r) => r.url),
|
|
261
|
+
hidden: nav.filter((n) => !inAnyCraft.has(n.id)).map((n) => n.id),
|
|
262
|
+
dangling: [...inAnyCraft].filter((id) => !navById.has(id)).sort(),
|
|
263
|
+
};
|
|
264
|
+
return { pages: rows, nav, crafts, chrome, findings };
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
function dedupe(list) {
|
|
268
|
+
const seen = new Set();
|
|
269
|
+
return list.filter((b) => { const k = `${b.from}|${b.via}|${b.href}`; if (seen.has(k)) return false; seen.add(k); return true; });
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// ---- the document ----
|
|
273
|
+
const CRAFT_NAME = { building: 'Building', artistry: 'Artistry', ideating: 'Ideating', governing: 'Governing' };
|
|
274
|
+
const CRAFT_HOME = {
|
|
275
|
+
building: '/ — the project city',
|
|
276
|
+
artistry: '/ — the gallery',
|
|
277
|
+
ideating: '/ — the sky over the city',
|
|
278
|
+
governing: '/street — the governor\'s street (Home hops there)',
|
|
279
|
+
};
|
|
280
|
+
function list(xs) { return xs.length ? xs.map((x) => '`' + x + '`').join(', ') : '—'; }
|
|
281
|
+
function render(m) {
|
|
282
|
+
const L = [];
|
|
283
|
+
const f = m.findings;
|
|
284
|
+
L.push('# Builders hall sitemap');
|
|
285
|
+
L.push('');
|
|
286
|
+
L.push('> **Generated** by `node scripts/bongos/gen-hall-sitemap.js` — do not hand-edit; regenerate. How to read it and what to do about a finding: [`docs/recipes/hall-sitemap.md`](recipes/hall-sitemap.md). `tests/hall_sitemap.mjs` fails when this file is stale or a link is broken.');
|
|
287
|
+
L.push('');
|
|
288
|
+
L.push(`${m.pages.length} pages · ${m.nav.length} nav items · ${m.pages.reduce((n, p) => n + p.out.length, 0)} page-to-page links · chrome on every page: ${list(m.chrome)}`);
|
|
289
|
+
L.push('');
|
|
290
|
+
L.push('## The crafts');
|
|
291
|
+
L.push('');
|
|
292
|
+
L.push('Choosing a craft from the top-bar switch shows its tabs; on a page only other crafts show, it goes to the craft\'s Home (task 1005067).');
|
|
293
|
+
L.push('');
|
|
294
|
+
L.push('| Craft | Home (its dashboard) | Tabs in the nav | Pages it can click to |');
|
|
295
|
+
L.push('|---|---|---|---|');
|
|
296
|
+
for (const c of CRAFTS) {
|
|
297
|
+
const own = m.crafts.tabs[c].filter((id) => !m.crafts.always.includes(id));
|
|
298
|
+
L.push(`| ${CRAFT_NAME[c]} | ${CRAFT_HOME[c]} | ${list(own)} | ${m.pages.filter((p) => p.reachIn.includes(c)).length} |`);
|
|
299
|
+
}
|
|
300
|
+
L.push(`| *every craft* | | ${list(m.crafts.always)} | |`);
|
|
301
|
+
L.push('');
|
|
302
|
+
L.push('## Findings');
|
|
303
|
+
L.push('');
|
|
304
|
+
L.push(`- **Broken links (${f.broken.length})** — a link to a URL the hall does not serve.`);
|
|
305
|
+
for (const b of f.broken) L.push(` - \`${b.href}\` in \`${b.via}\` (${b.from})`);
|
|
306
|
+
L.push(`- **Orphan pages (${f.orphan.length})** — no nav item and no page links to them: ${list(f.orphan)}`);
|
|
307
|
+
L.push(`- **Unclickable pages (${f.unclickable.length})** — no craft reaches them by clicking from its nav: ${list(f.unclickable)}`);
|
|
308
|
+
L.push(`- **Nav items no craft shows (${f.hidden.length})** — only the hall with no craft chosen sees them: ${list(f.hidden)}`);
|
|
309
|
+
L.push(`- **Craft tabs with no nav item (${f.dangling.length}):** ${list(f.dangling)}`);
|
|
310
|
+
L.push('');
|
|
311
|
+
L.push('## Pages');
|
|
312
|
+
L.push('');
|
|
313
|
+
L.push('*Nav* is where the sidebar lists it; *nav gate* who the sidebar shows it to (cosmetic); *server gate* the wall the server holds (`null` = the shell is served to anyone). *Shown in* is the crafts whose nav lists it; *reach* the crafts that can click their way to it.');
|
|
314
|
+
L.push('');
|
|
315
|
+
L.push('| Page | File | Nav | Nav gate | Server gate | Shown in | Reach | In | Out |');
|
|
316
|
+
L.push('|---|---|---|---|---|---|---|---|---|');
|
|
317
|
+
for (const p of m.pages) {
|
|
318
|
+
L.push(`| \`${p.url}\` ${p.title ? '<br>' + p.title.replace(/\|/g, '\\|') : ''} | ${p.file} | ${p.nav.join('<br>') || '—'} | ${p.navGate.join(', ') || '—'} | ${p.serverGate || 'null'} | ${p.shownIn.map((c) => CRAFT_NAME[c]).join(', ') || '—'} | ${p.reachIn.map((c) => CRAFT_NAME[c]).join(', ') || '—'} | ${p.in.length} | ${p.out.length} |`);
|
|
319
|
+
}
|
|
320
|
+
L.push('');
|
|
321
|
+
L.push('## Links');
|
|
322
|
+
L.push('');
|
|
323
|
+
L.push('Each page\'s own links (its markup and the scripts it loads, the chrome left out), then the pages that link to it.');
|
|
324
|
+
L.push('');
|
|
325
|
+
for (const p of m.pages) {
|
|
326
|
+
L.push(`### \`${p.url}\``);
|
|
327
|
+
L.push('');
|
|
328
|
+
L.push(`- → ${list(p.out)}`);
|
|
329
|
+
L.push(`- ← ${list(p.in)}`);
|
|
330
|
+
L.push('');
|
|
331
|
+
}
|
|
332
|
+
L.push('## Server routes that are not page files');
|
|
333
|
+
L.push('');
|
|
334
|
+
L.push('| URL | Serves | Why |');
|
|
335
|
+
L.push('|---|---|---|');
|
|
336
|
+
for (const [u, r] of Object.entries(ROUTES).sort()) L.push(`| \`${u}\` | \`${r.to}\` | ${r.why} |`);
|
|
337
|
+
L.push('');
|
|
338
|
+
return L.join('\n');
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
function main(argv) {
|
|
342
|
+
const m = build();
|
|
343
|
+
const text = render(m);
|
|
344
|
+
const dest = path.join(ROOT, OUT);
|
|
345
|
+
if (argv.includes('--json')) { process.stdout.write(JSON.stringify(m, null, 2) + '\n'); return 0; }
|
|
346
|
+
if (argv.includes('--audit')) {
|
|
347
|
+
const f = m.findings;
|
|
348
|
+
for (const b of f.broken) console.log(`broken ${b.href} in ${b.via} (${b.from})`);
|
|
349
|
+
for (const u of f.orphan) console.log(`orphan ${u}`);
|
|
350
|
+
for (const u of f.unclickable) console.log(`unclickable ${u}`);
|
|
351
|
+
for (const id of f.hidden) console.log(`hidden nav item '${id}'`);
|
|
352
|
+
for (const id of f.dangling) console.log(`dangling craft tab '${id}'`);
|
|
353
|
+
console.log(`${f.broken.length} broken · ${f.orphan.length} orphan · ${f.unclickable.length} unclickable · ${f.hidden.length} hidden · ${f.dangling.length} dangling`);
|
|
354
|
+
return f.broken.length ? 1 : 0;
|
|
355
|
+
}
|
|
356
|
+
const cur = fs.existsSync(dest) ? fs.readFileSync(dest, 'utf8') : null;
|
|
357
|
+
if (argv.includes('--check')) {
|
|
358
|
+
if (cur === text) return 0;
|
|
359
|
+
console.error(`${OUT} is stale — run: node scripts/bongos/gen-hall-sitemap.js`);
|
|
360
|
+
return 2;
|
|
361
|
+
}
|
|
362
|
+
if (cur !== text) fs.writeFileSync(dest, text);
|
|
363
|
+
console.log(`${OUT}: ${m.pages.length} pages, ${m.findings.broken.length} broken links`);
|
|
364
|
+
return 0;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
module.exports = { build, render, normalise, linksIn, classify, ROUTES, OUT };
|
|
368
|
+
if (require.main === module) process.exit(main(process.argv.slice(2)));
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// scripts/gds/gen-hall-sitemap.js — @bongos-forwarder (task 1004848). Generated by
|
|
3
|
+
// scripts/bongos/codemod-rename-scripts-gds.js; do not edit.
|
|
4
|
+
// The real script is scripts/bongos/gen-hall-sitemap.js. This forwarder keeps callers that still name
|
|
5
|
+
// the old path working, and warns once so they get repointed. Task 1003706 removes it.
|
|
6
|
+
'use strict';
|
|
7
|
+
require('../bongos/forwarder.js').forward(module, 'gen-hall-sitemap.js');
|
package/src/module-api.js
CHANGED
|
@@ -81,7 +81,7 @@ const { sleep } = require('./sleep');
|
|
|
81
81
|
// MAJOR (see allowBoxScope below): passes the request through untouched.
|
|
82
82
|
function deprecatedNoopMiddleware(_req, _res, next) { next(); }
|
|
83
83
|
|
|
84
|
-
const CORE_VERSION = '1.24.
|
|
84
|
+
const CORE_VERSION = '1.24.34'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
|
|
85
85
|
|
|
86
86
|
// A namespaced logger so a module's log lines are attributable + consistent.
|
|
87
87
|
// Usage: const log = api.logger('discord'); log.info('mounted');
|