@nervur-org/dock 0.3.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.
- package/GETTING_STARTED.md +128 -0
- package/LICENSE +202 -0
- package/NOTICE +6 -0
- package/README.md +37 -0
- package/api/quo-api.md +196 -0
- package/api/route.ts +227 -0
- package/beings/GLOSSARY.md +118 -0
- package/beings/WORLDS.md +567 -0
- package/beings/avatar.ts +101 -0
- package/beings/carry.ts +159 -0
- package/beings/clock.ts +552 -0
- package/beings/courier.ts +107 -0
- package/beings/desk.ts +128 -0
- package/beings/doorbell.ts +129 -0
- package/beings/envoy.ts +218 -0
- package/beings/index.ts +16 -0
- package/beings/join.ts +51 -0
- package/beings/link.ts +104 -0
- package/beings/look.ts +139 -0
- package/beings/porter.ts +43 -0
- package/beings/presence.ts +39 -0
- package/beings/quo-app-bells.md +280 -0
- package/beings/quo-dock.md +1942 -0
- package/beings/setup.ts +45 -0
- package/beings/side.ts +60 -0
- package/beings/user.ts +459 -0
- package/beings/way.ts +65 -0
- package/cli/bundles.ts +20 -0
- package/cli/client.ts +73 -0
- package/cli/daemon.ts +581 -0
- package/cli/estate/Caddyfile +33 -0
- package/cli/estate/quo.service +35 -0
- package/cli/estate/systems.quo.plist +44 -0
- package/cli/estate.ts +48 -0
- package/cli/folder.ts +27 -0
- package/cli/oauth.ts +32 -0
- package/cli/pilot.ts +38 -0
- package/cli/quo-dock.md +1942 -0
- package/cli/quo.ts +203 -0
- package/cli/stand.ts +160 -0
- package/dist/api/route.d.ts +21 -0
- package/dist/api/route.js +193 -0
- package/dist/beings/avatar.d.ts +32 -0
- package/dist/beings/avatar.js +100 -0
- package/dist/beings/carry.d.ts +12 -0
- package/dist/beings/carry.js +174 -0
- package/dist/beings/clock.d.ts +161 -0
- package/dist/beings/clock.js +528 -0
- package/dist/beings/courier.d.ts +50 -0
- package/dist/beings/courier.js +96 -0
- package/dist/beings/desk.d.ts +53 -0
- package/dist/beings/desk.js +135 -0
- package/dist/beings/doorbell.d.ts +78 -0
- package/dist/beings/doorbell.js +133 -0
- package/dist/beings/envoy.d.ts +54 -0
- package/dist/beings/envoy.js +211 -0
- package/dist/beings/index.d.ts +14 -0
- package/dist/beings/index.js +16 -0
- package/dist/beings/join.d.ts +15 -0
- package/dist/beings/join.js +42 -0
- package/dist/beings/link.d.ts +14 -0
- package/dist/beings/link.js +91 -0
- package/dist/beings/look.d.ts +30 -0
- package/dist/beings/look.js +104 -0
- package/dist/beings/porter.d.ts +14 -0
- package/dist/beings/porter.js +41 -0
- package/dist/beings/presence.d.ts +8 -0
- package/dist/beings/presence.js +21 -0
- package/dist/beings/setup.d.ts +3 -0
- package/dist/beings/setup.js +39 -0
- package/dist/beings/side.d.ts +32 -0
- package/dist/beings/side.js +37 -0
- package/dist/beings/user.d.ts +315 -0
- package/dist/beings/user.js +462 -0
- package/dist/beings/way.d.ts +19 -0
- package/dist/beings/way.js +28 -0
- package/dist/cli/bundles.d.ts +5 -0
- package/dist/cli/bundles.js +17 -0
- package/dist/cli/client.d.ts +15 -0
- package/dist/cli/client.js +58 -0
- package/dist/cli/daemon.d.ts +50 -0
- package/dist/cli/daemon.js +584 -0
- package/dist/cli/estate/Caddyfile +33 -0
- package/dist/cli/estate/quo.service +35 -0
- package/dist/cli/estate/systems.quo.plist +44 -0
- package/dist/cli/estate.d.ts +5 -0
- package/dist/cli/estate.js +50 -0
- package/dist/cli/folder.d.ts +2 -0
- package/dist/cli/folder.js +25 -0
- package/dist/cli/oauth.d.ts +2 -0
- package/dist/cli/oauth.js +32 -0
- package/dist/cli/pilot.d.ts +5 -0
- package/dist/cli/pilot.js +22 -0
- package/dist/cli/quo.d.ts +2 -0
- package/dist/cli/quo.js +201 -0
- package/dist/cli/stand.d.ts +37 -0
- package/dist/cli/stand.js +110 -0
- package/dist/dock/agents.d.ts +102 -0
- package/dist/dock/agents.js +105 -0
- package/dist/dock/apns.d.ts +27 -0
- package/dist/dock/apns.js +98 -0
- package/dist/dock/console.d.ts +6 -0
- package/dist/dock/console.js +20 -0
- package/dist/dock/dialers.d.ts +56 -0
- package/dist/dock/dialers.js +75 -0
- package/dist/dock/faculty.d.ts +24 -0
- package/dist/dock/faculty.js +142 -0
- package/dist/dock/fcm.d.ts +13 -0
- package/dist/dock/fcm.js +77 -0
- package/dist/dock/grants.d.ts +41 -0
- package/dist/dock/grants.js +99 -0
- package/dist/dock/http2.d.ts +4 -0
- package/dist/dock/http2.js +68 -0
- package/dist/dock/index.d.ts +41 -0
- package/dist/dock/index.js +113 -0
- package/dist/dock/local.d.ts +13 -0
- package/dist/dock/local.js +44 -0
- package/dist/dock/location.d.ts +19 -0
- package/dist/dock/location.js +11 -0
- package/dist/dock/notifications.d.ts +38 -0
- package/dist/dock/notifications.js +60 -0
- package/dist/dock/push.d.ts +79 -0
- package/dist/dock/push.js +132 -0
- package/dist/dock/service.d.ts +4 -0
- package/dist/dock/service.js +107 -0
- package/dist/dock/timer.d.ts +52 -0
- package/dist/dock/timer.js +149 -0
- package/dist/dock/webpush.d.ts +12 -0
- package/dist/dock/webpush.js +144 -0
- package/dist/harbor/browser.d.ts +15 -0
- package/dist/harbor/browser.js +82 -0
- package/dist/harbor/capacitor.d.ts +28 -0
- package/dist/harbor/capacitor.js +218 -0
- package/dist/harbor/disk.d.ts +27 -0
- package/dist/harbor/disk.js +249 -0
- package/dist/harbor/edge/alarm.d.ts +10 -0
- package/dist/harbor/edge/alarm.js +38 -0
- package/dist/harbor/edge/edge.d.ts +54 -0
- package/dist/harbor/edge/edge.js +370 -0
- package/dist/harbor/edge/exercise.d.ts +7 -0
- package/dist/harbor/edge/exercise.js +232 -0
- package/dist/harbor/edge/given.d.ts +26 -0
- package/dist/harbor/edge/given.js +42 -0
- package/dist/harbor/edge/platform.d.ts +39 -0
- package/dist/harbor/edge/storage.d.ts +30 -0
- package/dist/harbor/edge/storage.js +180 -0
- package/dist/harbor/edge/worker.d.ts +17 -0
- package/dist/harbor/edge/worker.js +67 -0
- package/dist/harbor/files.d.ts +15 -0
- package/dist/harbor/files.js +220 -0
- package/dist/harbor/http.d.ts +19 -0
- package/dist/harbor/http.js +177 -0
- package/dist/harbor/idb.d.ts +15 -0
- package/dist/harbor/idb.js +0 -0
- package/dist/harbor/quo.d.ts +10 -0
- package/dist/harbor/quo.js +42 -0
- package/dist/harbor/resolve.d.ts +16 -0
- package/dist/harbor/resolve.js +17 -0
- package/dist/harbor/seal.d.ts +3 -0
- package/dist/harbor/seal.js +25 -0
- package/dist/harbor/sealed.d.ts +35 -0
- package/dist/harbor/sealed.js +162 -0
- package/dist/harbor/tauri.d.ts +16 -0
- package/dist/harbor/tauri.js +59 -0
- package/dist/harbor/ward.d.ts +9 -0
- package/dist/harbor/ward.js +15 -0
- package/dist/human/app.d.ts +32 -0
- package/dist/human/app.js +527 -0
- package/dist/human/apps.d.ts +10 -0
- package/dist/human/apps.js +29 -0
- package/dist/human/dom.d.ts +2 -0
- package/dist/human/dom.js +15 -0
- package/dist/human/door.d.ts +19 -0
- package/dist/human/door.js +92 -0
- package/dist/human/guest.d.ts +3 -0
- package/dist/human/guest.js +33 -0
- package/dist/human/html.d.ts +55 -0
- package/dist/human/html.js +451 -0
- package/dist/human/local.d.ts +7 -0
- package/dist/human/local.js +10 -0
- package/dist/human/mark.d.ts +8 -0
- package/dist/human/mark.js +47 -0
- package/dist/human/move.d.ts +17 -0
- package/dist/human/move.js +90 -0
- package/dist/human/screen.d.ts +19 -0
- package/dist/human/screen.js +119 -0
- package/dist/human/style.d.ts +2 -0
- package/dist/human/style.js +60 -0
- package/dist/human/tab.bundle.txt +4775 -0
- package/dist/human/tab.d.ts +15 -0
- package/dist/human/tab.js +409 -0
- package/dist/human/tree.d.ts +50 -0
- package/dist/human/tree.js +157 -0
- package/dist/human/wake.d.ts +34 -0
- package/dist/human/wake.js +179 -0
- package/dist/human/web.d.ts +33 -0
- package/dist/human/web.js +184 -0
- package/dist/human/worker.bundle.txt +3868 -0
- package/dist/human/worker.d.ts +1 -0
- package/dist/human/worker.js +81 -0
- package/dist/human/worlds.d.ts +24 -0
- package/dist/human/worlds.js +99 -0
- package/dist/mcp/agent.d.ts +10 -0
- package/dist/mcp/agent.js +78 -0
- package/dist/mcp/allow.d.ts +20 -0
- package/dist/mcp/allow.js +88 -0
- package/dist/mcp/http.d.ts +27 -0
- package/dist/mcp/http.js +92 -0
- package/dist/mcp/oauth.d.ts +91 -0
- package/dist/mcp/oauth.js +241 -0
- package/dist/mcp/route.d.ts +15 -0
- package/dist/mcp/route.js +42 -0
- package/dist/mcp/runner.d.ts +45 -0
- package/dist/mcp/runner.js +99 -0
- package/dist/mcp/server.d.ts +11 -0
- package/dist/mcp/server.js +102 -0
- package/dock/agents.ts +121 -0
- package/dock/apns.ts +138 -0
- package/dock/console.ts +23 -0
- package/dock/dialers.ts +85 -0
- package/dock/faculty.ts +148 -0
- package/dock/fcm.ts +103 -0
- package/dock/grants.ts +111 -0
- package/dock/http2.ts +70 -0
- package/dock/index.ts +133 -0
- package/dock/local.ts +48 -0
- package/dock/location.ts +22 -0
- package/dock/notifications.ts +67 -0
- package/dock/push.ts +161 -0
- package/dock/quo-app-bells.md +280 -0
- package/dock/quo-dock.md +1942 -0
- package/dock/service.ts +100 -0
- package/dock/timer.ts +174 -0
- package/dock/webpush.ts +182 -0
- package/harbor/browser.ts +97 -0
- package/harbor/capacitor.ts +223 -0
- package/harbor/disk.ts +237 -0
- package/harbor/edge/alarm.ts +48 -0
- package/harbor/edge/edge.ts +407 -0
- package/harbor/edge/exercise.ts +241 -0
- package/harbor/edge/given.ts +106 -0
- package/harbor/edge/platform.d.ts +39 -0
- package/harbor/edge/storage.ts +179 -0
- package/harbor/edge/text.d.ts +15 -0
- package/harbor/edge/worker.ts +70 -0
- package/harbor/edge/wrangler.toml +30 -0
- package/harbor/files.ts +221 -0
- package/harbor/http.ts +188 -0
- package/harbor/idb.ts +0 -0
- package/harbor/quo-harbor.md +850 -0
- package/harbor/quo.ts +44 -0
- package/harbor/resolve.ts +31 -0
- package/harbor/seal.ts +26 -0
- package/harbor/sealed.ts +183 -0
- package/harbor/tauri.ts +79 -0
- package/harbor/ward.ts +28 -0
- package/human/app.ts +580 -0
- package/human/apps.ts +47 -0
- package/human/dom.ts +21 -0
- package/human/door.ts +113 -0
- package/human/guest.ts +34 -0
- package/human/html.ts +490 -0
- package/human/local.ts +30 -0
- package/human/mark.ts +53 -0
- package/human/move.ts +122 -0
- package/human/quo-human.md +1042 -0
- package/human/screen.ts +136 -0
- package/human/style.ts +63 -0
- package/human/tab.bundle.txt +4775 -0
- package/human/tab.ts +446 -0
- package/human/tree.ts +217 -0
- package/human/wake.ts +191 -0
- package/human/web.ts +259 -0
- package/human/worker.bundle.txt +3868 -0
- package/human/worker.ts +124 -0
- package/human/worlds.ts +127 -0
- package/mcp/agent.ts +94 -0
- package/mcp/allow.ts +131 -0
- package/mcp/http.ts +92 -0
- package/mcp/oauth.ts +270 -0
- package/mcp/quo-mcp.md +343 -0
- package/mcp/route.ts +45 -0
- package/mcp/runner.ts +136 -0
- package/mcp/server.ts +101 -0
- package/package.json +121 -0
package/human/tab.ts
ADDED
|
@@ -0,0 +1,446 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
// The tab: a page on a world's web. that is a device of its own. The tab
|
|
3
|
+
// boots a browser harbor on a seed minted into IndexedDB the first time,
|
|
4
|
+
// and is a browser of worlds and of the relations in them, `worlds.ts`: one
|
|
5
|
+
// local ward per far world, one avatar per relation, a switcher over both.
|
|
6
|
+
// A switch is a switch of avatar and nothing more.
|
|
7
|
+
//
|
|
8
|
+
// Three ways in and there is no fourth, the same act each time, a knock
|
|
9
|
+
// that ends in a standing, and each one a fresh avatar:
|
|
10
|
+
//
|
|
11
|
+
// a link the page with the invitation in its fragment, `#quo=...`,
|
|
12
|
+
// read and stripped before anything else runs, then joined.
|
|
13
|
+
// The invitation names its ward, so it is joined in that
|
|
14
|
+
// world's local ward whatever page it was opened on, and a
|
|
15
|
+
// link opened while already in is one more relation.
|
|
16
|
+
// a proof the page with a proof in its fragment, `#proof=<kind>.<token>`,
|
|
17
|
+
// read and stripped the same way. A proof names no ward, so it
|
|
18
|
+
// is for the world whose page it was opened on: the tab knocks
|
|
19
|
+
// at that world's public being with it, and what she trades it
|
|
20
|
+
// for is an invitation, joined like any other. This is the way
|
|
21
|
+
// in a world sent out itself, by mail or by message.
|
|
22
|
+
// a guest the world's public being, whatever class she is, rendered
|
|
23
|
+
// as a page by the same screen; a form on it whose answer is
|
|
24
|
+
// an invitation is the way in.
|
|
25
|
+
//
|
|
26
|
+
// Nothing is typed to get in, ever: no password, no account. A ward with
|
|
27
|
+
// nobody at its door shows the door page, `door.ts`, and a link is the only
|
|
28
|
+
// way. From then on there is no cookie and no token anywhere; the seed and
|
|
29
|
+
// the standings are in the tab's store, and a reload asks nothing. The screen
|
|
30
|
+
// is the human side in `screen.ts` over the DOM surface in `dom.ts`. This
|
|
31
|
+
// file is the shell: the harbor, the config, the switcher and the ways in,
|
|
32
|
+
// and it hands one avatar at a time to the side.
|
|
33
|
+
import type { Invitation, JsonObject } from '@nervur-org/nervur';
|
|
34
|
+
import { BrowserHarbor } from '../harbor/browser.ts';
|
|
35
|
+
import { USER } from '../beings/avatar.ts';
|
|
36
|
+
import { isInvitation } from '@nervur-org/nervur';
|
|
37
|
+
import { parse, parseProof, strip } from '../beings/link.ts';
|
|
38
|
+
import { giveWorld, type Giving } from './move.ts';
|
|
39
|
+
import { drained, subscribe } from './wake.ts';
|
|
40
|
+
import { screenSide } from './screen.ts';
|
|
41
|
+
import { refused } from './html.ts';
|
|
42
|
+
import { domSurface } from './dom.ts';
|
|
43
|
+
import { guest } from './guest.ts';
|
|
44
|
+
import { door, held } from './door.ts';
|
|
45
|
+
import { world, relations, fresh, localName, type Relation } from './worlds.ts';
|
|
46
|
+
import { local } from './local.ts';
|
|
47
|
+
import type { BeingClass } from '@nervur-org/nervur';
|
|
48
|
+
|
|
49
|
+
// What the page is told by the daemon that served it: the world's routes,
|
|
50
|
+
// every ward on that harbor by name, its pk and whether a public being is
|
|
51
|
+
// at its door, which of them this page is, if it is one's, and whether the
|
|
52
|
+
// world serves code for the tab.
|
|
53
|
+
// `bell` is the world's doorbell key, the public half of its VAPID pair,
|
|
54
|
+
// which a page subscribes to a push service with. A world with no doorbell
|
|
55
|
+
// sends none, and a device there is reachable only while it holds a line.
|
|
56
|
+
export type Config = { quo: string; web: string; wards: Record<string, { pk: string; public: boolean }>; ward?: string; being?: string; beings?: boolean; bell?: string };
|
|
57
|
+
|
|
58
|
+
// What the tab remembers between pages, beside the harbor: the worlds it
|
|
59
|
+
// has joined, by pk, where each lives and what it is called; which relation
|
|
60
|
+
// is on screen in each; and what each relation is called. A tab with no
|
|
61
|
+
// storage still works, and forgets.
|
|
62
|
+
type Worlds = Record<string, { url: string; name: string }>;
|
|
63
|
+
const kept = <T>(key: string, fallback: T): T => {
|
|
64
|
+
try {
|
|
65
|
+
return (JSON.parse(localStorage.getItem(key) ?? 'null') as T | null) ?? fallback;
|
|
66
|
+
} catch {
|
|
67
|
+
return fallback;
|
|
68
|
+
}
|
|
69
|
+
};
|
|
70
|
+
const keep = (key: string, value: unknown) => {
|
|
71
|
+
try {
|
|
72
|
+
localStorage.setItem(key, JSON.stringify(value));
|
|
73
|
+
} catch {
|
|
74
|
+
/* forgets */
|
|
75
|
+
}
|
|
76
|
+
};
|
|
77
|
+
const WORLDS = 'quo-worlds';
|
|
78
|
+
const worlds = (): Worlds => kept(WORLDS, {});
|
|
79
|
+
const remember = (pk: string, url: string, name: string) => keep(WORLDS, { ...worlds(), [pk]: { url, name } });
|
|
80
|
+
const AT = (pk: string) => `quo-at:${pk}`;
|
|
81
|
+
const NAMES = (pk: string) => `quo-names:${pk}`;
|
|
82
|
+
|
|
83
|
+
const el = <K extends keyof HTMLElementTagNameMap>(tag: K, text = '', attrs: Record<string, string> = {}): HTMLElementTagNameMap[K] => {
|
|
84
|
+
const e = document.createElement(tag);
|
|
85
|
+
if (text) e.textContent = text;
|
|
86
|
+
for (const [k, v] of Object.entries(attrs)) e.setAttribute(k, v);
|
|
87
|
+
return e;
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
export async function start(cfg: Config, root: HTMLElement = document.body): Promise<void> {
|
|
91
|
+
// The link, first and once: the invitation, or the proof a world sent
|
|
92
|
+
// out, leaves the address bar before any other code sees it.
|
|
93
|
+
const linked = parse(location.hash);
|
|
94
|
+
const proved = parseProof(location.hash);
|
|
95
|
+
if (linked || proved) history.replaceState(null, '', location.pathname + location.search + strip(location.hash));
|
|
96
|
+
|
|
97
|
+
const nav = el('nav', '', { class: 'worlds nv-strip' });
|
|
98
|
+
const who = el('nav', '', { class: 'relations nv-row' });
|
|
99
|
+
const status = el('p', 'booting', { class: 'state nv-t-quiet' });
|
|
100
|
+
const screen = el('div');
|
|
101
|
+
root.append(nav, who, status, screen);
|
|
102
|
+
const say = (s: string) => (status.textContent = s);
|
|
103
|
+
|
|
104
|
+
// The harbor in the tab: one database per origin, one ward per world.
|
|
105
|
+
// The world's code first, when its origin serves any: the classes for
|
|
106
|
+
// the tab, handed to the harbor beside the built-in ones before any ward
|
|
107
|
+
// boots, since a being kept from the last visit is booted by class name.
|
|
108
|
+
// A world that serves none has no beings in the tab, and the page is the
|
|
109
|
+
// far being's alone.
|
|
110
|
+
const harbor = new BrowserHarbor('quo');
|
|
111
|
+
const classes = cfg.beings
|
|
112
|
+
? await import(`${cfg.web}/beings.js`).then((mod: Record<string, unknown>) => {
|
|
113
|
+
const out: Record<string, BeingClass> = {};
|
|
114
|
+
for (const [name, v] of Object.entries(mod)) if (typeof v === 'function' && 'prototype' in v) out[name] = v as BeingClass;
|
|
115
|
+
return out;
|
|
116
|
+
})
|
|
117
|
+
: {};
|
|
118
|
+
Object.assign(harbor.classes, classes);
|
|
119
|
+
// A device is one harbor, and this browser may already be holding it in
|
|
120
|
+
// another tab. That tab is the device; this one is a page, and it says
|
|
121
|
+
// so here rather than sitting on the word `booting` with the refusal in a
|
|
122
|
+
// console nobody has open.
|
|
123
|
+
try {
|
|
124
|
+
await harbor.boot();
|
|
125
|
+
} catch (e) {
|
|
126
|
+
status.remove();
|
|
127
|
+
screen.innerHTML = held({ world: cfg.ward, host: new URL(cfg.web).host });
|
|
128
|
+
throw e; // the terrain still hears it: a page that says so is not a page that succeeded
|
|
129
|
+
}
|
|
130
|
+
harbor.dial(cfg.quo);
|
|
131
|
+
|
|
132
|
+
// The doorbell. A world with a bell key is a world that can wake this
|
|
133
|
+
// device when it is closed, and the ear is the service worker on this
|
|
134
|
+
// origin; a device already subscribed says so again silently, and one
|
|
135
|
+
// that has never been asked is offered a button rather than a prompt on
|
|
136
|
+
// arrival. The ring arrives at the worker: with this page open the worker
|
|
137
|
+
// hands it a port, and the page wakes its dialers, drains and answers
|
|
138
|
+
// what it found, since the harbor is held here and nowhere else.
|
|
139
|
+
const pks = Object.values(cfg.wards).map((w) => w.pk);
|
|
140
|
+
let bell = cfg.bell ? await subscribe(cfg.web, cfg.bell) : null;
|
|
141
|
+
globalThis.navigator?.serviceWorker?.addEventListener('message', (e: MessageEvent) => {
|
|
142
|
+
if ((e.data as { quo?: string } | null)?.quo !== 'wake') return;
|
|
143
|
+
const port = e.ports[0];
|
|
144
|
+
void (async () => {
|
|
145
|
+
harbor.wake();
|
|
146
|
+
port?.postMessage({ missed: await drained(harbor, pks) });
|
|
147
|
+
})();
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
// The world this page is about: the link's, since an invitation names
|
|
151
|
+
// its ward, or the page's own. A link for a world not on this harbor is
|
|
152
|
+
// a link to the wrong page, and says so.
|
|
153
|
+
const nameOf = (pk: string) => Object.keys(cfg.wards).find((n) => cfg.wards[n].pk === pk);
|
|
154
|
+
const pk = linked?.ward ?? (cfg.ward ? cfg.wards[cfg.ward]?.pk : undefined);
|
|
155
|
+
const wardName = pk ? nameOf(pk) : undefined;
|
|
156
|
+
const pageOf = (name: string) => `${new URL(cfg.web).pathname.replace(/\/$/, '')}/${encodeURIComponent(name)}`;
|
|
157
|
+
// The same page, whole. What leaves this tab, a remembered world or a link
|
|
158
|
+
// for another device, carries the address and not the path.
|
|
159
|
+
const addressOf = (name: string) => location.origin + pageOf(name);
|
|
160
|
+
|
|
161
|
+
const switcher = () => {
|
|
162
|
+
nav.replaceChildren();
|
|
163
|
+
const known = worlds();
|
|
164
|
+
for (const [k, w] of Object.entries(known)) {
|
|
165
|
+
const a = el('a', w.name || k.slice(0, 8), { href: w.url, class: 'nv-chip' });
|
|
166
|
+
if (k === pk) a.setAttribute('aria-current', 'page');
|
|
167
|
+
nav.append(a);
|
|
168
|
+
}
|
|
169
|
+
// a world this device is not in yet is named by its own page, not by the strip
|
|
170
|
+
if (!nav.childElementCount) nav.remove();
|
|
171
|
+
else if (!nav.isConnected) root.insertBefore(nav, root.firstChild);
|
|
172
|
+
};
|
|
173
|
+
switcher();
|
|
174
|
+
|
|
175
|
+
if (!pk || !wardName) {
|
|
176
|
+
say(linked ? 'this link is for a world that does not live here' : 'choose a world');
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
179
|
+
if (linked && location.pathname !== pageOf(wardName)) history.replaceState(null, '', pageOf(wardName) + location.search + location.hash);
|
|
180
|
+
const ward = await world(harbor, pk);
|
|
181
|
+
const at = { name: wardName, pk, public: cfg.wards[wardName].public };
|
|
182
|
+
|
|
183
|
+
// The relations in this world, and which one is on screen.
|
|
184
|
+
let side: { close(): Promise<void> } | null = null;
|
|
185
|
+
let current: Relation | null = null;
|
|
186
|
+
const names = () => kept<Record<string, string>>(NAMES(pk), {});
|
|
187
|
+
const people = () => {
|
|
188
|
+
who.replaceChildren();
|
|
189
|
+
const rels = relations(ward);
|
|
190
|
+
const called = names();
|
|
191
|
+
for (const r of rels) {
|
|
192
|
+
const b = el('button', called[r.key] ?? r.key, { type: 'button', class: 'nv-chip' });
|
|
193
|
+
if (r.key === current?.key) b.setAttribute('aria-current', 'true');
|
|
194
|
+
b.onclick = () => void inside(r, `in, as ${called[r.key] ?? r.key}`, called[r.key] ?? r.key);
|
|
195
|
+
who.append(b);
|
|
196
|
+
}
|
|
197
|
+
if (rels.length) {
|
|
198
|
+
const more = el('button', 'another way in', { type: 'button', class: 'another nv-chip' });
|
|
199
|
+
more.onclick = () => void atDoor();
|
|
200
|
+
who.append(more);
|
|
201
|
+
}
|
|
202
|
+
moving.hidden = rels.length === 0; // there is nothing to move out of a world this device is not in
|
|
203
|
+
};
|
|
204
|
+
|
|
205
|
+
// The world's beings in the tab, for one relation: one of each class,
|
|
206
|
+
// booted under her key the first time and found there after, since their
|
|
207
|
+
// cells are in the ward and come back with it. Each is a subject of her
|
|
208
|
+
// own with her own side in her own section, so a note she keeps and a
|
|
209
|
+
// form she shows are hers, painted by the same painter as the far page.
|
|
210
|
+
const locals: { close(): Promise<void> }[] = [];
|
|
211
|
+
const boot = async (rel: Relation) => {
|
|
212
|
+
for (const name of Object.keys(classes)) {
|
|
213
|
+
const key = `${rel.key}-${name}`;
|
|
214
|
+
// Booted the first time and found after. Boot refuses a key that has a
|
|
215
|
+
// row, so asking is how a caller learns she is already there, and a
|
|
216
|
+
// refusal is the same nothing-to-do as finding her.
|
|
217
|
+
if (!ward.being(key)) {
|
|
218
|
+
const out = (await ward.ask('boot', { key, class: name })) as { error?: string };
|
|
219
|
+
if (out.error) continue;
|
|
220
|
+
await ward.save();
|
|
221
|
+
}
|
|
222
|
+
if (!ward.being(key)) continue;
|
|
223
|
+
const section = el('section', '', { class: 'local', 'data-being': key });
|
|
224
|
+
screen.append(section);
|
|
225
|
+
// The ask pointer saves before it returns, so the side has nothing to do after one.
|
|
226
|
+
locals.push(await screenSide(local(ward, key), domSurface(section), { at: addressOf(at.name) }));
|
|
227
|
+
}
|
|
228
|
+
};
|
|
229
|
+
|
|
230
|
+
// In, as one relation: her page, then the world's beings for her. Every
|
|
231
|
+
// call rotates her keys and a same-ward ask never crosses the harbor, so
|
|
232
|
+
// the side saves after each.
|
|
233
|
+
const inside = async (rel: Relation, notice: string, called: string) => {
|
|
234
|
+
await side?.close();
|
|
235
|
+
for (const l of locals.splice(0)) await l.close();
|
|
236
|
+
status.remove();
|
|
237
|
+
root.querySelector('form.password')?.remove();
|
|
238
|
+
screen.replaceChildren();
|
|
239
|
+
current = rel;
|
|
240
|
+
keep(AT(pk), rel.key);
|
|
241
|
+
const mine = el('div', '', { class: 'far' });
|
|
242
|
+
screen.append(mine);
|
|
243
|
+
// the page's address: the world's page, or one being she carries as the whole page
|
|
244
|
+
const s = await screenSide(rel.avatar, domSurface(mine), {
|
|
245
|
+
after: () => ward.save(),
|
|
246
|
+
notice,
|
|
247
|
+
at: addressOf(at.name),
|
|
248
|
+
// A world that ended the relation is over, and the one act left is
|
|
249
|
+
// leaving. The tab says so and puts the way out where she is looking;
|
|
250
|
+
// it drops nothing, since one answer at one moment is never enough to
|
|
251
|
+
// delete a ward and the far world can never be asked to confirm it.
|
|
252
|
+
ended: async () => {
|
|
253
|
+
leave.textContent = 'leave this world';
|
|
254
|
+
leave.classList.add('only');
|
|
255
|
+
moving.scrollIntoView({ block: 'nearest' });
|
|
256
|
+
},
|
|
257
|
+
...(cfg.being ? { focus: cfg.being } : {}),
|
|
258
|
+
});
|
|
259
|
+
side = s;
|
|
260
|
+
const bp = s.model.blueprint;
|
|
261
|
+
const notesName = typeof (bp?.notes as JsonObject | null)?.name === 'string' ? ((bp!.notes as JsonObject).name as string) : '';
|
|
262
|
+
remember(pk, addressOf(at.name), s.model.look.name || notesName || at.name);
|
|
263
|
+
if (!names()[rel.key]) keep(NAMES(pk), { ...names(), [rel.key]: called });
|
|
264
|
+
// How this world wakes this device, said again whenever a relation comes
|
|
265
|
+
// on screen: a push service may hand a browser a new endpoint at any
|
|
266
|
+
// time, and a stale one rings an empty house.
|
|
267
|
+
if (bell) await rel.avatar.call('bell', { token: bell }).then(() => ward.save());
|
|
268
|
+
// The device is back, so it drains: whatever was pushed while this tab
|
|
269
|
+
// was closed is on her under this relation, and it lands on the avatar
|
|
270
|
+
// where a push would have landed. A tab with nothing waiting spends one
|
|
271
|
+
// ask and shows the same page.
|
|
272
|
+
await drained(harbor, [pk]);
|
|
273
|
+
switcher();
|
|
274
|
+
people();
|
|
275
|
+
if (!cfg.being) await boot(rel); // a being's page is hers alone: the world's tab beings stay on the world's page
|
|
276
|
+
};
|
|
277
|
+
|
|
278
|
+
// The way in, from any of the three: a fresh avatar joins, the ward is
|
|
279
|
+
// saved, and she is the one on screen.
|
|
280
|
+
const admit = async (inv: Invitation, notice: string, called: string) => {
|
|
281
|
+
say('knocking');
|
|
282
|
+
const rel = await fresh(ward);
|
|
283
|
+
const got = await rel.avatar.join(inv);
|
|
284
|
+
await ward.save(); // the knock went through her own door, which the harbor never sees
|
|
285
|
+
if (!('asks' in got)) {
|
|
286
|
+
say(`not in: ${got.error}`);
|
|
287
|
+
return false;
|
|
288
|
+
}
|
|
289
|
+
await inside(rel, notice, called);
|
|
290
|
+
return true;
|
|
291
|
+
};
|
|
292
|
+
|
|
293
|
+
// At the door. With a public being there, her describe as a page, and a
|
|
294
|
+
// form whose answer is an invitation lets the guest in. With nobody
|
|
295
|
+
// there, the door page: a link is the only way in, and nothing to type.
|
|
296
|
+
const atDoor = async () => {
|
|
297
|
+
await side?.close();
|
|
298
|
+
for (const l of locals.splice(0)) await l.close();
|
|
299
|
+
side = null;
|
|
300
|
+
current = null;
|
|
301
|
+
screen.replaceChildren();
|
|
302
|
+
if (!status.isConnected) root.insertBefore(status, screen);
|
|
303
|
+
people();
|
|
304
|
+
if (!at.public) {
|
|
305
|
+
status.remove();
|
|
306
|
+
screen.innerHTML = door({ world: at.name, host: new URL(cfg.web).host });
|
|
307
|
+
return;
|
|
308
|
+
}
|
|
309
|
+
say(`at the door of ${at.name}`);
|
|
310
|
+
const rel = await fresh(ward);
|
|
311
|
+
const gate = guest(rel.avatar, pk);
|
|
312
|
+
const s = await screenSide(gate, domSurface(screen), {
|
|
313
|
+
after: () => ward.save(),
|
|
314
|
+
notice: `a guest at ${at.name}: what she shows strangers`,
|
|
315
|
+
at: addressOf(at.name),
|
|
316
|
+
admit: async (inv) => {
|
|
317
|
+
await s.close();
|
|
318
|
+
await admit(inv, `in, as a guest of ${at.name}`, 'guest');
|
|
319
|
+
},
|
|
320
|
+
});
|
|
321
|
+
side = s;
|
|
322
|
+
status.remove(); // her page is up and says where the human stands; the status was for the wait
|
|
323
|
+
};
|
|
324
|
+
|
|
325
|
+
// Moving this world to another device. What the tab holds of a world is
|
|
326
|
+
// one local ward, and every standing won here is in it, so the move is
|
|
327
|
+
// that ward and nothing else: the world is never asked and never told.
|
|
328
|
+
// The other device shows an invitation, the human carries it here, and
|
|
329
|
+
// this tab is out of the world the moment the other one has it.
|
|
330
|
+
// Leaving. What this tab holds of a world is one local ward, and every
|
|
331
|
+
// standing won here is in it, so leaving is that ward dropped whole, seed
|
|
332
|
+
// and all, and the world forgotten from the list. The far world is never
|
|
333
|
+
// asked and never told, as WORLDS has it of a lost phone: it sees one
|
|
334
|
+
// device gone and nothing else. Nothing here is recoverable afterwards, so
|
|
335
|
+
// it is one press and never a consequence of an answer: a world that says
|
|
336
|
+
// the relation ended offers this and does not take it.
|
|
337
|
+
const forget = async () => {
|
|
338
|
+
await side?.close();
|
|
339
|
+
for (const l of locals.splice(0)) await l.close();
|
|
340
|
+
side = null;
|
|
341
|
+
keep(
|
|
342
|
+
WORLDS,
|
|
343
|
+
Object.fromEntries(Object.entries(worlds()).filter(([k]) => k !== pk)),
|
|
344
|
+
);
|
|
345
|
+
localStorage.removeItem(AT(pk));
|
|
346
|
+
localStorage.removeItem(NAMES(pk));
|
|
347
|
+
};
|
|
348
|
+
const leave = el('button', 'leave this world', { type: 'button', class: 'leave nv-button', 'data-variant': 'danger' });
|
|
349
|
+
leave.onclick = () =>
|
|
350
|
+
void (async () => {
|
|
351
|
+
await forget();
|
|
352
|
+
await harbor.drop(localName(pk));
|
|
353
|
+
location.reload(); // at the door of a world this device is out of
|
|
354
|
+
})();
|
|
355
|
+
|
|
356
|
+
const moving = el('div', '', { class: 'move' });
|
|
357
|
+
const offer = el('p', '', { class: 'invitation' });
|
|
358
|
+
const said = el('p', '', { class: 'quiet' });
|
|
359
|
+
const give = el('button', 'move this world to another device', { type: 'button', class: 'nv-button', 'data-variant': 'quiet' });
|
|
360
|
+
// Being woken is asked for and never sprung: a page that asks a browser
|
|
361
|
+
// for notifications the moment it opens is a page every browser punishes,
|
|
362
|
+
// and a human who has not said yet is offered a button instead.
|
|
363
|
+
const doorbell = el('button', 'let this world wake me', { type: 'button', class: 'doorbell nv-button', 'data-variant': 'quiet' });
|
|
364
|
+
doorbell.hidden = !cfg.bell || bell !== null;
|
|
365
|
+
doorbell.onclick = () =>
|
|
366
|
+
void (async () => {
|
|
367
|
+
bell = cfg.bell ? await subscribe(cfg.web, cfg.bell, true) : null;
|
|
368
|
+
doorbell.hidden = bell !== null;
|
|
369
|
+
if (bell && current) await current.avatar.call('bell', { token: bell }).then(() => ward.save());
|
|
370
|
+
})();
|
|
371
|
+
moving.append(give, leave, offer, said, doorbell);
|
|
372
|
+
let giving: Giving | null = null;
|
|
373
|
+
const stopGiving = async () => {
|
|
374
|
+
const g = giving;
|
|
375
|
+
giving = null;
|
|
376
|
+
offer.textContent = '';
|
|
377
|
+
give.textContent = 'move this world to another device';
|
|
378
|
+
await g?.close();
|
|
379
|
+
};
|
|
380
|
+
give.onclick = () =>
|
|
381
|
+
void (async () => {
|
|
382
|
+
if (giving) return stopGiving();
|
|
383
|
+
said.textContent = '';
|
|
384
|
+
giving = await giveWorld(harbor, { pk, at: addressOf(at.name), name: at.name }, cfg.quo);
|
|
385
|
+
offer.textContent = giving.offer;
|
|
386
|
+
give.textContent = 'stop offering it';
|
|
387
|
+
void giving.moved.then(async () => {
|
|
388
|
+
// it is on the other device now, and this one is out of the world:
|
|
389
|
+
// the standings went with the ward and nothing here is in it
|
|
390
|
+
await forget(); // the ward went with the move, so nothing is dropped here
|
|
391
|
+
location.reload(); // the page reloads at the door of a world this device is out of
|
|
392
|
+
});
|
|
393
|
+
})().catch((e: unknown) => (said.textContent = String(e)));
|
|
394
|
+
root.append(moving);
|
|
395
|
+
|
|
396
|
+
// A proof: the world's own way in, spent at her door. A proof names no
|
|
397
|
+
// ward, so it is for the world this page is, and what she trades it for
|
|
398
|
+
// is an invitation, joined like any other.
|
|
399
|
+
//
|
|
400
|
+
// `device` is the ask, and that name is a convention of the dock's rather
|
|
401
|
+
// than the desk's alone: a proof arrives in a fragment and not as a form,
|
|
402
|
+
// so there is nothing to draw and a name is the only way to hand it over.
|
|
403
|
+
// A public being of any class takes proofs by writing that ask; one that
|
|
404
|
+
// writes none answers this as an unknown ask, which is a world with no
|
|
405
|
+
// proof way in and is refused like any other refusal. What a proof is worth is the
|
|
406
|
+
// way in's word and never the desk's: the desk routes it by kind, and the
|
|
407
|
+
// being who verifies it says who it is for, how far she may go and
|
|
408
|
+
// whether it may be spent again. A world with nobody at her door trades
|
|
409
|
+
// nothing, and a refusal of any of those kinds leaves the human at the
|
|
410
|
+
// door with nothing taken and one line saying so.
|
|
411
|
+
if (proved && at.public) {
|
|
412
|
+
say('at the door');
|
|
413
|
+
const rel = await fresh(ward);
|
|
414
|
+
const got = await rel.avatar.knock({ ward: pk }, 'device', { proof: { kind: proved.kind, token: proved.token } });
|
|
415
|
+
await ward.save();
|
|
416
|
+
if (isInvitation(got) && (await admit(got, 'in, by the link', 'link'))) return;
|
|
417
|
+
// What came back and not a guess at it. A world refuses a way in for
|
|
418
|
+
// several different reasons and only one of them is that it was spent;
|
|
419
|
+
// `id taken` in particular is a device of that name already in this
|
|
420
|
+
// world, which is what a human meets when she has lost the device that
|
|
421
|
+
// held it, and telling her it was spent sends her looking for the wrong
|
|
422
|
+
// thing.
|
|
423
|
+
say(refused(got));
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
// A link: one more relation, whoever else is in. Then whoever was on
|
|
427
|
+
// screen last, or the door.
|
|
428
|
+
if (linked && (await admit(linked, 'in, by the link', 'link'))) return;
|
|
429
|
+
const rels = relations(ward);
|
|
430
|
+
const last = kept<string | null>(AT(pk), null);
|
|
431
|
+
const back = rels.find((r) => r.key === last) ?? rels[rels.length - 1];
|
|
432
|
+
if (back) {
|
|
433
|
+
const had = await back.avatar.tools();
|
|
434
|
+
await ward.save(); // her keys rotated on that ask, whatever it answered
|
|
435
|
+
if ('asks' in had) return inside(back, linked ? `not in by the link; in, as before` : 'in, as before', names()[back.key] ?? back.key);
|
|
436
|
+
say(`not in (${had.error})`);
|
|
437
|
+
}
|
|
438
|
+
await atDoor();
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
// The page hands the config in a JSON script, which a content security
|
|
442
|
+
// policy allows where an inline script is not.
|
|
443
|
+
const config = document.getElementById('quo');
|
|
444
|
+
if (config?.textContent) void start(JSON.parse(config.textContent) as Config);
|
|
445
|
+
|
|
446
|
+
export { USER };
|
package/human/tree.ts
ADDED
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
// The page grammar: a page as a tree of values, and its painter. One
|
|
3
|
+
// grammar, two sources: a far being answers `page` with a tree, values
|
|
4
|
+
// only, the way she answers `look`; a being running in the tab computes one
|
|
5
|
+
// from her cells. The screen paints either with the painter below, and a
|
|
6
|
+
// being that answers nothing gets the forms `html.ts` paints on its own.
|
|
7
|
+
//
|
|
8
|
+
// The vocabulary is closed, and a node that fails its shape is dropped, not
|
|
9
|
+
// fixed: nothing here can carry a selector, a stylesheet or a script, and an
|
|
10
|
+
// image is a data URI, so a far being can design her page and can never
|
|
11
|
+
// reach out of it. What a node names, an ask or a standing, must exist in
|
|
12
|
+
// her describe for this asker, or it paints as nothing: a page adds no
|
|
13
|
+
// right.
|
|
14
|
+
//
|
|
15
|
+
// One node names a place, and it is the one the side holds rather than the
|
|
16
|
+
// grammar. A `link` carries an address, because a human who must buy
|
|
17
|
+
// something, sign something or come back from a vendor has to be able to go
|
|
18
|
+
// there, and a being who could not say so would be a being every world
|
|
19
|
+
// works around. So the shape here admits only an absolute `http` or `https`
|
|
20
|
+
// address, and where it may point is not decided here: the painter is the
|
|
21
|
+
// side's, and the side is what knows which places this human already
|
|
22
|
+
// reaches. A painter that will not vouch for an address paints the address
|
|
23
|
+
// as words, so the human sees where she was being sent and goes there by
|
|
24
|
+
// her own hand or not at all.
|
|
25
|
+
//
|
|
26
|
+
// stack children one under another { kind: 'stack', of: [...], as?: list | grid | hero | feed }
|
|
27
|
+
// row children side by side, wrapping { kind: 'row', of: [...], as?: person }
|
|
28
|
+
// text words with a role { kind: 'text', text, role?: title | lead | body | quiet | label }
|
|
29
|
+
// image a picture as a data URI, never a URL { kind: 'image', src, alt? }
|
|
30
|
+
// link somewhere to go, held by the side { kind: 'link', href, text? }
|
|
31
|
+
// form one of her asks as a form { kind: 'form', ask }
|
|
32
|
+
// answer one of her asks' last answer, shown { kind: 'answer', ask, as?: auto | text | table | cards | list | people | feed }
|
|
33
|
+
// standing one standing she carries, a section { kind: 'standing', id, as?: door }
|
|
34
|
+
// standings every standing she carries { kind: 'standings' }
|
|
35
|
+
//
|
|
36
|
+
// A container may say what kind of thing it holds, and never how it looks:
|
|
37
|
+
// `list` is several of one thing as rows on one surface, `grid` is cards,
|
|
38
|
+
// `hero` is one thing on a lot of air, `feed` is what happened in order,
|
|
39
|
+
// and a row that is a `person` is a face and the words beside it. The
|
|
40
|
+
// painter holds every opinion about what each kind is on a screen, so
|
|
41
|
+
// every world's people, requests and contacts are one shape everywhere, and
|
|
42
|
+
// a being never carries a layout. An answer says its kind the same way:
|
|
43
|
+
// `people` are rows of { name, line?, picture? }, and `feed` is rows of
|
|
44
|
+
// { at, text }; a value that is not that shape is shown by its own shape.
|
|
45
|
+
import type { Json } from '@nervur-org/nervur';
|
|
46
|
+
import { escape } from './html.ts';
|
|
47
|
+
|
|
48
|
+
export type Role = 'title' | 'lead' | 'body' | 'quiet' | 'label';
|
|
49
|
+
export type As = 'auto' | 'text' | 'table' | 'cards' | 'list' | 'people' | 'feed';
|
|
50
|
+
export type Layout = 'list' | 'grid' | 'hero' | 'feed';
|
|
51
|
+
export type Node =
|
|
52
|
+
| { kind: 'stack'; of: Node[]; as?: Layout }
|
|
53
|
+
| { kind: 'row'; of: Node[]; as?: 'person' }
|
|
54
|
+
| { kind: 'text'; text: string; role?: Role }
|
|
55
|
+
| { kind: 'image'; src: string; alt?: string }
|
|
56
|
+
| { kind: 'link'; href: string; text?: string }
|
|
57
|
+
| { kind: 'form'; ask: string }
|
|
58
|
+
| { kind: 'answer'; ask: string; as?: As }
|
|
59
|
+
| { kind: 'standing'; id: string; as?: 'door' }
|
|
60
|
+
| { kind: 'standings' };
|
|
61
|
+
|
|
62
|
+
const ROLES = new Set<Role>(['title', 'lead', 'body', 'quiet', 'label']);
|
|
63
|
+
const AS = new Set<As>(['auto', 'text', 'table', 'cards', 'list', 'people', 'feed']);
|
|
64
|
+
const LAYOUTS = new Set<Layout>(['list', 'grid', 'hero', 'feed']);
|
|
65
|
+
const TEXT = 4000; // characters a text node may carry
|
|
66
|
+
const DEPTH = 12; // how deep a tree may nest
|
|
67
|
+
const WIDE = 200; // children a container may have
|
|
68
|
+
// A name in the grammar is a name in her describe, spelled as she spells
|
|
69
|
+
// it: an ask of her own, a standing's id, or a carried ask, `<id>-<name>`.
|
|
70
|
+
// A standing's id is the dock's to choose and carries a colon where the
|
|
71
|
+
// dock's own convention puts one, a way back to a device being `to:<id>`,
|
|
72
|
+
// so a shape that refused a colon would drop the section a device's own
|
|
73
|
+
// faculties are painted in. It stays a name and never a path: no slash, no
|
|
74
|
+
// space, and it is looked up in her describe before anything is painted.
|
|
75
|
+
const NAME = /^[\w.:-]{1,80}$/;
|
|
76
|
+
const HREF = 2000; // characters an address may be
|
|
77
|
+
|
|
78
|
+
// A value held to the grammar, or null. Children that fail are dropped and
|
|
79
|
+
// the container kept; a container with nothing left is kept empty, since an
|
|
80
|
+
// empty region is a design and not a fault.
|
|
81
|
+
export function sanitiseTree(v: Json, depth = 0): Node | null {
|
|
82
|
+
if (depth > DEPTH || v === null || typeof v !== 'object' || Array.isArray(v)) return null;
|
|
83
|
+
const o = v as Record<string, Json>;
|
|
84
|
+
switch (o.kind) {
|
|
85
|
+
case 'stack':
|
|
86
|
+
case 'row': {
|
|
87
|
+
const of = Array.isArray(o.of) ? o.of.slice(0, WIDE).map((c) => sanitiseTree(c, depth + 1)).filter((c): c is Node => c !== null) : [];
|
|
88
|
+
if (o.kind === 'row') return typeof o.as === 'string' && o.as === 'person' ? { kind: 'row', of, as: 'person' } : { kind: 'row', of };
|
|
89
|
+
return typeof o.as === 'string' && LAYOUTS.has(o.as as Layout) ? { kind: 'stack', of, as: o.as as Layout } : { kind: 'stack', of };
|
|
90
|
+
}
|
|
91
|
+
case 'text': {
|
|
92
|
+
if (typeof o.text !== 'string') return null;
|
|
93
|
+
const n: Node = { kind: 'text', text: o.text.slice(0, TEXT) };
|
|
94
|
+
if (typeof o.role === 'string' && ROLES.has(o.role as Role)) n.role = o.role as Role;
|
|
95
|
+
return n;
|
|
96
|
+
}
|
|
97
|
+
case 'image': {
|
|
98
|
+
if (typeof o.src !== 'string' || !/^data:image\/[a-z0-9.+-]+;base64,[A-Za-z0-9+/=]+$/i.test(o.src)) return null;
|
|
99
|
+
const n: Node = { kind: 'image', src: o.src };
|
|
100
|
+
if (typeof o.alt === 'string') n.alt = o.alt.slice(0, 200);
|
|
101
|
+
return n;
|
|
102
|
+
}
|
|
103
|
+
case 'link': {
|
|
104
|
+
// The shape only: an absolute address on the web, parsed rather than
|
|
105
|
+
// matched, so nothing that merely looks like one gets through. Where
|
|
106
|
+
// it may point is the painter's, not this file's.
|
|
107
|
+
if (typeof o.href !== 'string' || o.href.length > HREF) return null;
|
|
108
|
+
let url: URL;
|
|
109
|
+
try {
|
|
110
|
+
url = new URL(o.href);
|
|
111
|
+
} catch {
|
|
112
|
+
return null;
|
|
113
|
+
}
|
|
114
|
+
if (url.protocol !== 'https:' && url.protocol !== 'http:') return null;
|
|
115
|
+
const n: Node = { kind: 'link', href: url.href };
|
|
116
|
+
if (typeof o.text === 'string' && o.text.trim()) n.text = o.text.slice(0, 200);
|
|
117
|
+
return n;
|
|
118
|
+
}
|
|
119
|
+
case 'form':
|
|
120
|
+
return typeof o.ask === 'string' && NAME.test(o.ask) ? { kind: 'form', ask: o.ask } : null;
|
|
121
|
+
case 'answer': {
|
|
122
|
+
if (typeof o.ask !== 'string' || !NAME.test(o.ask)) return null;
|
|
123
|
+
const n: Node = { kind: 'answer', ask: o.ask };
|
|
124
|
+
if (typeof o.as === 'string' && AS.has(o.as as As)) n.as = o.as as As;
|
|
125
|
+
return n;
|
|
126
|
+
}
|
|
127
|
+
case 'standing':
|
|
128
|
+
if (typeof o.id !== 'string' || !NAME.test(o.id)) return null;
|
|
129
|
+
return o.as === 'door' ? { kind: 'standing', id: o.id, as: 'door' } : { kind: 'standing', id: o.id };
|
|
130
|
+
case 'standings':
|
|
131
|
+
return { kind: 'standings' };
|
|
132
|
+
default:
|
|
133
|
+
return null;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// The names a tree asks the answers of: what a side runs on her behalf so
|
|
138
|
+
// the page opens with what it shows, when the ask needs nothing typed.
|
|
139
|
+
export function answersIn(n: Node, out: string[] = []): string[] {
|
|
140
|
+
if (n.kind === 'answer') out.push(n.ask);
|
|
141
|
+
if (n.kind === 'stack' || n.kind === 'row') for (const c of n.of) answersIn(c, out);
|
|
142
|
+
return out;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// What the painter needs from whoever holds the describe and the answers:
|
|
146
|
+
// each returns HTML for a name it knows, and the empty string for one it
|
|
147
|
+
// does not, so a page can never show what the gate did not.
|
|
148
|
+
export type Painter = {
|
|
149
|
+
// An address the being named, and the words she wants on it. The painter
|
|
150
|
+
// vouches or it does not: an anchor where this human already reaches, and
|
|
151
|
+
// the address as words where she does not, so nothing is hidden either
|
|
152
|
+
// way.
|
|
153
|
+
link(href: string, text: string): string;
|
|
154
|
+
form(ask: string): string;
|
|
155
|
+
answer(ask: string, as: As): string;
|
|
156
|
+
// A standing as her section, or as a door: named, with the way to her
|
|
157
|
+
// own page, and nothing of hers on this one.
|
|
158
|
+
standing(id: string, as?: 'door'): string;
|
|
159
|
+
standings(): string;
|
|
160
|
+
};
|
|
161
|
+
|
|
162
|
+
const ROLE_TAG: Record<Role, string> = { title: 'h1', lead: 'p', body: 'p', quiet: 'p', label: 'p' };
|
|
163
|
+
|
|
164
|
+
// Whether a tree opens with a title of its own, at its top or one level
|
|
165
|
+
// in: then the page's bar says no name, since the page says it once.
|
|
166
|
+
export function titled(n: Node | null, depth = 0): boolean {
|
|
167
|
+
if (!n || depth > 2) return false;
|
|
168
|
+
if (n.kind === 'text') return n.role === 'title';
|
|
169
|
+
if (n.kind === 'stack' || n.kind === 'row') return n.of.some((c) => titled(c, depth + 1));
|
|
170
|
+
return false;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// Each kind on the kit's pieces, and the painter holds the opinion: a list
|
|
174
|
+
// wraps each child as a row, a grid as a card, a person puts her picture in
|
|
175
|
+
// the round place a face has.
|
|
176
|
+
export function paint(n: Node, p: Painter): string {
|
|
177
|
+
switch (n.kind) {
|
|
178
|
+
case 'stack': {
|
|
179
|
+
const inner = (wrap: string) => n.of.map((c) => `<div class="${wrap}">${paint(c, p)}</div>`).join('');
|
|
180
|
+
if (n.as === 'list') return `<div class="nv-list">${inner('nv-item')}</div>`;
|
|
181
|
+
if (n.as === 'grid') return `<div class="nv-cards">${inner('nv-card')}</div>`;
|
|
182
|
+
if (n.as === 'hero') return `<div class="nv-hero">${n.of.map((c) => paint(c, p)).join('')}</div>`;
|
|
183
|
+
if (n.as === 'feed') return `<div class="nv-feed">${n.of.map((c) => paint(c, p)).join('')}</div>`;
|
|
184
|
+
return `<div class="nv-stack">${n.of.map((c) => paint(c, p)).join('')}</div>`;
|
|
185
|
+
}
|
|
186
|
+
case 'row': {
|
|
187
|
+
if (n.as === 'person') {
|
|
188
|
+
const face = n.of.find((c) => c.kind === 'image');
|
|
189
|
+
const rest = n.of.filter((c) => c !== face).map((c) => paint(c, p)).join('');
|
|
190
|
+
const words = n.of.find((c) => c.kind === 'text');
|
|
191
|
+
const initial = words && words.kind === 'text' ? escape(words.text.trim().charAt(0).toUpperCase()) : '';
|
|
192
|
+
return `<div class="nv-person"><span class="nv-avatar">${face ? paint(face, p) : initial}</span><div class="nv-stack">${rest}</div></div>`;
|
|
193
|
+
}
|
|
194
|
+
return `<div class="nv-row" data-align="center">${n.of.map((c) => paint(c, p)).join('')}</div>`;
|
|
195
|
+
}
|
|
196
|
+
case 'text': {
|
|
197
|
+
const role = n.role ?? 'body';
|
|
198
|
+
return `<${ROLE_TAG[role]} class="nv-t-${role}">${escape(n.text)}</${ROLE_TAG[role]}>`;
|
|
199
|
+
}
|
|
200
|
+
case 'image':
|
|
201
|
+
return `<img class="nv-picture" alt="${escape(n.alt ?? '')}" src="${n.src}">`;
|
|
202
|
+
case 'link':
|
|
203
|
+
return p.link(n.href, n.text ?? n.href);
|
|
204
|
+
case 'form':
|
|
205
|
+
return p.form(n.ask);
|
|
206
|
+
case 'answer':
|
|
207
|
+
return p.answer(n.ask, n.as ?? 'auto');
|
|
208
|
+
case 'standing':
|
|
209
|
+
return p.standing(n.id, n.as);
|
|
210
|
+
case 'standings':
|
|
211
|
+
return p.standings();
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// A painter with nothing behind it: for a page that names no ask, the door
|
|
216
|
+
// page and any page painted before anyone is in.
|
|
217
|
+
export const nobody: Painter = { link: () => '', form: () => '', answer: () => '', standing: () => '', standings: () => '' };
|