@nanobpm/urban 0.27.0 → 0.28.0
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.
|
@@ -23,6 +23,12 @@ export interface PagesDeps {
|
|
|
23
23
|
engine: EngineClient;
|
|
24
24
|
/** Read a page file; injectable for tests. */
|
|
25
25
|
readPage(path: string): Promise<string>;
|
|
26
|
+
/**
|
|
27
|
+
* List the available page ids (the `*.page.json` basenames under `pagesDir`),
|
|
28
|
+
* powering the `/app/pages` index endpoint and a `nav` node's `items: "auto"`.
|
|
29
|
+
* Optional; when absent the index is empty. Injectable for tests.
|
|
30
|
+
*/
|
|
31
|
+
listPages?(): Promise<string[]>;
|
|
26
32
|
}
|
|
27
33
|
/**
|
|
28
34
|
* Build the page-runtime routes over an injected datasource + engine. Pure over its deps
|
|
@@ -34,7 +34,7 @@ export function createPagesRoutes(opts, deps) {
|
|
|
34
34
|
const rowLimitRaw = opts.rowLimit ?? 200;
|
|
35
35
|
const rowLimit = Number.isFinite(rowLimitRaw) ? Math.max(0, Math.floor(rowLimitRaw)) : 200;
|
|
36
36
|
const sourceName = opts.sourceName ?? "app";
|
|
37
|
-
const { db, engine, readPage } = deps;
|
|
37
|
+
const { db, engine, readPage, listPages } = deps;
|
|
38
38
|
// The table-name whitelist is memoised: an Urban app runs its migrations at boot
|
|
39
39
|
// (before serving), so the schema is stable for the process lifetime, and the renderer
|
|
40
40
|
// refreshes grids repeatedly — re-introspecting on every `/app/data` hit would be a hot
|
|
@@ -75,6 +75,36 @@ export function createPagesRoutes(opts, deps) {
|
|
|
75
75
|
source: "surface:pages",
|
|
76
76
|
handler: () => javascript(RENDERER_JS),
|
|
77
77
|
});
|
|
78
|
+
// ── GET /app/pages (index of available pages) ───────────────────────────
|
|
79
|
+
// Powers multi-page navigation: a `nav` node with `items: "auto"` lists every
|
|
80
|
+
// page here, and it lets any client enumerate the app's screens. Each entry
|
|
81
|
+
// carries the page's own `title` (best-effort — an unreadable/!JSON page still
|
|
82
|
+
// appears by id) and a `home` flag. Registered as an exact path so it never
|
|
83
|
+
// shadows the `/app/pages/<id>` prefix route below.
|
|
84
|
+
routes.push({
|
|
85
|
+
method: "GET",
|
|
86
|
+
path: "/app/pages",
|
|
87
|
+
source: "surface:pages",
|
|
88
|
+
handler: async () => {
|
|
89
|
+
const ids = listPages ? await listPages().catch(() => []) : [];
|
|
90
|
+
const pages = [];
|
|
91
|
+
for (const id of ids) {
|
|
92
|
+
if (!/^[A-Za-z0-9_-]+$/.test(id))
|
|
93
|
+
continue;
|
|
94
|
+
let title = id;
|
|
95
|
+
try {
|
|
96
|
+
const doc = JSON.parse(await readPage(`${pagesDir}/${id}.page.json`));
|
|
97
|
+
if (isRecord(doc) && typeof doc.title === "string" && doc.title)
|
|
98
|
+
title = doc.title;
|
|
99
|
+
}
|
|
100
|
+
catch {
|
|
101
|
+
// Unreadable or non-JSON page: still list it by id.
|
|
102
|
+
}
|
|
103
|
+
pages.push({ id, title, home: id === homePage });
|
|
104
|
+
}
|
|
105
|
+
return json({ pages, home: homePage });
|
|
106
|
+
},
|
|
107
|
+
});
|
|
78
108
|
// ── GET /app/pages/<id> ─────────────────────────────────────────────────
|
|
79
109
|
routes.push({
|
|
80
110
|
method: "GET",
|
|
@@ -297,6 +327,13 @@ export function mountPages(ctx, app) {
|
|
|
297
327
|
db: app.data.open(sourceName),
|
|
298
328
|
engine: app.engine,
|
|
299
329
|
readPage: (p) => ctx.host.readTextFile(p),
|
|
330
|
+
listPages: async () => {
|
|
331
|
+
const dir = opts.pagesDir ?? "pages";
|
|
332
|
+
const names = await ctx.host.listDir(dir).catch(() => []);
|
|
333
|
+
return names
|
|
334
|
+
.filter((n) => n.endsWith(".page.json"))
|
|
335
|
+
.map((n) => n.slice(0, -".page.json".length));
|
|
336
|
+
},
|
|
300
337
|
});
|
|
301
338
|
ctx.host.log("info", "pages surface mounted", { source: sourceName, home: opts.homePage ?? "home" });
|
|
302
339
|
return {
|
|
@@ -400,6 +437,22 @@ table.pc-grid th { font-weight:600; color:var(--nano-text-muted); }
|
|
|
400
437
|
.pc-collapse-header { display:flex; align-items:center; gap:.5rem; width:100%; margin:0 0 .75rem; padding:0; background:transparent; border:0; color:inherit; font:inherit; font-size:1rem; font-weight:600; cursor:pointer; text-align:left; }
|
|
401
438
|
.pc-chevron-inline { color:var(--nano-text-faint); font-size:.75rem; width:1em; }
|
|
402
439
|
.pc-card-body[hidden] { display:none; }
|
|
440
|
+
/* Multi-page navigation: a horizontal menu bar or a vertical side rail. */
|
|
441
|
+
.pc-layout { display:flex; gap:1.5rem; align-items:flex-start; }
|
|
442
|
+
.pc-rail-wrap { flex:0 0 12rem; position:sticky; top:1rem; align-self:flex-start; }
|
|
443
|
+
.pc-main-col { flex:1 1 auto; min-width:0; }
|
|
444
|
+
.pc-nav-title { font-weight:650; }
|
|
445
|
+
.pc-bar { display:flex; align-items:center; gap:.6rem; flex-wrap:wrap; border-bottom:1px solid var(--nano-edge); padding-bottom:.6rem; margin-bottom:1.25rem; }
|
|
446
|
+
.pc-bar .pc-nav-title { margin-right:.5rem; }
|
|
447
|
+
.pc-bar .pc-nav-items { display:flex; gap:.25rem; flex-wrap:wrap; }
|
|
448
|
+
.pc-rail { display:flex; flex-direction:column; gap:.5rem; }
|
|
449
|
+
.pc-rail .pc-nav-title { margin-bottom:.25rem; padding:0 .7rem; }
|
|
450
|
+
.pc-rail .pc-nav-items { display:flex; flex-direction:column; gap:.15rem; }
|
|
451
|
+
.pc-nav-link { display:flex; align-items:center; gap:.4rem; padding:.4rem .7rem; border-radius:.4rem; color:var(--nano-text-muted); text-decoration:none; font-size:.9rem; }
|
|
452
|
+
.pc-nav-link:hover { background:var(--nano-hover); color:var(--nano-text); }
|
|
453
|
+
.pc-nav-link.active { background:var(--nano-accent); color:var(--nano-on-accent); }
|
|
454
|
+
.pc-nav-icon { line-height:1; }
|
|
455
|
+
.pc-nav-empty { color:var(--nano-text-faint); font-size:.85rem; padding:.4rem .7rem; }
|
|
403
456
|
`;
|
|
404
457
|
// The schema-driven browser renderer (ADR 0042 §3). Plain ES module string served at
|
|
405
458
|
// /app/runtime.js — it does NOT ship Craft.js (authoring is console-side only). It
|
|
@@ -409,6 +462,37 @@ const RENDERER_JS = String.raw `
|
|
|
409
462
|
const root = document.getElementById("page");
|
|
410
463
|
const HOME = root.dataset.home || "home";
|
|
411
464
|
|
|
465
|
+
// ── Multi-page routing (hash-based, reverse-proxy safe) ──────────────────
|
|
466
|
+
// Pages are selected by the URL fragment (#/<page>) so navigation never hits
|
|
467
|
+
// the server and works identically at the origin root and under the Nano
|
|
468
|
+
// console's /console/app-view/<name>/ path-prefixed proxy (the hash is never
|
|
469
|
+
// sent upstream). An empty/invalid hash falls back to the home page. Only a
|
|
470
|
+
// safe id charset is accepted so the fragment can't smuggle a path/URL.
|
|
471
|
+
const PAGE_ID = /^[A-Za-z0-9_-]+$/;
|
|
472
|
+
function safePageId(value) {
|
|
473
|
+
return typeof value === "string" && PAGE_ID.test(value);
|
|
474
|
+
}
|
|
475
|
+
function currentPage() {
|
|
476
|
+
try {
|
|
477
|
+
const raw = decodeURIComponent((location.hash || "").replace(/^#\/?/, ""));
|
|
478
|
+
return safePageId(raw) ? raw : HOME;
|
|
479
|
+
} catch (e) {
|
|
480
|
+
return HOME;
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
let CURRENT = currentPage();
|
|
484
|
+
|
|
485
|
+
// Per-render teardown. Each dataGrid registers its poll interval + pc:refresh
|
|
486
|
+
// listener here; navigating to another page runs every disposer before the new
|
|
487
|
+
// page renders, so a switched-away grid stops polling (otherwise every visited
|
|
488
|
+
// page would leave a live setInterval fetching forever).
|
|
489
|
+
const disposers = [];
|
|
490
|
+
function teardown() {
|
|
491
|
+
while (disposers.length) {
|
|
492
|
+
try { disposers.pop()(); } catch (e) { /* best-effort cleanup */ }
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
|
|
412
496
|
// Theme bridge for the Nano console embed. The app runs in a sandboxed,
|
|
413
497
|
// same-origin iframe, so the console's --nano-* custom properties and its
|
|
414
498
|
// data-appearance never cascade in. Instead the console postMessages its
|
|
@@ -857,15 +941,71 @@ function renderDataGrid(node) {
|
|
|
857
941
|
}
|
|
858
942
|
}
|
|
859
943
|
document.addEventListener("pc:refresh", refresh);
|
|
944
|
+
disposers.push(() => document.removeEventListener("pc:refresh", refresh));
|
|
860
945
|
// Skip the automatic poll while the user is typing in the grid — an explicit pc:refresh
|
|
861
946
|
// (e.g. after submitting an answer) still runs — so rows never shift under an in-progress
|
|
862
|
-
// answer and the detail input keeps focus across ticks.
|
|
863
|
-
|
|
947
|
+
// answer and the detail input keeps focus across ticks. The interval is registered as a
|
|
948
|
+
// disposer so a page switch (hashchange → renderPage → teardown) stops it.
|
|
949
|
+
if (p.refreshMs && p.refreshMs > 0) {
|
|
950
|
+
const timer = setInterval(() => { if (!editingInGrid()) refresh(); }, p.refreshMs);
|
|
951
|
+
disposers.push(() => clearInterval(timer));
|
|
952
|
+
}
|
|
864
953
|
refresh();
|
|
865
954
|
return card;
|
|
866
955
|
}
|
|
867
956
|
|
|
868
|
-
|
|
957
|
+
// A navigation node — a horizontal menu bar (variant "bar", default) or a
|
|
958
|
+
// vertical side rail (variant "rail"). Each item links either to another page
|
|
959
|
+
// ({ label, page } -> an in-app #/<page> hash link, highlighted when it's the
|
|
960
|
+
// current page) or to an external URL ({ label, href } -> a hardened new-tab
|
|
961
|
+
// link; only http(s) is honoured). With items:"auto" (or omitted) the nav
|
|
962
|
+
// lists every page from the /app/pages index, using each page's title.
|
|
963
|
+
function navLink(item) {
|
|
964
|
+
const isPage = safePageId(item.page);
|
|
965
|
+
const isExt = !isPage && typeof item.href === "string" && /^https?:\/\//i.test(item.href);
|
|
966
|
+
if (!isPage && !isExt) return null;
|
|
967
|
+
const attrs = { class: "pc-nav-link" };
|
|
968
|
+
if (isPage) {
|
|
969
|
+
attrs.href = "#/" + encodeURIComponent(item.page);
|
|
970
|
+
if (item.page === CURRENT) { attrs.class += " active"; attrs["aria-current"] = "page"; }
|
|
971
|
+
} else {
|
|
972
|
+
attrs.href = item.href; attrs.target = "_blank"; attrs.rel = "noopener noreferrer";
|
|
973
|
+
}
|
|
974
|
+
const kids = [];
|
|
975
|
+
if (item.icon != null) kids.push(el("span", { class: "pc-nav-icon" }, String(item.icon)));
|
|
976
|
+
kids.push(el("span", { class: "pc-nav-label" }, String(item.label != null ? item.label : (item.page || item.href))));
|
|
977
|
+
return el("a", attrs, ...kids);
|
|
978
|
+
}
|
|
979
|
+
function fillNav(list, items) {
|
|
980
|
+
list.replaceChildren();
|
|
981
|
+
let n = 0;
|
|
982
|
+
for (const item of items || []) {
|
|
983
|
+
if (!item || typeof item !== "object") continue;
|
|
984
|
+
const a = navLink(item);
|
|
985
|
+
if (a) { list.append(a); n++; }
|
|
986
|
+
}
|
|
987
|
+
if (!n) list.append(el("span", { class: "pc-nav-empty" }, "No pages"));
|
|
988
|
+
}
|
|
989
|
+
function renderNav(node) {
|
|
990
|
+
const p = node.props || {};
|
|
991
|
+
const rail = p.variant === "rail";
|
|
992
|
+
const nav = el("nav", { class: rail ? "pc-nav pc-rail" : "pc-nav pc-bar" });
|
|
993
|
+
if (p.title != null) nav.append(el("div", { class: "pc-nav-title" }, String(p.title)));
|
|
994
|
+
const list = el("div", { class: "pc-nav-items" });
|
|
995
|
+
nav.append(list);
|
|
996
|
+
const items = p.items;
|
|
997
|
+
if (Array.isArray(items)) {
|
|
998
|
+
fillNav(list, items);
|
|
999
|
+
} else {
|
|
1000
|
+
// "auto" (or unspecified): enumerate every page and link to each.
|
|
1001
|
+
getJSON("/app/pages")
|
|
1002
|
+
.then((res) => fillNav(list, (res.pages || []).map((pg) => ({ label: pg.title || pg.id, page: pg.id }))))
|
|
1003
|
+
.catch(() => list.append(el("span", { class: "pc-nav-empty" }, "No pages")));
|
|
1004
|
+
}
|
|
1005
|
+
return nav;
|
|
1006
|
+
}
|
|
1007
|
+
|
|
1008
|
+
const RENDERERS = { text: renderText, actionForm: renderActionForm, dataGrid: renderDataGrid, nav: renderNav };
|
|
869
1009
|
|
|
870
1010
|
// Durable per-node UI state. localStorage is keyed by the home page id + node
|
|
871
1011
|
// id so two grids on the same page (or the same grid across pages) don't clash,
|
|
@@ -920,7 +1060,7 @@ function makeCollapsible(node, card) {
|
|
|
920
1060
|
chevron,
|
|
921
1061
|
el("span", { class: "pc-collapse-title" }, titleText),
|
|
922
1062
|
);
|
|
923
|
-
const storageKey = "pc:collapsed:" +
|
|
1063
|
+
const storageKey = "pc:collapsed:" + CURRENT + ":" + (node.id || titleText);
|
|
924
1064
|
let collapsed = readCollapsed(storageKey, !!props.defaultCollapsed);
|
|
925
1065
|
function apply() {
|
|
926
1066
|
body.hidden = collapsed;
|
|
@@ -937,16 +1077,30 @@ function makeCollapsible(node, card) {
|
|
|
937
1077
|
return container;
|
|
938
1078
|
}
|
|
939
1079
|
|
|
940
|
-
|
|
1080
|
+
// Render the page named by the current hash route. A rail nav is lifted into a
|
|
1081
|
+
// left <aside> and the rest of the nodes flow into a content column; otherwise
|
|
1082
|
+
// (bar nav or none) nodes render in document order. Re-runs on every hashchange,
|
|
1083
|
+
// tearing down the previous page's grid polls first.
|
|
1084
|
+
async function renderPage() {
|
|
1085
|
+
teardown();
|
|
1086
|
+
CURRENT = currentPage();
|
|
941
1087
|
try {
|
|
942
|
-
const doc = await getJSON("/app/pages/" + encodeURIComponent(
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
);
|
|
1088
|
+
const doc = await getJSON("/app/pages/" + encodeURIComponent(CURRENT));
|
|
1089
|
+
document.title = (doc && doc.title) || "Urban App";
|
|
1090
|
+
const nodes = (doc && doc.nodes) || [];
|
|
1091
|
+
const built = nodes.map((n) => ({ n, node: makeCollapsible(n, (RENDERERS[n.type] || (() => el("div")))(n)) }));
|
|
1092
|
+
const rail = built.find((b) => b.n.type === "nav" && b.n.props && b.n.props.variant === "rail");
|
|
1093
|
+
if (rail) {
|
|
1094
|
+
const col = el("div", { class: "pc-main-col" });
|
|
1095
|
+
for (const b of built) if (b !== rail) col.append(b.node);
|
|
1096
|
+
root.replaceChildren(el("div", { class: "pc-layout" }, el("aside", { class: "pc-rail-wrap" }, rail.node), col));
|
|
1097
|
+
} else {
|
|
1098
|
+
root.replaceChildren(...built.map((b) => b.node));
|
|
1099
|
+
}
|
|
947
1100
|
} catch (e) {
|
|
948
|
-
root.replaceChildren(el("p", { class: "pc-msg err" }, "Failed to load page: " + String(e.message || e)));
|
|
1101
|
+
root.replaceChildren(el("p", { class: "pc-msg err" }, "Failed to load page: " + String((e && e.message) || e)));
|
|
949
1102
|
}
|
|
950
1103
|
}
|
|
951
|
-
|
|
1104
|
+
window.addEventListener("hashchange", renderPage);
|
|
1105
|
+
renderPage();
|
|
952
1106
|
`;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nanobpm/urban",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.28.0",
|
|
4
4
|
"description": "Urban: build and run code-first apps on Nano — runtime, derivation toolkit, and CLI in one. Author durable processes with @nanobpm/workflow (defineFlow), a typed datasource, forms, triggers and surfaces from a nano.app.json manifest, on Node or Deno.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"type": "module",
|
|
@@ -65,12 +65,14 @@ function build(dbOverrides: Partial<PagesDataSource> = {}) {
|
|
|
65
65
|
const db = fakeDb(dbOverrides);
|
|
66
66
|
const readPage = async (path: string) => {
|
|
67
67
|
if (path === "pages/home.page.json") return JSON.stringify({ title: "Home", nodes: [] });
|
|
68
|
+
if (path === "pages/epic.page.json") return JSON.stringify({ title: "Epic Coordination", nodes: [] });
|
|
68
69
|
throw new Error("not found");
|
|
69
70
|
};
|
|
70
71
|
const routes = createPagesRoutes({ pagesDir: "pages", homePage: "home", sourceName: "app" }, {
|
|
71
72
|
db,
|
|
72
73
|
engine,
|
|
73
74
|
readPage,
|
|
75
|
+
listPages: async () => ["home", "epic"],
|
|
74
76
|
});
|
|
75
77
|
const router = makeRouter(routes);
|
|
76
78
|
return { router, calls };
|
|
@@ -212,8 +214,9 @@ test("renderer makes collapsible nodes persist their state across sessions", asy
|
|
|
212
214
|
assert.match(js, /if \(!props\.collapsible\) return card;/);
|
|
213
215
|
// Seed from defaultCollapsed the first time, then from persisted state.
|
|
214
216
|
assert.match(js, /readCollapsed\(storageKey, !!props\.defaultCollapsed\)/);
|
|
215
|
-
// Namespaced, per-node storage key
|
|
216
|
-
|
|
217
|
+
// Namespaced, per-node storage key — keyed by the *current* page id so two
|
|
218
|
+
// pages that reuse a node id don't share collapse state across navigation.
|
|
219
|
+
assert.match(js, /"pc:collapsed:" \+ CURRENT \+ ":" \+ \(node\.id/);
|
|
217
220
|
// Persist on toggle.
|
|
218
221
|
assert.match(js, /writeCollapsed\(storageKey, collapsed\)/);
|
|
219
222
|
// Every storage access is wrapped so a throwing localStorage can't break the UI.
|
|
@@ -239,6 +242,77 @@ test("GET /app/pages/<id> returns the page json, 404 for unknown", async () => {
|
|
|
239
242
|
assert.equal(miss.status, 404);
|
|
240
243
|
});
|
|
241
244
|
|
|
245
|
+
test("GET /app/pages indexes the available pages with titles + home flag", async () => {
|
|
246
|
+
const res = await dispatch("GET", "/app/pages");
|
|
247
|
+
assert.equal(res.status, 200);
|
|
248
|
+
const body = JSON.parse(res.body ?? "{}");
|
|
249
|
+
assert.equal(body.home, "home");
|
|
250
|
+
const byId = Object.fromEntries((body.pages ?? []).map((p: { id: string }) => [p.id, p]));
|
|
251
|
+
assert.deepEqual(byId.home, { id: "home", title: "Home", home: true });
|
|
252
|
+
assert.deepEqual(byId.epic, { id: "epic", title: "Epic Coordination", home: false });
|
|
253
|
+
// The exact index path must not be shadowed by the /app/pages/<id> prefix route.
|
|
254
|
+
assert.equal((await dispatch("GET", "/app/pages/epic")).status, 200);
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
test("GET /app/pages is empty (not an error) when listing is unavailable", async () => {
|
|
258
|
+
// listPages is optional — without it the index is an empty list, not a 500.
|
|
259
|
+
const routes = createPagesRoutes({ pagesDir: "pages", homePage: "home", sourceName: "app" }, {
|
|
260
|
+
db: fakeDb(),
|
|
261
|
+
engine: fakeEngine().engine,
|
|
262
|
+
readPage: async () => "{}",
|
|
263
|
+
});
|
|
264
|
+
const res = await makeRouter(routes)(req("GET", "/app/pages"));
|
|
265
|
+
assert.equal(res.status, 200);
|
|
266
|
+
assert.deepEqual(JSON.parse(res.body ?? "{}"), { pages: [], home: "home" });
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
test("the renderer routes between pages by hash and tears down grid polls", async () => {
|
|
270
|
+
const res = await dispatch("GET", "/app/runtime.js");
|
|
271
|
+
const js = res.body ?? "";
|
|
272
|
+
// A page is chosen by the URL fragment (#/<page>), validated to a safe id, and
|
|
273
|
+
// re-rendered on hashchange — proxy-safe because the hash never hits the server.
|
|
274
|
+
assert.match(js, /const PAGE_ID = \/\^\[A-Za-z0-9_-/);
|
|
275
|
+
assert.match(js, /function safePageId\(value\)/);
|
|
276
|
+
assert.match(js, /function currentPage\(\)/);
|
|
277
|
+
assert.match(js, /try \{\s*const raw = decodeURIComponent/s);
|
|
278
|
+
assert.match(js, /catch \(e\) \{\s*return HOME;/s);
|
|
279
|
+
assert.match(js, /location\.hash/);
|
|
280
|
+
assert.match(js, /window\.addEventListener\("hashchange", renderPage\)/);
|
|
281
|
+
assert.match(js, /async function renderPage\(\)/);
|
|
282
|
+
// Navigation runs every registered disposer first, so a switched-away grid's
|
|
283
|
+
// poll interval + pc:refresh listener are removed rather than leaking forever.
|
|
284
|
+
assert.match(js, /const disposers = \[\]/);
|
|
285
|
+
assert.match(js, /function teardown\(\)/);
|
|
286
|
+
assert.match(js, /disposers\.push\(\(\) => document\.removeEventListener\("pc:refresh", refresh\)\)/);
|
|
287
|
+
assert.match(js, /disposers\.push\(\(\) => clearInterval\(timer\)\)/);
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
test("the renderer ships a nav node (menu bar / rail) with in-app + external links", async () => {
|
|
291
|
+
const res = await dispatch("GET", "/app/runtime.js");
|
|
292
|
+
const js = res.body ?? "";
|
|
293
|
+
// nav is a first-class node type.
|
|
294
|
+
assert.match(js, /nav: renderNav/);
|
|
295
|
+
assert.match(js, /function renderNav\(node\)/);
|
|
296
|
+
// "rail" variant renders a side rail; anything else is a horizontal bar.
|
|
297
|
+
assert.match(js, /p\.variant === "rail"/);
|
|
298
|
+
assert.match(js, /"pc-nav pc-rail"/);
|
|
299
|
+
assert.match(js, /"pc-nav pc-bar"/);
|
|
300
|
+
// A page item becomes an in-app hash link, active-highlighted on the current page.
|
|
301
|
+
assert.match(js, /const isPage = safePageId\(item\.page\)/);
|
|
302
|
+
assert.match(js, /attrs\.href = "#\/" \+ encodeURIComponent\(item\.page\)/);
|
|
303
|
+
assert.match(js, /item\.page === CURRENT/);
|
|
304
|
+
assert.match(js, /aria-current/);
|
|
305
|
+
// An external item is an http(s)-only, hardened new-tab link.
|
|
306
|
+
assert.match(js, /\^https\?:\\\/\\\//);
|
|
307
|
+
assert.match(js, /attrs\.rel = "noopener noreferrer"/);
|
|
308
|
+
// items:"auto" (or omitted) enumerates every page from the index endpoint.
|
|
309
|
+
assert.match(js, /getJSON\("\/app\/pages"\)/);
|
|
310
|
+
// A rail is lifted into a left <aside>; the rest flow into a content column.
|
|
311
|
+
assert.match(js, /pc-rail-wrap/);
|
|
312
|
+
assert.match(js, /pc-layout/);
|
|
313
|
+
assert.match(js, /pc-main-col/);
|
|
314
|
+
});
|
|
315
|
+
|
|
242
316
|
test("GET /app/data/<source>/<table> returns rows", async () => {
|
|
243
317
|
const res = await dispatch("GET", "/app/data/app/orders");
|
|
244
318
|
assert.equal(res.status, 200);
|
|
@@ -43,6 +43,12 @@ export interface PagesDeps {
|
|
|
43
43
|
engine: EngineClient;
|
|
44
44
|
/** Read a page file; injectable for tests. */
|
|
45
45
|
readPage(path: string): Promise<string>;
|
|
46
|
+
/**
|
|
47
|
+
* List the available page ids (the `*.page.json` basenames under `pagesDir`),
|
|
48
|
+
* powering the `/app/pages` index endpoint and a `nav` node's `items: "auto"`.
|
|
49
|
+
* Optional; when absent the index is empty. Injectable for tests.
|
|
50
|
+
*/
|
|
51
|
+
listPages?(): Promise<string[]>;
|
|
46
52
|
}
|
|
47
53
|
|
|
48
54
|
/** A JavaScript-body response (the renderer module served at /app/runtime.js). */
|
|
@@ -64,7 +70,7 @@ export function createPagesRoutes(opts: PagesOptions, deps: PagesDeps): Route[]
|
|
|
64
70
|
const rowLimitRaw = opts.rowLimit ?? 200;
|
|
65
71
|
const rowLimit = Number.isFinite(rowLimitRaw) ? Math.max(0, Math.floor(rowLimitRaw)) : 200;
|
|
66
72
|
const sourceName = opts.sourceName ?? "app";
|
|
67
|
-
const { db, engine, readPage } = deps;
|
|
73
|
+
const { db, engine, readPage, listPages } = deps;
|
|
68
74
|
|
|
69
75
|
// The table-name whitelist is memoised: an Urban app runs its migrations at boot
|
|
70
76
|
// (before serving), so the schema is stable for the process lifetime, and the renderer
|
|
@@ -113,6 +119,34 @@ export function createPagesRoutes(opts: PagesOptions, deps: PagesDeps): Route[]
|
|
|
113
119
|
handler: () => javascript(RENDERER_JS),
|
|
114
120
|
});
|
|
115
121
|
|
|
122
|
+
// ── GET /app/pages (index of available pages) ───────────────────────────
|
|
123
|
+
// Powers multi-page navigation: a `nav` node with `items: "auto"` lists every
|
|
124
|
+
// page here, and it lets any client enumerate the app's screens. Each entry
|
|
125
|
+
// carries the page's own `title` (best-effort — an unreadable/!JSON page still
|
|
126
|
+
// appears by id) and a `home` flag. Registered as an exact path so it never
|
|
127
|
+
// shadows the `/app/pages/<id>` prefix route below.
|
|
128
|
+
routes.push({
|
|
129
|
+
method: "GET",
|
|
130
|
+
path: "/app/pages",
|
|
131
|
+
source: "surface:pages",
|
|
132
|
+
handler: async () => {
|
|
133
|
+
const ids = listPages ? await listPages().catch(() => []) : [];
|
|
134
|
+
const pages: { id: string; title: string; home: boolean }[] = [];
|
|
135
|
+
for (const id of ids) {
|
|
136
|
+
if (!/^[A-Za-z0-9_-]+$/.test(id)) continue;
|
|
137
|
+
let title = id;
|
|
138
|
+
try {
|
|
139
|
+
const doc = JSON.parse(await readPage(`${pagesDir}/${id}.page.json`));
|
|
140
|
+
if (isRecord(doc) && typeof doc.title === "string" && doc.title) title = doc.title;
|
|
141
|
+
} catch {
|
|
142
|
+
// Unreadable or non-JSON page: still list it by id.
|
|
143
|
+
}
|
|
144
|
+
pages.push({ id, title, home: id === homePage });
|
|
145
|
+
}
|
|
146
|
+
return json({ pages, home: homePage });
|
|
147
|
+
},
|
|
148
|
+
});
|
|
149
|
+
|
|
116
150
|
// ── GET /app/pages/<id> ─────────────────────────────────────────────────
|
|
117
151
|
routes.push({
|
|
118
152
|
method: "GET",
|
|
@@ -324,6 +358,13 @@ export function mountPages(ctx: RuntimeContext, app: AppApi): PagesHandle {
|
|
|
324
358
|
db: app.data.open(sourceName),
|
|
325
359
|
engine: app.engine,
|
|
326
360
|
readPage: (p) => ctx.host.readTextFile(p),
|
|
361
|
+
listPages: async () => {
|
|
362
|
+
const dir = opts.pagesDir ?? "pages";
|
|
363
|
+
const names = await ctx.host.listDir(dir).catch(() => []);
|
|
364
|
+
return names
|
|
365
|
+
.filter((n) => n.endsWith(".page.json"))
|
|
366
|
+
.map((n) => n.slice(0, -".page.json".length));
|
|
367
|
+
},
|
|
327
368
|
});
|
|
328
369
|
ctx.host.log("info", "pages surface mounted", { source: sourceName, home: opts.homePage ?? "home" });
|
|
329
370
|
return {
|
|
@@ -430,6 +471,22 @@ table.pc-grid th { font-weight:600; color:var(--nano-text-muted); }
|
|
|
430
471
|
.pc-collapse-header { display:flex; align-items:center; gap:.5rem; width:100%; margin:0 0 .75rem; padding:0; background:transparent; border:0; color:inherit; font:inherit; font-size:1rem; font-weight:600; cursor:pointer; text-align:left; }
|
|
431
472
|
.pc-chevron-inline { color:var(--nano-text-faint); font-size:.75rem; width:1em; }
|
|
432
473
|
.pc-card-body[hidden] { display:none; }
|
|
474
|
+
/* Multi-page navigation: a horizontal menu bar or a vertical side rail. */
|
|
475
|
+
.pc-layout { display:flex; gap:1.5rem; align-items:flex-start; }
|
|
476
|
+
.pc-rail-wrap { flex:0 0 12rem; position:sticky; top:1rem; align-self:flex-start; }
|
|
477
|
+
.pc-main-col { flex:1 1 auto; min-width:0; }
|
|
478
|
+
.pc-nav-title { font-weight:650; }
|
|
479
|
+
.pc-bar { display:flex; align-items:center; gap:.6rem; flex-wrap:wrap; border-bottom:1px solid var(--nano-edge); padding-bottom:.6rem; margin-bottom:1.25rem; }
|
|
480
|
+
.pc-bar .pc-nav-title { margin-right:.5rem; }
|
|
481
|
+
.pc-bar .pc-nav-items { display:flex; gap:.25rem; flex-wrap:wrap; }
|
|
482
|
+
.pc-rail { display:flex; flex-direction:column; gap:.5rem; }
|
|
483
|
+
.pc-rail .pc-nav-title { margin-bottom:.25rem; padding:0 .7rem; }
|
|
484
|
+
.pc-rail .pc-nav-items { display:flex; flex-direction:column; gap:.15rem; }
|
|
485
|
+
.pc-nav-link { display:flex; align-items:center; gap:.4rem; padding:.4rem .7rem; border-radius:.4rem; color:var(--nano-text-muted); text-decoration:none; font-size:.9rem; }
|
|
486
|
+
.pc-nav-link:hover { background:var(--nano-hover); color:var(--nano-text); }
|
|
487
|
+
.pc-nav-link.active { background:var(--nano-accent); color:var(--nano-on-accent); }
|
|
488
|
+
.pc-nav-icon { line-height:1; }
|
|
489
|
+
.pc-nav-empty { color:var(--nano-text-faint); font-size:.85rem; padding:.4rem .7rem; }
|
|
433
490
|
`;
|
|
434
491
|
|
|
435
492
|
// The schema-driven browser renderer (ADR 0042 §3). Plain ES module string served at
|
|
@@ -440,6 +497,37 @@ const RENDERER_JS = String.raw`
|
|
|
440
497
|
const root = document.getElementById("page");
|
|
441
498
|
const HOME = root.dataset.home || "home";
|
|
442
499
|
|
|
500
|
+
// ── Multi-page routing (hash-based, reverse-proxy safe) ──────────────────
|
|
501
|
+
// Pages are selected by the URL fragment (#/<page>) so navigation never hits
|
|
502
|
+
// the server and works identically at the origin root and under the Nano
|
|
503
|
+
// console's /console/app-view/<name>/ path-prefixed proxy (the hash is never
|
|
504
|
+
// sent upstream). An empty/invalid hash falls back to the home page. Only a
|
|
505
|
+
// safe id charset is accepted so the fragment can't smuggle a path/URL.
|
|
506
|
+
const PAGE_ID = /^[A-Za-z0-9_-]+$/;
|
|
507
|
+
function safePageId(value) {
|
|
508
|
+
return typeof value === "string" && PAGE_ID.test(value);
|
|
509
|
+
}
|
|
510
|
+
function currentPage() {
|
|
511
|
+
try {
|
|
512
|
+
const raw = decodeURIComponent((location.hash || "").replace(/^#\/?/, ""));
|
|
513
|
+
return safePageId(raw) ? raw : HOME;
|
|
514
|
+
} catch (e) {
|
|
515
|
+
return HOME;
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
let CURRENT = currentPage();
|
|
519
|
+
|
|
520
|
+
// Per-render teardown. Each dataGrid registers its poll interval + pc:refresh
|
|
521
|
+
// listener here; navigating to another page runs every disposer before the new
|
|
522
|
+
// page renders, so a switched-away grid stops polling (otherwise every visited
|
|
523
|
+
// page would leave a live setInterval fetching forever).
|
|
524
|
+
const disposers = [];
|
|
525
|
+
function teardown() {
|
|
526
|
+
while (disposers.length) {
|
|
527
|
+
try { disposers.pop()(); } catch (e) { /* best-effort cleanup */ }
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
|
|
443
531
|
// Theme bridge for the Nano console embed. The app runs in a sandboxed,
|
|
444
532
|
// same-origin iframe, so the console's --nano-* custom properties and its
|
|
445
533
|
// data-appearance never cascade in. Instead the console postMessages its
|
|
@@ -888,15 +976,71 @@ function renderDataGrid(node) {
|
|
|
888
976
|
}
|
|
889
977
|
}
|
|
890
978
|
document.addEventListener("pc:refresh", refresh);
|
|
979
|
+
disposers.push(() => document.removeEventListener("pc:refresh", refresh));
|
|
891
980
|
// Skip the automatic poll while the user is typing in the grid — an explicit pc:refresh
|
|
892
981
|
// (e.g. after submitting an answer) still runs — so rows never shift under an in-progress
|
|
893
|
-
// answer and the detail input keeps focus across ticks.
|
|
894
|
-
|
|
982
|
+
// answer and the detail input keeps focus across ticks. The interval is registered as a
|
|
983
|
+
// disposer so a page switch (hashchange → renderPage → teardown) stops it.
|
|
984
|
+
if (p.refreshMs && p.refreshMs > 0) {
|
|
985
|
+
const timer = setInterval(() => { if (!editingInGrid()) refresh(); }, p.refreshMs);
|
|
986
|
+
disposers.push(() => clearInterval(timer));
|
|
987
|
+
}
|
|
895
988
|
refresh();
|
|
896
989
|
return card;
|
|
897
990
|
}
|
|
898
991
|
|
|
899
|
-
|
|
992
|
+
// A navigation node — a horizontal menu bar (variant "bar", default) or a
|
|
993
|
+
// vertical side rail (variant "rail"). Each item links either to another page
|
|
994
|
+
// ({ label, page } -> an in-app #/<page> hash link, highlighted when it's the
|
|
995
|
+
// current page) or to an external URL ({ label, href } -> a hardened new-tab
|
|
996
|
+
// link; only http(s) is honoured). With items:"auto" (or omitted) the nav
|
|
997
|
+
// lists every page from the /app/pages index, using each page's title.
|
|
998
|
+
function navLink(item) {
|
|
999
|
+
const isPage = safePageId(item.page);
|
|
1000
|
+
const isExt = !isPage && typeof item.href === "string" && /^https?:\/\//i.test(item.href);
|
|
1001
|
+
if (!isPage && !isExt) return null;
|
|
1002
|
+
const attrs = { class: "pc-nav-link" };
|
|
1003
|
+
if (isPage) {
|
|
1004
|
+
attrs.href = "#/" + encodeURIComponent(item.page);
|
|
1005
|
+
if (item.page === CURRENT) { attrs.class += " active"; attrs["aria-current"] = "page"; }
|
|
1006
|
+
} else {
|
|
1007
|
+
attrs.href = item.href; attrs.target = "_blank"; attrs.rel = "noopener noreferrer";
|
|
1008
|
+
}
|
|
1009
|
+
const kids = [];
|
|
1010
|
+
if (item.icon != null) kids.push(el("span", { class: "pc-nav-icon" }, String(item.icon)));
|
|
1011
|
+
kids.push(el("span", { class: "pc-nav-label" }, String(item.label != null ? item.label : (item.page || item.href))));
|
|
1012
|
+
return el("a", attrs, ...kids);
|
|
1013
|
+
}
|
|
1014
|
+
function fillNav(list, items) {
|
|
1015
|
+
list.replaceChildren();
|
|
1016
|
+
let n = 0;
|
|
1017
|
+
for (const item of items || []) {
|
|
1018
|
+
if (!item || typeof item !== "object") continue;
|
|
1019
|
+
const a = navLink(item);
|
|
1020
|
+
if (a) { list.append(a); n++; }
|
|
1021
|
+
}
|
|
1022
|
+
if (!n) list.append(el("span", { class: "pc-nav-empty" }, "No pages"));
|
|
1023
|
+
}
|
|
1024
|
+
function renderNav(node) {
|
|
1025
|
+
const p = node.props || {};
|
|
1026
|
+
const rail = p.variant === "rail";
|
|
1027
|
+
const nav = el("nav", { class: rail ? "pc-nav pc-rail" : "pc-nav pc-bar" });
|
|
1028
|
+
if (p.title != null) nav.append(el("div", { class: "pc-nav-title" }, String(p.title)));
|
|
1029
|
+
const list = el("div", { class: "pc-nav-items" });
|
|
1030
|
+
nav.append(list);
|
|
1031
|
+
const items = p.items;
|
|
1032
|
+
if (Array.isArray(items)) {
|
|
1033
|
+
fillNav(list, items);
|
|
1034
|
+
} else {
|
|
1035
|
+
// "auto" (or unspecified): enumerate every page and link to each.
|
|
1036
|
+
getJSON("/app/pages")
|
|
1037
|
+
.then((res) => fillNav(list, (res.pages || []).map((pg) => ({ label: pg.title || pg.id, page: pg.id }))))
|
|
1038
|
+
.catch(() => list.append(el("span", { class: "pc-nav-empty" }, "No pages")));
|
|
1039
|
+
}
|
|
1040
|
+
return nav;
|
|
1041
|
+
}
|
|
1042
|
+
|
|
1043
|
+
const RENDERERS = { text: renderText, actionForm: renderActionForm, dataGrid: renderDataGrid, nav: renderNav };
|
|
900
1044
|
|
|
901
1045
|
// Durable per-node UI state. localStorage is keyed by the home page id + node
|
|
902
1046
|
// id so two grids on the same page (or the same grid across pages) don't clash,
|
|
@@ -951,7 +1095,7 @@ function makeCollapsible(node, card) {
|
|
|
951
1095
|
chevron,
|
|
952
1096
|
el("span", { class: "pc-collapse-title" }, titleText),
|
|
953
1097
|
);
|
|
954
|
-
const storageKey = "pc:collapsed:" +
|
|
1098
|
+
const storageKey = "pc:collapsed:" + CURRENT + ":" + (node.id || titleText);
|
|
955
1099
|
let collapsed = readCollapsed(storageKey, !!props.defaultCollapsed);
|
|
956
1100
|
function apply() {
|
|
957
1101
|
body.hidden = collapsed;
|
|
@@ -968,16 +1112,30 @@ function makeCollapsible(node, card) {
|
|
|
968
1112
|
return container;
|
|
969
1113
|
}
|
|
970
1114
|
|
|
971
|
-
|
|
1115
|
+
// Render the page named by the current hash route. A rail nav is lifted into a
|
|
1116
|
+
// left <aside> and the rest of the nodes flow into a content column; otherwise
|
|
1117
|
+
// (bar nav or none) nodes render in document order. Re-runs on every hashchange,
|
|
1118
|
+
// tearing down the previous page's grid polls first.
|
|
1119
|
+
async function renderPage() {
|
|
1120
|
+
teardown();
|
|
1121
|
+
CURRENT = currentPage();
|
|
972
1122
|
try {
|
|
973
|
-
const doc = await getJSON("/app/pages/" + encodeURIComponent(
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
);
|
|
1123
|
+
const doc = await getJSON("/app/pages/" + encodeURIComponent(CURRENT));
|
|
1124
|
+
document.title = (doc && doc.title) || "Urban App";
|
|
1125
|
+
const nodes = (doc && doc.nodes) || [];
|
|
1126
|
+
const built = nodes.map((n) => ({ n, node: makeCollapsible(n, (RENDERERS[n.type] || (() => el("div")))(n)) }));
|
|
1127
|
+
const rail = built.find((b) => b.n.type === "nav" && b.n.props && b.n.props.variant === "rail");
|
|
1128
|
+
if (rail) {
|
|
1129
|
+
const col = el("div", { class: "pc-main-col" });
|
|
1130
|
+
for (const b of built) if (b !== rail) col.append(b.node);
|
|
1131
|
+
root.replaceChildren(el("div", { class: "pc-layout" }, el("aside", { class: "pc-rail-wrap" }, rail.node), col));
|
|
1132
|
+
} else {
|
|
1133
|
+
root.replaceChildren(...built.map((b) => b.node));
|
|
1134
|
+
}
|
|
978
1135
|
} catch (e) {
|
|
979
|
-
root.replaceChildren(el("p", { class: "pc-msg err" }, "Failed to load page: " + String(e.message || e)));
|
|
1136
|
+
root.replaceChildren(el("p", { class: "pc-msg err" }, "Failed to load page: " + String((e && e.message) || e)));
|
|
980
1137
|
}
|
|
981
1138
|
}
|
|
982
|
-
|
|
1139
|
+
window.addEventListener("hashchange", renderPage);
|
|
1140
|
+
renderPage();
|
|
983
1141
|
`;
|