pi-weave 0.1.7 → 0.1.8
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/README.md +114 -34
- package/package.json +16 -4
- package/src/core/cache/workspace.ts +466 -0
- package/src/core/frontmatter.ts +217 -23
- package/src/core/git.ts +19 -0
- package/src/core/graph/build.ts +37 -4
- package/src/core/graph/current.ts +41 -28
- package/src/core/graph/mentions.ts +170 -0
- package/src/core/graph/model.ts +24 -0
- package/src/core/index.ts +12 -0
- package/src/core/openInEditor.ts +69 -0
- package/src/core/types.ts +40 -0
- package/src/core/vault.ts +477 -43
- package/src/core/view/cluster.ts +262 -0
- package/src/core/view/detail.ts +118 -0
- package/src/core/view/focus.ts +109 -0
- package/src/core/view/health.ts +156 -0
- package/src/core/view/index.ts +15 -0
- package/src/core/view/links.ts +105 -0
- package/src/core/view/time.ts +47 -0
- package/src/core/view/tree.ts +269 -0
- package/src/core/view/types.ts +39 -0
- package/src/pi/index.ts +104 -11
- package/src/pi/viewer/tui/explorer.ts +4 -2
- package/src/pi/viewer/tui/model.ts +46 -667
- package/src/pi/viewer/tui/openNote.ts +7 -56
- package/src/pi/viewer/tui/surface/explore.ts +4 -2
- package/src/pi/viewer/web/run.ts +331 -0
- package/src/web/client/api.dom.ts +40 -0
- package/src/web/client/api.ts +472 -0
- package/src/web/client/bootstrap.ts +58 -0
- package/src/web/client/context/context.model.ts +313 -0
- package/src/web/client/dist/app.js +751 -0
- package/src/web/client/graph/Graph.tsx +158 -0
- package/src/web/client/graph/column.model.ts +431 -0
- package/src/web/client/graph/graph.model.ts +538 -0
- package/src/web/client/graph/positions.ts +339 -0
- package/src/web/client/graph/project.ts +153 -0
- package/src/web/client/graph/renderer.dom.ts +52 -0
- package/src/web/client/graph/renderer.ts +279 -0
- package/src/web/client/graph/scheme.ts +44 -0
- package/src/web/client/live.model.ts +275 -0
- package/src/web/client/live.ts +151 -0
- package/src/web/client/main.tsx +27 -0
- package/src/web/client/note/Editor.tsx +102 -0
- package/src/web/client/note/Note.tsx +113 -0
- package/src/web/client/note/editor.controller.ts +151 -0
- package/src/web/client/note/editor.model.ts +636 -0
- package/src/web/client/note/note.model.ts +738 -0
- package/src/web/client/search/SearchPalette.tsx +105 -0
- package/src/web/client/search/search.model.ts +588 -0
- package/src/web/client/search/search.ts +107 -0
- package/src/web/client/shell/Columns.tsx +161 -0
- package/src/web/client/shell/ContextRail.tsx +87 -0
- package/src/web/client/shell/Divider.tsx +44 -0
- package/src/web/client/shell/FocusTrap.tsx +56 -0
- package/src/web/client/shell/Header.tsx +54 -0
- package/src/web/client/shell/HelpOverlay.tsx +70 -0
- package/src/web/client/shell/Shell.tsx +193 -0
- package/src/web/client/shell/StatusBar.tsx +28 -0
- package/src/web/client/shell/cssvars.ts +70 -0
- package/src/web/client/shell/drag.model.ts +170 -0
- package/src/web/client/shell/focus.model.ts +100 -0
- package/src/web/client/shell/keys.model.ts +453 -0
- package/src/web/client/shell/keys.ts +59 -0
- package/src/web/client/shell/layout.model.ts +526 -0
- package/src/web/client/shell/shell.model.ts +333 -0
- package/src/web/client/shell/theme.ts +477 -0
- package/src/web/client/shell/viewport.ts +29 -0
- package/src/web/client/state.ts +78 -0
- package/src/web/client/tree/Tree.tsx +138 -0
- package/src/web/client/tree/tree.model.ts +674 -0
- package/src/web/client/workspace.ts +214 -0
- package/src/web/server/page.ts +256 -0
- package/src/web/server/routes.ts +975 -0
- package/src/web/server/security.ts +361 -0
- package/src/web/server/server.ts +275 -0
- package/src/web/server/sse.ts +321 -0
- package/src/web/server/watcher.ts +507 -0
- package/src/web/shared/graph.ts +206 -0
- package/src/web/shared/layout.ts +497 -0
- package/src/web/shared/metrics.ts +136 -0
- package/src/web/shared/view.ts +200 -0
- package/src/web/shared/wire.ts +358 -0
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The controller: fetches, signals, and the SSE loop joined up
|
|
3
|
+
* (weave-workspace §1.3, §6).
|
|
4
|
+
*
|
|
5
|
+
* Three modules already exist and none of them knows about the others —
|
|
6
|
+
* `api.ts` fetches, `live.ts` listens, `state.ts` holds. This is the seam
|
|
7
|
+
* that connects them, and it is a plain `.ts` with every dependency injected
|
|
8
|
+
* so it is covered by ordinary tests: `fetch` comes in as a {@link FetchLike}
|
|
9
|
+
* and the socket as an {@link EventSourceFactory}, exactly as those modules
|
|
10
|
+
* were designed to allow.
|
|
11
|
+
*
|
|
12
|
+
* Keeping it out of a component is what makes the shell's `useEffect` a
|
|
13
|
+
* two-liner (`start`, return `stop`). A `.tsx` cannot be tested here, so any
|
|
14
|
+
* decision that lands in one is a decision that ships uncovered.
|
|
15
|
+
*
|
|
16
|
+
* ## Refetch is ordered and conditional
|
|
17
|
+
*
|
|
18
|
+
* A plan can ask for both endpoints; the graph is fetched first because it
|
|
19
|
+
* carries the stamp that `seen()` records and therefore the dedupe key for
|
|
20
|
+
* every subsequent frame. Both requests are conditional in the sense that
|
|
21
|
+
* matters: the graph sends `If-None-Match` and a `304` costs an empty body,
|
|
22
|
+
* so "refetch everything on reconnect" (§6) is genuinely cheap rather than
|
|
23
|
+
* merely correct.
|
|
24
|
+
*
|
|
25
|
+
* ## Failures are absorbed, not thrown
|
|
26
|
+
*
|
|
27
|
+
* `api.ts` returns a discriminated result precisely so this layer never
|
|
28
|
+
* catches. A failed refetch leaves the previous signal value in place — a
|
|
29
|
+
* stale graph is strictly better than a blank workspace, and the next frame
|
|
30
|
+
* or the `⟳` button retries. The connection indicator, driven separately by
|
|
31
|
+
* the socket, is what tells the user something is wrong.
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
import type { NotePayload } from "../shared/wire";
|
|
35
|
+
import type { ApiResult, FetchLike } from "./api";
|
|
36
|
+
import { fetchGraph, fetchNote } from "./api";
|
|
37
|
+
import type { EventSourceFactory, LiveHandle } from "./live";
|
|
38
|
+
import { startLive } from "./live";
|
|
39
|
+
import type { RefetchPlan } from "./live.model";
|
|
40
|
+
import { connection, graph, noteBody, selectedId } from "./state";
|
|
41
|
+
|
|
42
|
+
/** What {@link startWorkspace} needs. Everything injectable is injected. */
|
|
43
|
+
export interface WorkspaceOptions {
|
|
44
|
+
fetch: FetchLike;
|
|
45
|
+
/** Socket constructor. `domEventSource` at the real call site. */
|
|
46
|
+
open: EventSourceFactory;
|
|
47
|
+
/** Overrides the SSE path. Tests use it; the shell does not. */
|
|
48
|
+
path?: string;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Told about every note that arrives, so the editor can decide (§6, P5).
|
|
53
|
+
*
|
|
54
|
+
* A module-level hook rather than a parameter threaded through five call
|
|
55
|
+
* sites, because a note reaches the column from three unrelated directions —
|
|
56
|
+
* the mount fetch, a selection, and an SSE refetch — and the editor's
|
|
57
|
+
* decision ("is this the note I am editing, at a revision I do not hold?")
|
|
58
|
+
* has to be made on all three or it is made on none. The alternative was
|
|
59
|
+
* `loadNote` taking a callback that every caller had to remember to pass.
|
|
60
|
+
*
|
|
61
|
+
* Set by the shell at mount and cleared on unmount, exactly like
|
|
62
|
+
* `Shell.tsx`'s `fit` ref. `null` — the shape every test that does not care
|
|
63
|
+
* about editing sees — means the load simply publishes and nothing else
|
|
64
|
+
* happens.
|
|
65
|
+
*/
|
|
66
|
+
let onNoteLoaded: ((payload: NotePayload) => void) | null = null;
|
|
67
|
+
|
|
68
|
+
/** Register the editor's load hook. Returns an unsubscribe. */
|
|
69
|
+
export function observeNotes(hook: (payload: NotePayload) => void): () => void {
|
|
70
|
+
onNoteLoaded = hook;
|
|
71
|
+
return () => {
|
|
72
|
+
// Only clear our own registration: two shells in one test process
|
|
73
|
+
// unmounting out of order must not blank a live hook.
|
|
74
|
+
if (onNoteLoaded === hook) onNoteLoaded = null;
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** A running workspace. */
|
|
79
|
+
export interface WorkspaceHandle {
|
|
80
|
+
/** Force a full refetch — the header's `⟳`. */
|
|
81
|
+
refresh(): void;
|
|
82
|
+
/** Fetch the body for the current selection, or clear it. */
|
|
83
|
+
syncNote(): Promise<void>;
|
|
84
|
+
/** Close the socket. Idempotent. */
|
|
85
|
+
stop(): void;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Fetch the graph and publish it.
|
|
90
|
+
*
|
|
91
|
+
* The stamp is handed to {@link LiveHandle.seen} only on success, which is
|
|
92
|
+
* the invariant `live.model.ts` documents: a stamp recorded for a fetch that
|
|
93
|
+
* failed would dedupe away the very frame that would have retried it.
|
|
94
|
+
*
|
|
95
|
+
* A `304` arrives as `cached: true` with the caller's own payload, so
|
|
96
|
+
* re-assigning the signal would be a no-op write that still wakes every
|
|
97
|
+
* subscriber. Skipping it is the difference between an idle workspace doing
|
|
98
|
+
* nothing and one re-rendering three columns every time the watcher twitches.
|
|
99
|
+
*/
|
|
100
|
+
async function loadGraph(fetchImpl: FetchLike, live: LiveHandle | null): Promise<ApiResult<unknown>> {
|
|
101
|
+
const result = await fetchGraph(fetchImpl, graph.value);
|
|
102
|
+
if (!result.ok) return result;
|
|
103
|
+
if (!result.cached) graph.value = result.data;
|
|
104
|
+
live?.seen(result.data.stamp);
|
|
105
|
+
return result;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Fetch the selected note's body, or clear it.
|
|
110
|
+
*
|
|
111
|
+
* The selection is a graph node id, and only *note* nodes have a body — the
|
|
112
|
+
* repository, git-state and file nodes do not. `note:` is the prefix core's
|
|
113
|
+
* graph builder gives them; anything else clears the column rather than
|
|
114
|
+
* issuing a request the server would answer `404`.
|
|
115
|
+
*/
|
|
116
|
+
async function loadNote(fetchImpl: FetchLike): Promise<void> {
|
|
117
|
+
const slug = noteSlug(selectedId.value);
|
|
118
|
+
if (slug === null) {
|
|
119
|
+
noteBody.value = null;
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
const result = await fetchNote(fetchImpl, slug);
|
|
123
|
+
// A failed note fetch leaves the previous body on screen. The alternative —
|
|
124
|
+
// blanking the column on a transient error — throws away readable content
|
|
125
|
+
// to display nothing, and the note is usually still there.
|
|
126
|
+
if (!result.ok) return;
|
|
127
|
+
noteBody.value = result.data;
|
|
128
|
+
// After the signal, not before: the editor's decision may leave the draft
|
|
129
|
+
// in place *while* the column's read-mode rendering shows the new version,
|
|
130
|
+
// and the two are independent. Publishing second would let the editor see
|
|
131
|
+
// a payload the rest of the workspace does not yet hold.
|
|
132
|
+
onNoteLoaded?.(result.data);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* The slug inside a `note:<slug>` node id, or `null` for any other node.
|
|
137
|
+
*
|
|
138
|
+
* Exported because it is the one piece of id-shape knowledge in this file and
|
|
139
|
+
* it deserves a test of its own rather than being reached only through a
|
|
140
|
+
* fetch. An empty slug (`"note:"`) is rejected: it would produce a request
|
|
141
|
+
* for `/api/note/` and a 404 that means nothing to anyone.
|
|
142
|
+
*/
|
|
143
|
+
export function noteSlug(id: string | null): string | null {
|
|
144
|
+
if (id === null || !id.startsWith("note:")) return null;
|
|
145
|
+
const slug = id.slice("note:".length);
|
|
146
|
+
return slug === "" ? null : slug;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Boot the workspace: first graph fetch, then the event stream.
|
|
151
|
+
*
|
|
152
|
+
* In that order, deliberately. The mount fetch seeds the stamp via `seen()`,
|
|
153
|
+
* so the hello frame `sse.ts` sends every newly attached client is recognised
|
|
154
|
+
* as already-held and deduped away. Opening the socket first would make the
|
|
155
|
+
* first frame arrive before there is a stamp to compare it to, and the
|
|
156
|
+
* workspace would fetch the same graph twice on every single load.
|
|
157
|
+
*/
|
|
158
|
+
export function startWorkspace(opts: WorkspaceOptions): WorkspaceHandle {
|
|
159
|
+
let live: LiveHandle | null = null;
|
|
160
|
+
|
|
161
|
+
const runPlan = (plan: RefetchPlan): void => {
|
|
162
|
+
// Fire-and-forget: this is called from a socket callback, which cannot
|
|
163
|
+
// await. Failures are values (`api.ts`), so there is nothing to reject —
|
|
164
|
+
// `void` documents that rather than hiding a floating promise.
|
|
165
|
+
void (async () => {
|
|
166
|
+
if (plan.graph) await loadGraph(opts.fetch, live);
|
|
167
|
+
if (plan.note) await loadNote(opts.fetch);
|
|
168
|
+
})();
|
|
169
|
+
};
|
|
170
|
+
|
|
171
|
+
live = startLive({
|
|
172
|
+
open: opts.open,
|
|
173
|
+
refetch: runPlan,
|
|
174
|
+
hasSelection: () => noteSlug(selectedId.value) !== null,
|
|
175
|
+
...(opts.path === undefined ? {} : { path: opts.path }),
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
void loadGraph(opts.fetch, live);
|
|
179
|
+
|
|
180
|
+
return {
|
|
181
|
+
refresh() {
|
|
182
|
+
live?.refresh();
|
|
183
|
+
},
|
|
184
|
+
syncNote() {
|
|
185
|
+
return loadNote(opts.fetch);
|
|
186
|
+
},
|
|
187
|
+
stop() {
|
|
188
|
+
live?.stop();
|
|
189
|
+
live = null;
|
|
190
|
+
},
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Select a node — the §1.3 context bus, in one function.
|
|
196
|
+
*
|
|
197
|
+
* Writing `selectedId` is the whole mechanism; the note fetch that follows is
|
|
198
|
+
* a *consequence* of the write, not part of it, which is why the signal is
|
|
199
|
+
* set before the fetch is issued. Every column that derives from the
|
|
200
|
+
* selection updates on the synchronous write, so the UI responds immediately
|
|
201
|
+
* and the body arrives when it arrives.
|
|
202
|
+
*/
|
|
203
|
+
export function select(fetchImpl: FetchLike, id: string | null): Promise<void> {
|
|
204
|
+
selectedId.value = id;
|
|
205
|
+
return loadNote(fetchImpl);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/** Reset every signal. The shell's unmount path, and every test's cleanup. */
|
|
209
|
+
export function resetWorkspace(): void {
|
|
210
|
+
selectedId.value = null;
|
|
211
|
+
graph.value = null;
|
|
212
|
+
noteBody.value = null;
|
|
213
|
+
connection.value = "live";
|
|
214
|
+
}
|
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The HTML shell (weave-workspace §5.2, §5.3, §9).
|
|
3
|
+
*
|
|
4
|
+
* Four elements and nothing else: a nonce'd `<style>` carrying the CSS
|
|
5
|
+
* variable theme, `<div id="app">`, a nonce'd JSON bootstrap block, and
|
|
6
|
+
* `<script nonce src="/app.js">`. All behaviour lives in the committed
|
|
7
|
+
* bundle; this file exists to deliver a nonce, a CSP header and three
|
|
8
|
+
* strings of context.
|
|
9
|
+
*
|
|
10
|
+
* ## Why the no-backtick guard survived the rewrite
|
|
11
|
+
*
|
|
12
|
+
* §9 retired the *source* guard for `dist/app.js` in favour of a stronger
|
|
13
|
+
* invariant — `build:web:check` byte-compares the committed bundle against a
|
|
14
|
+
* fresh build, so the shipped artifact provably matches its source. That
|
|
15
|
+
* argument does not transfer here, because this file is not generated: it is
|
|
16
|
+
* a template literal into which `cwd`, `vaultRoot` and a session id are
|
|
17
|
+
* interpolated, and it is therefore still an injection surface. A vault path
|
|
18
|
+
* containing `</script>` is not a hypothetical — it is one `mkdir` away.
|
|
19
|
+
*
|
|
20
|
+
* So the guard stays, in two forms, both in `tests/web/page.test.ts`:
|
|
21
|
+
*
|
|
22
|
+
* 1. **Output**: the rendered page contains no `` ` `` and no `${`. Every
|
|
23
|
+
* escaper below emits those as numeric entities or `\u` escapes, so a
|
|
24
|
+
* hit means an interpolation reached the output raw.
|
|
25
|
+
* 2. **Source**: every `${…}` in this file's template is a call to one of
|
|
26
|
+
* {@link escapeHtml}, {@link escapeAttr} or {@link jsonScriptBody}. This
|
|
27
|
+
* is the stronger half — the output guard can only catch a leak that a
|
|
28
|
+
* *test fixture* happens to trigger, while the source guard catches the
|
|
29
|
+
* unescaped interpolation itself, on the commit that adds it.
|
|
30
|
+
*
|
|
31
|
+
* ## CSP
|
|
32
|
+
*
|
|
33
|
+
* `default-src 'none'` and a per-response nonce. Nothing loads that we did
|
|
34
|
+
* not name: no `'unsafe-inline'`, no `'unsafe-eval'`, no `blob:`, no remote
|
|
35
|
+
* origin. `connect-src 'self'` is what permits `/api/*` and the `/events`
|
|
36
|
+
* stream; `frame-ancestors 'none'` means no page can embed us, which
|
|
37
|
+
* matters because a framed workspace plus a stolen click is a way to reach
|
|
38
|
+
* `POST /api/open`.
|
|
39
|
+
*
|
|
40
|
+
* The nonce is fresh per response (16 bytes). Reuse across responses would
|
|
41
|
+
* make it a static secret that any successful injection could simply read
|
|
42
|
+
* from the previous page and replay.
|
|
43
|
+
*/
|
|
44
|
+
|
|
45
|
+
import { randomBytes } from "node:crypto";
|
|
46
|
+
import type { Bootstrap } from "../shared/wire";
|
|
47
|
+
import { BOOTSTRAP_ELEMENT_ID } from "../shared/wire";
|
|
48
|
+
|
|
49
|
+
/** Nonce entropy, in bytes. 128 bits — CSP requires ≥ 128. */
|
|
50
|
+
export const NONCE_BYTES = 16;
|
|
51
|
+
|
|
52
|
+
/** A fresh per-response CSP nonce. */
|
|
53
|
+
export function generateNonce(): string {
|
|
54
|
+
return randomBytes(NONCE_BYTES).toString("base64");
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* A nonce, validated for use inside a CSP header.
|
|
59
|
+
*
|
|
60
|
+
* The third escaper, and the odd one out: HTML escaping is *wrong* here,
|
|
61
|
+
* because a `"` in a header value is a literal `"`, not a quote.
|
|
62
|
+
* What a header needs is a character-set check — a nonce containing `;`,
|
|
63
|
+
* `'`, CR or LF would either terminate the directive early (turning
|
|
64
|
+
* `script-src` into something permissive) or split the header outright.
|
|
65
|
+
*
|
|
66
|
+
* Base64 is a strict subset of what CSP's `base64-value` grammar allows, so
|
|
67
|
+
* requiring it costs nothing and makes the failure loud. It throws rather
|
|
68
|
+
* than sanitising: a nonce that is not base64 did not come from
|
|
69
|
+
* {@link generateNonce}, and quietly repairing it would hide the bug that
|
|
70
|
+
* produced it.
|
|
71
|
+
*/
|
|
72
|
+
export function cspNonce(nonce: string): string {
|
|
73
|
+
if (!/^[A-Za-z0-9+/]+={0,2}$/.test(nonce)) throw new Error("pi-weave: CSP nonce must be base64");
|
|
74
|
+
return nonce;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* The §5.2 policy, bound to one nonce.
|
|
79
|
+
*
|
|
80
|
+
* Emitted as a single line with `; ` separators. `tests/web/routes.test.ts`
|
|
81
|
+
* asserts the exact string — a policy that drifts silently is a policy that
|
|
82
|
+
* has already stopped protecting anything.
|
|
83
|
+
*/
|
|
84
|
+
export function contentSecurityPolicy(nonce: string): string {
|
|
85
|
+
return [
|
|
86
|
+
"default-src 'none'",
|
|
87
|
+
`script-src 'nonce-${cspNonce(nonce)}'`,
|
|
88
|
+
`style-src 'nonce-${cspNonce(nonce)}'`,
|
|
89
|
+
"img-src 'self' data:",
|
|
90
|
+
"connect-src 'self'",
|
|
91
|
+
"font-src 'self'",
|
|
92
|
+
"base-uri 'none'",
|
|
93
|
+
"form-action 'none'",
|
|
94
|
+
"frame-ancestors 'none'",
|
|
95
|
+
].join("; ");
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// --- escaping ----------------------------------------------------------------
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* The five HTML-significant characters, plus `` ` `` and `$`.
|
|
102
|
+
*
|
|
103
|
+
* The last two are not HTML-significant and are escaped anyway, to keep the
|
|
104
|
+
* output guard above meaningful: if `` ` `` can never appear in rendered
|
|
105
|
+
* output, then finding one is unambiguous evidence of a raw interpolation
|
|
106
|
+
* rather than a false positive from a note title that happened to contain a
|
|
107
|
+
* code span. ``` and `$` render identically to the user.
|
|
108
|
+
*/
|
|
109
|
+
const HTML_ESCAPES: Readonly<Record<string, string>> = {
|
|
110
|
+
"&": "&",
|
|
111
|
+
"<": "<",
|
|
112
|
+
">": ">",
|
|
113
|
+
'"': """,
|
|
114
|
+
"'": "'",
|
|
115
|
+
"`": "`",
|
|
116
|
+
$: "$",
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* The character class matching exactly {@link HTML_ESCAPES}' keys.
|
|
121
|
+
*
|
|
122
|
+
* Derived from the map rather than written twice, so the two cannot drift —
|
|
123
|
+
* a key added to the map without a matching regex edit would be a character
|
|
124
|
+
* that silently stops being escaped, which is the quietest possible way to
|
|
125
|
+
* introduce an XSS.
|
|
126
|
+
*/
|
|
127
|
+
const HTML_ESCAPE_PATTERN = new RegExp(
|
|
128
|
+
// Built by concatenation rather than a template literal so the source
|
|
129
|
+
// guard in `tests/web/page.test.ts` stays absolute: *every* `${…}` in this
|
|
130
|
+
// file is an escaper call, with no exemptions to remember.
|
|
131
|
+
"[" + Object.keys(HTML_ESCAPES).map((ch) => "\\" + ch).join("") + "]",
|
|
132
|
+
"g",
|
|
133
|
+
);
|
|
134
|
+
|
|
135
|
+
/** Escape a value for HTML text content. */
|
|
136
|
+
export function escapeHtml(value: string): string {
|
|
137
|
+
// The regex is built from the map's keys, so every match has an entry.
|
|
138
|
+
// Asserted rather than left as a `?? ch` fallback, which would be a dead
|
|
139
|
+
// branch dressed up as safety — and one that fails *open*, emitting the
|
|
140
|
+
// raw character it was supposed to escape.
|
|
141
|
+
return value.replace(HTML_ESCAPE_PATTERN, (ch) => HTML_ESCAPES[ch] as string);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Escape a value for a double-quoted attribute.
|
|
146
|
+
*
|
|
147
|
+
* Identical to {@link escapeHtml} today — the set of characters that can
|
|
148
|
+
* break out of a quoted attribute is a subset of the set that can break out
|
|
149
|
+
* of text. Kept as a distinct name because the *call site* documents intent,
|
|
150
|
+
* and because an attribute-specific rule (escaping whitespace, for unquoted
|
|
151
|
+
* attributes) is the obvious future divergence.
|
|
152
|
+
*/
|
|
153
|
+
export function escapeAttr(value: string): string {
|
|
154
|
+
return escapeHtml(value);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Serialize a value for embedding inside `<script type="application/json">`.
|
|
159
|
+
*
|
|
160
|
+
* `JSON.stringify` alone is **not** safe here. The HTML parser looks for
|
|
161
|
+
* `</script` inside a script element before the JSON parser ever sees the
|
|
162
|
+
* bytes, so a string containing it terminates the element early and
|
|
163
|
+
* everything after is parsed as markup. `<!--` opens an HTML comment with
|
|
164
|
+
* the same effect. Escaping `<` and `>` to `\u003c` / `\u003e` closes both,
|
|
165
|
+
* and is transparent to `JSON.parse`.
|
|
166
|
+
*
|
|
167
|
+
* `&`, `` ` `` and `$` follow for the same reason as in {@link HTML_ESCAPES}:
|
|
168
|
+
* so the output guard has no legitimate exceptions to carve out.
|
|
169
|
+
*
|
|
170
|
+
* U+2028 and U+2029 are escaped because they are valid in JSON strings but
|
|
171
|
+
* are line terminators in JavaScript source — a hazard if this block is ever
|
|
172
|
+
* read with `eval`-adjacent machinery rather than `JSON.parse`.
|
|
173
|
+
*/
|
|
174
|
+
export function jsonScriptBody(value: unknown): string {
|
|
175
|
+
return JSON.stringify(value).replace(
|
|
176
|
+
/[<>&`$\u2028\u2029]/g,
|
|
177
|
+
(ch) => "\\u" + ch.charCodeAt(0).toString(16).padStart(4, "0"),
|
|
178
|
+
);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// --- rendering ---------------------------------------------------------------
|
|
182
|
+
|
|
183
|
+
export interface RenderPageOptions {
|
|
184
|
+
bootstrap: Bootstrap;
|
|
185
|
+
/** Defaults to a fresh {@link generateNonce}. Injectable for tests. */
|
|
186
|
+
nonce?: string;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
export interface RenderedPage {
|
|
190
|
+
html: string;
|
|
191
|
+
nonce: string;
|
|
192
|
+
/** The exact `Content-Security-Policy` header value for this response. */
|
|
193
|
+
csp: string;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* The theme, as CSS custom properties.
|
|
198
|
+
*
|
|
199
|
+
* A constant, never interpolated, so it cannot carry user input. It stays in
|
|
200
|
+
* the shell rather than the bundle so the first paint has a background
|
|
201
|
+
* colour before `app.js` parses — a dark-mode user should not get a white
|
|
202
|
+
* flash while 21 KB of JavaScript loads.
|
|
203
|
+
*/
|
|
204
|
+
const THEME_CSS = [
|
|
205
|
+
":root{color-scheme:light dark;",
|
|
206
|
+
"--weave-bg:#faf9f7;--weave-fg:#1c1b19;--weave-dim:#6f6b66;",
|
|
207
|
+
"--weave-line:#e3e0dc;--weave-accent:#7a5cff;--weave-warn:#c2410c;",
|
|
208
|
+
"--weave-mono:ui-monospace,SFMono-Regular,Menlo,monospace;",
|
|
209
|
+
"--weave-sans:ui-sans-serif,system-ui,-apple-system,Segoe UI,sans-serif}",
|
|
210
|
+
"@media(prefers-color-scheme:dark){:root{",
|
|
211
|
+
"--weave-bg:#16151a;--weave-fg:#eceaf0;--weave-dim:#9a95a3;",
|
|
212
|
+
"--weave-line:#2c2a33;--weave-accent:#a48cff;--weave-warn:#fb923c}}",
|
|
213
|
+
"*{box-sizing:border-box}",
|
|
214
|
+
"html,body{height:100%}",
|
|
215
|
+
"body{margin:0;background:var(--weave-bg);color:var(--weave-fg);",
|
|
216
|
+
"font-family:var(--weave-sans);font-size:14px;line-height:1.5}",
|
|
217
|
+
"#app{height:100%;display:flex;flex-direction:column}",
|
|
218
|
+
].join("");
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* Render the shell.
|
|
222
|
+
*
|
|
223
|
+
* Returns the nonce and the CSP alongside the HTML rather than setting a
|
|
224
|
+
* header itself: the caller owns the response, and coupling a renderer to
|
|
225
|
+
* `ServerResponse` would make it untestable without a socket. The invariant
|
|
226
|
+
* that the header and the document agree on the nonce is held by
|
|
227
|
+
* `routes.ts`, which receives both from this one call.
|
|
228
|
+
*/
|
|
229
|
+
export function renderPage(opts: RenderPageOptions): RenderedPage {
|
|
230
|
+
const nonce = opts.nonce ?? generateNonce();
|
|
231
|
+
const html = [
|
|
232
|
+
"<!doctype html>",
|
|
233
|
+
'<html lang="en">',
|
|
234
|
+
"<head>",
|
|
235
|
+
'<meta charset="utf-8">',
|
|
236
|
+
'<meta name="viewport" content="width=device-width,initial-scale=1">',
|
|
237
|
+
'<meta name="referrer" content="no-referrer">',
|
|
238
|
+
"<title>pi-weave workspace</title>",
|
|
239
|
+
`<style nonce="${escapeAttr(nonce)}">`,
|
|
240
|
+
THEME_CSS,
|
|
241
|
+
"</style>",
|
|
242
|
+
"</head>",
|
|
243
|
+
"<body>",
|
|
244
|
+
`<div id="app" data-cwd="${escapeAttr(opts.bootstrap.cwd)}">`,
|
|
245
|
+
"<noscript>pi-weave needs JavaScript. Use <code>/weave-view tui</code> instead.</noscript>",
|
|
246
|
+
"</div>",
|
|
247
|
+
`<script type="application/json" id="${escapeAttr(BOOTSTRAP_ELEMENT_ID)}" nonce="${escapeAttr(nonce)}">`,
|
|
248
|
+
jsonScriptBody(opts.bootstrap),
|
|
249
|
+
"</script>",
|
|
250
|
+
`<script nonce="${escapeAttr(nonce)}" src="/app.js"></script>`,
|
|
251
|
+
"</body>",
|
|
252
|
+
"</html>",
|
|
253
|
+
].join("\n");
|
|
254
|
+
|
|
255
|
+
return { html, nonce, csp: contentSecurityPolicy(nonce) };
|
|
256
|
+
}
|