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.
Files changed (84) hide show
  1. package/README.md +114 -34
  2. package/package.json +16 -4
  3. package/src/core/cache/workspace.ts +466 -0
  4. package/src/core/frontmatter.ts +217 -23
  5. package/src/core/git.ts +19 -0
  6. package/src/core/graph/build.ts +37 -4
  7. package/src/core/graph/current.ts +41 -28
  8. package/src/core/graph/mentions.ts +170 -0
  9. package/src/core/graph/model.ts +24 -0
  10. package/src/core/index.ts +12 -0
  11. package/src/core/openInEditor.ts +69 -0
  12. package/src/core/types.ts +40 -0
  13. package/src/core/vault.ts +477 -43
  14. package/src/core/view/cluster.ts +262 -0
  15. package/src/core/view/detail.ts +118 -0
  16. package/src/core/view/focus.ts +109 -0
  17. package/src/core/view/health.ts +156 -0
  18. package/src/core/view/index.ts +15 -0
  19. package/src/core/view/links.ts +105 -0
  20. package/src/core/view/time.ts +47 -0
  21. package/src/core/view/tree.ts +269 -0
  22. package/src/core/view/types.ts +39 -0
  23. package/src/pi/index.ts +104 -11
  24. package/src/pi/viewer/tui/explorer.ts +4 -2
  25. package/src/pi/viewer/tui/model.ts +46 -667
  26. package/src/pi/viewer/tui/openNote.ts +7 -56
  27. package/src/pi/viewer/tui/surface/explore.ts +4 -2
  28. package/src/pi/viewer/web/run.ts +331 -0
  29. package/src/web/client/api.dom.ts +40 -0
  30. package/src/web/client/api.ts +472 -0
  31. package/src/web/client/bootstrap.ts +58 -0
  32. package/src/web/client/context/context.model.ts +313 -0
  33. package/src/web/client/dist/app.js +751 -0
  34. package/src/web/client/graph/Graph.tsx +158 -0
  35. package/src/web/client/graph/column.model.ts +431 -0
  36. package/src/web/client/graph/graph.model.ts +538 -0
  37. package/src/web/client/graph/positions.ts +339 -0
  38. package/src/web/client/graph/project.ts +153 -0
  39. package/src/web/client/graph/renderer.dom.ts +52 -0
  40. package/src/web/client/graph/renderer.ts +279 -0
  41. package/src/web/client/graph/scheme.ts +44 -0
  42. package/src/web/client/live.model.ts +275 -0
  43. package/src/web/client/live.ts +151 -0
  44. package/src/web/client/main.tsx +27 -0
  45. package/src/web/client/note/Editor.tsx +102 -0
  46. package/src/web/client/note/Note.tsx +113 -0
  47. package/src/web/client/note/editor.controller.ts +151 -0
  48. package/src/web/client/note/editor.model.ts +636 -0
  49. package/src/web/client/note/note.model.ts +738 -0
  50. package/src/web/client/search/SearchPalette.tsx +105 -0
  51. package/src/web/client/search/search.model.ts +588 -0
  52. package/src/web/client/search/search.ts +107 -0
  53. package/src/web/client/shell/Columns.tsx +161 -0
  54. package/src/web/client/shell/ContextRail.tsx +87 -0
  55. package/src/web/client/shell/Divider.tsx +44 -0
  56. package/src/web/client/shell/FocusTrap.tsx +56 -0
  57. package/src/web/client/shell/Header.tsx +54 -0
  58. package/src/web/client/shell/HelpOverlay.tsx +70 -0
  59. package/src/web/client/shell/Shell.tsx +193 -0
  60. package/src/web/client/shell/StatusBar.tsx +28 -0
  61. package/src/web/client/shell/cssvars.ts +70 -0
  62. package/src/web/client/shell/drag.model.ts +170 -0
  63. package/src/web/client/shell/focus.model.ts +100 -0
  64. package/src/web/client/shell/keys.model.ts +453 -0
  65. package/src/web/client/shell/keys.ts +59 -0
  66. package/src/web/client/shell/layout.model.ts +526 -0
  67. package/src/web/client/shell/shell.model.ts +333 -0
  68. package/src/web/client/shell/theme.ts +477 -0
  69. package/src/web/client/shell/viewport.ts +29 -0
  70. package/src/web/client/state.ts +78 -0
  71. package/src/web/client/tree/Tree.tsx +138 -0
  72. package/src/web/client/tree/tree.model.ts +674 -0
  73. package/src/web/client/workspace.ts +214 -0
  74. package/src/web/server/page.ts +256 -0
  75. package/src/web/server/routes.ts +975 -0
  76. package/src/web/server/security.ts +361 -0
  77. package/src/web/server/server.ts +275 -0
  78. package/src/web/server/sse.ts +321 -0
  79. package/src/web/server/watcher.ts +507 -0
  80. package/src/web/shared/graph.ts +206 -0
  81. package/src/web/shared/layout.ts +497 -0
  82. package/src/web/shared/metrics.ts +136 -0
  83. package/src/web/shared/view.ts +200 -0
  84. package/src/web/shared/wire.ts +358 -0
@@ -0,0 +1,738 @@
1
+ /**
2
+ * The note column: Markdown, sanitisation and wikilinks
3
+ * (weave-workspace §1.2, §5.2, §10, P2.4).
4
+ *
5
+ * "Notes are the product" (§1.1), so this is the column the workspace is
6
+ * actually for. It is also the only place in the client that turns content off
7
+ * the disk into DOM, which makes it the only place with a genuine threat
8
+ * model — so most of the length below is about that.
9
+ *
10
+ * ## The threat model, stated plainly
11
+ *
12
+ * The server is loopback-only and cookie-authenticated (§5.1), so this is not
13
+ * about a remote attacker reaching the page. It is about *content*: a vault
14
+ * note written by an agent, a `.okf` summary generated from a repository, or a
15
+ * README in a repo someone cloned to look at. Any of those can contain
16
+ * `<img src=x onerror=…>`, and a workspace whose whole premise is "point it at
17
+ * an unfamiliar repository and read the knowledge" cannot treat that as
18
+ * hypothetical. Rendering untrusted-ish local content into the DOM is exactly
19
+ * the case §0 V5 chose DOMPurify for.
20
+ *
21
+ * ## Three independent layers, in order
22
+ *
23
+ * Each would be sufficient against the obvious attacks; they are stacked
24
+ * because "sufficient against the attacks I thought of" is the assumption that
25
+ * fails.
26
+ *
27
+ * 1. **Raw HTML never becomes HTML.** {@link markdownRenderer} overrides
28
+ * marked's `html` renderer to escape its input, so `<script>alert(1)</script>`
29
+ * in a note body reaches the DOM as the *text* `<script>alert(1)</script>`.
30
+ * marked removed its own `sanitize` option in v5 precisely in favour of
31
+ * this shape — the renderer, not the parser, is where the decision belongs.
32
+ * 2. **Unsafe URL schemes are dropped at render.** {@link safeUrl} allows a
33
+ * short scheme allowlist and nothing else, so a `[a](javascript:alert(1))`
34
+ * link renders with no `href` at all rather than with a stripped one.
35
+ * 3. **DOMPurify is the final gate.** {@link SANITIZE_CONFIG} is an explicit
36
+ * tag/attribute *allowlist* — not a denylist of known-bad — so anything
37
+ * layers 1 and 2 failed to anticipate still has to be on a list somebody
38
+ * wrote down. No `on*` handler is on it, and `script`, `iframe`, `object`,
39
+ * `embed`, `form` and `style` are not either.
40
+ *
41
+ * ## What the tests can and cannot prove
42
+ *
43
+ * §10 forbids a DOM test environment, and DOMPurify needs one — it is a
44
+ * wrapper around `DOMParser` and refuses to run without it
45
+ * (`DOMPurify.isSupported === false` under bare Node). So layer 3 cannot be
46
+ * *executed* in this repository's test suite, and no amount of wanting it to
47
+ * changes that.
48
+ *
49
+ * What the suite does instead is prove the two layers that are pure string
50
+ * transforms, against hostile fixtures, and prove that layer 3 is wired
51
+ * correctly: {@link renderNote} takes its sanitiser as a parameter, so a test
52
+ * hands it a spy and asserts the HTML is passed through it with
53
+ * {@link SANITIZE_CONFIG}, and asserts the config's contents directly. The
54
+ * honest summary is: *the input to DOMPurify is already safe by construction
55
+ * and that is tested; DOMPurify is configured strictly and wired as the last
56
+ * step, and that is tested; DOMPurify's own behaviour is upstream's tested
57
+ * property, not ours.*
58
+ *
59
+ * ## Tier rules (§2)
60
+ *
61
+ * `src/web/client/**`. `marked` and `dompurify` are the two npm dependencies
62
+ * this phase adds, both listed in `tests/web/tiers.test.ts`' client allowlist
63
+ * and both already licence-cleared in `scripts/build-web.mjs` (§0.1). The
64
+ * view-models come through `../../shared/view`; core is never imported
65
+ * directly. No DOM *type* is named here either — {@link Purifier} and
66
+ * {@link ClosestElement} are structural ports — which is what lets the root
67
+ * `tsconfig.json` project compile the tests.
68
+ */
69
+
70
+ import { Marked } from "marked";
71
+ import type { TokenizerAndRendererExtension, Tokens } from "marked";
72
+ import { relTime } from "../../shared/view";
73
+ import type { GraphPayload, ViewNote, WireGraphNode, WireNoteSource } from "../../shared/wire";
74
+ import { provenanceGlyph, provenanceTitle } from "../tree/tree.model";
75
+
76
+ // --- escaping ---------------------------------------------------------------------
77
+
78
+ /** The five HTML-significant characters. */
79
+ const HTML_ESCAPES = {
80
+ "&": "&amp;",
81
+ "<": "&lt;",
82
+ ">": "&gt;",
83
+ '"': "&quot;",
84
+ "'": "&#39;",
85
+ } as const;
86
+
87
+ type HtmlSpecial = keyof typeof HTML_ESCAPES;
88
+
89
+ /**
90
+ * The character class matching exactly {@link HTML_ESCAPES}' keys.
91
+ *
92
+ * Built from the keys rather than written out a second time, which is what
93
+ * justifies the cast in {@link escapeHtml}: the regex and the table cannot
94
+ * disagree about which characters are special, because one is derived from the
95
+ * other. All five are literal inside a character class, so no escaping is
96
+ * needed to assemble it.
97
+ */
98
+ const HTML_SPECIAL_RE = new RegExp(`[${Object.keys(HTML_ESCAPES).join("")}]`, "g");
99
+
100
+ /**
101
+ * Escape a string for interpolation into HTML text or a quoted attribute.
102
+ *
103
+ * `src/web/server/page.ts` has a near-identical function and this is not
104
+ * shared with it, deliberately: that one is server-tier and the client may not
105
+ * import it, and promoting five characters into `src/web/shared/` to avoid
106
+ * five lines would be a module whose only content is a table everyone already
107
+ * knows. The duplication is bounded (it cannot grow — HTML will not acquire a
108
+ * sixth significant character) and both copies are tested.
109
+ *
110
+ * Both quote styles are escaped even though every attribute below is
111
+ * double-quoted, because the cost is nothing and the invariant "an escaped
112
+ * string is safe in *any* attribute position" is much easier to hold in your
113
+ * head than "safe in the positions I checked".
114
+ *
115
+ * The cast is sound and the usual `?? ch` fallback is deliberately absent:
116
+ * {@link HTML_SPECIAL_RE} is generated from {@link HTML_ESCAPES}' own keys, so
117
+ * a matched character is a key by construction. A fallback here would be a
118
+ * branch that cannot be taken and therefore cannot be covered — the same
119
+ * consideration as `tree.model.ts`' `idAt`, resolved by making the two
120
+ * definitions share a source instead of by adding dead defensive code.
121
+ */
122
+ export function escapeHtml(value: string): string {
123
+ return value.replace(HTML_SPECIAL_RE, (ch) => HTML_ESCAPES[ch as HtmlSpecial]);
124
+ }
125
+
126
+ // --- URL safety -----------------------------------------------------------------------
127
+
128
+ /**
129
+ * URL schemes a link or image in a note may use.
130
+ *
131
+ * An allowlist, and a short one. `javascript:` is the attack everyone knows;
132
+ * `data:` is the one that gets forgotten, and `data:text/html,<script>…` in an
133
+ * `href` is script execution with a friendlier spelling. `vbscript:` still
134
+ * works in enough places to be worth naming. Rather than enumerate those, this
135
+ * enumerates what a knowledge note legitimately needs.
136
+ *
137
+ * `mailto:` is included because notes cite people. `data:` is *not*, even for
138
+ * images: an inline image is a legitimate thing to want, but it is also how a
139
+ * large hostile payload gets past a size heuristic, and the CSP already allows
140
+ * `img-src data:` for anything the client itself constructs.
141
+ */
142
+ export const SAFE_SCHEMES: readonly string[] = ["http:", "https:", "mailto:"];
143
+
144
+ /**
145
+ * A scheme-bearing prefix: letters, digits, `+`, `-`, `.`, then a colon.
146
+ *
147
+ * Matched against the value with **all** ASCII whitespace and control
148
+ * characters removed first, which is the step naive implementations skip.
149
+ * `java\tscript:alert(1)` and `java\u0000script:alert(1)` are both parsed as
150
+ * `javascript:` by browsers, so a check against the raw string sees an
151
+ * unrecognised scheme, decides it must be a relative path, and lets it
152
+ * through. DOMPurify strips the same class of character for the same reason
153
+ * (`ATTR_WHITESPACE` in its source).
154
+ */
155
+ const SCHEME_RE = /^[a-z][a-z0-9+.-]*:/i;
156
+ const URL_NOISE_RE = /[\u0000-\u0020\u00a0\u1680\u180e\u2000-\u2029\u205f\u3000]/g;
157
+
158
+ /**
159
+ * The URL to put in an `href`/`src`, or `null` to emit none.
160
+ *
161
+ * Returning `null` rather than `"#"` or `"about:blank"` matters: an anchor
162
+ * with no `href` is not a link, so it is not focusable, not announced as a
163
+ * link, and cannot be middle-clicked into a new tab. A neutralised link that
164
+ * still *looks* like a link is a link a user will keep trying to follow.
165
+ *
166
+ * A value with no scheme at all is a relative URL — `./diagram.png`,
167
+ * `#heading` — and is allowed. That is not a hole: relative URLs resolve
168
+ * against the loopback origin, which `connect-src 'self'` and
169
+ * `img-src 'self'` already bound.
170
+ */
171
+ export function safeUrl(raw: string): string | null {
172
+ const bare = raw.replace(URL_NOISE_RE, "");
173
+ const scheme = SCHEME_RE.exec(bare);
174
+ if (scheme === null) return bare === "" ? null : raw;
175
+ return SAFE_SCHEMES.includes(scheme[0].toLowerCase()) ? raw : null;
176
+ }
177
+
178
+ // --- resolving wikilinks ----------------------------------------------------------------
179
+
180
+ /**
181
+ * The attribute a clickable wikilink carries.
182
+ *
183
+ * A `data-` attribute holding a **bare slug**, not a `note:` node id and not
184
+ * an `href`. Three reasons, in increasing order of importance:
185
+ *
186
+ * - An `href` would navigate. §1.1's rule is that selecting anything updates
187
+ * every column *in place*; a full page load is the opposite of that, and it
188
+ * would also throw away the SSE connection and the layout state.
189
+ * - `note:foo` reads as a URL with an unknown scheme, and DOMPurify's
190
+ * `IS_ALLOWED_URI` rejects unknown schemes in any attribute it does not
191
+ * consider inert. A bare slug sidesteps a fight with the sanitiser over a
192
+ * value that was never a URL. (Belt and braces: it is also declared
193
+ * URI-safe in {@link SANITIZE_CONFIG}, so the check is skipped outright.)
194
+ * - The component adds the `note:` prefix when it reads the attribute, so the
195
+ * node-id format lives in one place — `workspace.ts`'s `noteSlug` and this,
196
+ * which are inverses of each other.
197
+ */
198
+ export const WIKILINK_ATTR = "data-weave-target";
199
+
200
+ /** Prefix core's graph builder gives note nodes. Mirrors `noteSlug`. */
201
+ const NOTE_PREFIX = "note:";
202
+
203
+ /**
204
+ * Raw wikilink text → the slug of the note it names, for one graph.
205
+ *
206
+ * ## Why this is a lookup and not a slugifier
207
+ *
208
+ * Core resolves `[[Release Plan]]` by running it through `slugify` and
209
+ * checking the result against the vault. The browser cannot call that
210
+ * function: `src/core/slug.ts` is not among the modules `src/web/shared/view.ts`
211
+ * re-exports, and it is not a view-model, so widening the door for it is out
212
+ * of scope here (§2.1 requires each addition to come with its own purity
213
+ * proof, in the commit that first needs it).
214
+ *
215
+ * The alternative — reimplementing `slugify` in the client — is the exact
216
+ * drift §3 exists to prevent, and it would be *silently* wrong rather than
217
+ * loudly wrong: core's version does an NFKD normalise and strips combining
218
+ * marks, so a hand-rolled copy that skipped that would resolve `[[Café]]`
219
+ * differently from the graph and every accented note would render as a ghost.
220
+ *
221
+ * So this resolves against the data instead. Every note node in the payload
222
+ * contributes two keys — its title (the node's label) and its slug (the tail
223
+ * of its node id) — each also under a case-folded spelling. A wikilink written
224
+ * the way people actually write them, as either the title or the slug, hits
225
+ * one of those exactly.
226
+ *
227
+ * ## The gap, named rather than hidden
228
+ *
229
+ * A spelling that is *neither* the title nor the slug but that `slugify`
230
+ * happens to normalise onto one — `[[Release_Plan]]`, `[[release plan]]` —
231
+ * resolves in the graph and misses here. {@link isGhost} is what keeps that
232
+ * from becoming a lie: a miss is only rendered as a ghost when the payload's
233
+ * `dangling` map says this note *has* unresolved links. When it says the note
234
+ * has none, a miss renders as ordinary text with no ghost styling, because the
235
+ * one thing known for certain is that nothing in this note is unresolved.
236
+ *
237
+ * Closing the gap properly means promoting `slugify` through the door. That is
238
+ * a one-line change to `src/web/shared/view.ts` plus an entry in
239
+ * `NODE_FREE_CORE_MODULES` — cheap, but it is a change to a guarded boundary
240
+ * and it belongs in its own reviewed commit rather than riding along here.
241
+ */
242
+ export interface WikiIndex {
243
+ /** Lookup key → slug. Keys are the raw title/slug and their case-folded forms. */
244
+ readonly bySpelling: ReadonlyMap<string, string>;
245
+ /** True when the note being rendered has at least one unresolved link. */
246
+ readonly hasGhosts: boolean;
247
+ }
248
+
249
+ /** The empty index, for the render that happens before the graph arrives. */
250
+ export const EMPTY_WIKI_INDEX: WikiIndex = { bySpelling: new Map(), hasGhosts: false };
251
+
252
+ /** The slug inside a `note:<slug>` id, or `null` for any other node. */
253
+ export function slugOfNode(node: WireGraphNode): string | null {
254
+ if (node.kind !== "note" || !node.id.startsWith(NOTE_PREFIX)) return null;
255
+ const slug = node.id.slice(NOTE_PREFIX.length);
256
+ return slug === "" ? null : slug;
257
+ }
258
+
259
+ /**
260
+ * Build the index for rendering the note with slug `slug`.
261
+ *
262
+ * `slug` is only used to decide {@link WikiIndex.hasGhosts}; the spelling map
263
+ * covers the whole vault, because a note links to other notes and not to
264
+ * itself.
265
+ */
266
+ export function wikiIndex(payload: GraphPayload | null, slug: string | null): WikiIndex {
267
+ if (payload === null) return EMPTY_WIKI_INDEX;
268
+ const bySpelling = new Map<string, string>();
269
+ for (const node of payload.model.nodes) {
270
+ const target = slugOfNode(node);
271
+ if (target === null) continue;
272
+ // Insertion order is node order and `set` overwrites, so a later note
273
+ // whose title collides with an earlier one's slug wins. Collisions between
274
+ // a title and a slug are vanishingly rare and either answer is defensible;
275
+ // what matters is that it is deterministic, which node order makes it.
276
+ for (const key of [target, node.label]) {
277
+ bySpelling.set(key, target);
278
+ bySpelling.set(key.toLowerCase(), target);
279
+ }
280
+ }
281
+ const hasGhosts = slug !== null && (payload.dangling[slug]?.length ?? 0) > 0;
282
+ return { bySpelling, hasGhosts };
283
+ }
284
+
285
+ /** The slug a wikilink points at, or `null` when nothing in the vault matches. */
286
+ export function resolveWikilink(index: WikiIndex, target: string): string | null {
287
+ const trimmed = target.trim();
288
+ return index.bySpelling.get(trimmed) ?? index.bySpelling.get(trimmed.toLowerCase()) ?? null;
289
+ }
290
+
291
+ /**
292
+ * Whether an unresolved wikilink should be drawn as a ghost.
293
+ *
294
+ * See {@link WikiIndex}: only when the payload independently confirms this
295
+ * note has unresolved links. Obsidian's ghost styling is an *offer to create
296
+ * the note*, so showing it for a link that in fact resolves would be an offer
297
+ * to create a note that already exists.
298
+ */
299
+ export function isGhost(index: WikiIndex): boolean {
300
+ return index.hasGhosts;
301
+ }
302
+
303
+ // --- the markdown renderer -------------------------------------------------------------
304
+
305
+ /** A `[[target|alias]]` token. */
306
+ export interface WikilinkToken extends Tokens.Generic {
307
+ readonly type: "wikilink";
308
+ readonly raw: string;
309
+ readonly target: string;
310
+ readonly alias: string;
311
+ }
312
+
313
+ /**
314
+ * `[[target]]` and `[[target|alias]]`, matched at the start of the remaining
315
+ * inline source.
316
+ *
317
+ * The character classes mirror `src/core/graph/wikilinks.ts`' `WIKILINK_RE`
318
+ * exactly — no `[`, `]` or `|` in the target, anything but `]` in the alias —
319
+ * so the browser tokenises the same spans of text the graph builder counted as
320
+ * links. A looser rule here would draw link styling on text that produced no
321
+ * edge; a tighter one would leave a real edge unlinked.
322
+ */
323
+ const WIKILINK_RE = /^\[\[([^\][|]+)(?:\|([^\]]*))?\]\]/;
324
+
325
+ /** Where the next `[[` is, so marked can skip ahead. */
326
+ function wikilinkStart(src: string): number | undefined {
327
+ const at = src.indexOf("[[");
328
+ return at === -1 ? undefined : at;
329
+ }
330
+
331
+ /**
332
+ * The `[[wikilink]]` inline extension.
333
+ *
334
+ * A real tokenizer rather than a regex pass over the rendered HTML, which is
335
+ * the tempting shortcut and is wrong in both directions: it would rewrite
336
+ * `[[not a link]]` inside a fenced code block, and it would miss nothing but
337
+ * would corrupt any HTML attribute that happened to contain double brackets.
338
+ * Tokenising means a wikilink inside a code span is left alone by
339
+ * construction, because marked never offers the extension that text.
340
+ */
341
+ function wikilinkExtension(index: WikiIndex): TokenizerAndRendererExtension {
342
+ return {
343
+ name: "wikilink",
344
+ level: "inline",
345
+ start: wikilinkStart,
346
+ tokenizer(src: string) {
347
+ return parseWikilink(src);
348
+ },
349
+ renderer(token) {
350
+ return renderWikilinkToken(index, token);
351
+ },
352
+ };
353
+ }
354
+
355
+ /**
356
+ * Render whatever marked hands the `wikilink` renderer.
357
+ *
358
+ * marked types extension tokens as `Tokens.Generic`, so the two fields the
359
+ * tokenizer set arrive as `unknown` and have to be narrowed rather than
360
+ * asserted — a renderer registered under a name is called for every token
361
+ * carrying that `type`, and this module does not own the token stream.
362
+ *
363
+ * Split out of the extension object so the narrowing is directly testable:
364
+ * reached only through `marked`, the non-string arm cannot occur, and an
365
+ * uncoverable branch in the middle of the rendering path is exactly what the
366
+ * 95 % gate is meant to stop accumulating.
367
+ */
368
+ export function renderWikilinkToken(index: WikiIndex, token: Tokens.Generic): string {
369
+ const target = typeof token["target"] === "string" ? token["target"] : "";
370
+ const alias = typeof token["alias"] === "string" ? token["alias"] : "";
371
+ return renderWikilink(index, target, alias);
372
+ }
373
+
374
+ /**
375
+ * Tokenise a leading `[[target]]` / `[[target|alias]]`, or `undefined`.
376
+ *
377
+ * Split out of the extension so it is directly testable: reaching it through
378
+ * `marked` means every assertion about the token shape has to be made
379
+ * indirectly through rendered HTML, and the `alias`-absent case in particular
380
+ * is invisible that way.
381
+ *
382
+ * `target` needs no fallback — group 1 is not optional in
383
+ * {@link WIKILINK_RE}, so a match guarantees it. `alias` genuinely can be
384
+ * absent (`[[target]]` has no `|`), and `""` is the right value for it: the
385
+ * renderer's rule is "show the alias, or the target when there is no alias".
386
+ */
387
+ export function parseWikilink(src: string): WikilinkToken | undefined {
388
+ const match = WIKILINK_RE.exec(src);
389
+ if (match === null) return undefined;
390
+ return { type: "wikilink", raw: match[0], target: match[1] as string, alias: match[2] ?? "" };
391
+ }
392
+
393
+ /**
394
+ * One wikilink, as HTML.
395
+ *
396
+ * Exported because it is the security-relevant half of the extension and
397
+ * deserves direct tests: the target and the alias both come from a note body
398
+ * and both are interpolated into markup, one into an attribute and one into
399
+ * text.
400
+ *
401
+ * A resolved link is an `<a>` with no `href` — see {@link WIKILINK_ATTR} — plus
402
+ * `role="link"` and `tabIndex` so that removing the `href` does not also
403
+ * remove it from the keyboard order. A ghost is a `<span>`, not a disabled
404
+ * anchor: it is not a link, it is a name with no note behind it, and marking
405
+ * it up as a link that refuses to work is worse than not marking it up at all.
406
+ */
407
+ export function renderWikilink(index: WikiIndex, target: string, alias: string): string {
408
+ const text = escapeHtml((alias === "" ? target : alias).trim());
409
+ // Nothing to show and nothing to link to. `WIKILINK_RE` requires at least
410
+ // one character, so this is unreachable through the tokenizer — but
411
+ // `renderWikilinkToken` accepts whatever token stream marked hands it, and
412
+ // an empty ghost span is a hoverable nothing rather than an affordance.
413
+ if (text === "") return "";
414
+ const slug = resolveWikilink(index, target);
415
+ if (slug !== null) {
416
+ return `<a class="weave-wiki" ${WIKILINK_ATTR}="${escapeHtml(slug)}" role="link" tabindex="0" title="${escapeHtml(target.trim())}">${text}</a>`;
417
+ }
418
+ if (!isGhost(index)) return text;
419
+ return `<span class="weave-wiki weave-wiki-ghost" title="no note named ${escapeHtml(target.trim())}">${text}</span>`;
420
+ }
421
+
422
+ /**
423
+ * A `Marked` configured for one render.
424
+ *
425
+ * Rebuilt per render rather than held at module scope, because the wikilink
426
+ * renderer closes over the index and the index depends on which note is open.
427
+ * A module-level instance would need a mutable "current index" global, which
428
+ * is a data race waiting for the first time two renders interleave.
429
+ *
430
+ * `async: false` is explicit so `parse` is typed as returning a `string`
431
+ * rather than `string | Promise<string>`; nothing here is asynchronous.
432
+ */
433
+ export function markdownRenderer(index: WikiIndex): Marked {
434
+ return new Marked({
435
+ gfm: true,
436
+ async: false,
437
+ extensions: [wikilinkExtension(index)],
438
+ renderer: {
439
+ /**
440
+ * Layer 1. Raw HTML in a note body is rendered as **text**.
441
+ *
442
+ * This is the single highest-value line in the file. marked passes
443
+ * through inline and block HTML verbatim by default, so without this
444
+ * override `<img src=x onerror=alert(1)>` reaches DOMPurify as an actual
445
+ * element and the whole question becomes "is DOMPurify's configuration
446
+ * perfect". With it, that string is never markup in the first place.
447
+ *
448
+ * The cost is that a note using `<kbd>` or `<sub>` sees the tag rather
449
+ * than the effect. That is the correct trade for a read-only workspace
450
+ * over content an agent may have written, and it is also what a reader
451
+ * needs in order to notice that a note contains markup at all.
452
+ */
453
+ html({ text }) {
454
+ return escapeHtml(text);
455
+ },
456
+ /** Layer 2, for links. An unsafe scheme yields no `href`. */
457
+ link({ href, title, tokens }) {
458
+ const url = safeUrl(href);
459
+ const text = this.parser.parseInline(tokens);
460
+ const attrs = [
461
+ url === null ? "" : ` href="${escapeHtml(url)}"`,
462
+ title === null || title === undefined || title === "" ? "" : ` title="${escapeHtml(title)}"`,
463
+ // A note that links out is linking out of the workspace, and the
464
+ // workspace is the only thing in this tab worth keeping.
465
+ url === null ? "" : ' rel="noreferrer noopener" target="_blank"',
466
+ ].join("");
467
+ return `<a${attrs}>${text}</a>`;
468
+ },
469
+ /** Layer 2, for images. An unsafe scheme yields the alt text alone. */
470
+ image({ href, title, text }) {
471
+ const url = safeUrl(href);
472
+ if (url === null) return escapeHtml(text);
473
+ const titleAttr = title === null || title === undefined || title === "" ? "" : ` title="${escapeHtml(title)}"`;
474
+ return `<img src="${escapeHtml(url)}" alt="${escapeHtml(text)}"${titleAttr}>`;
475
+ },
476
+ },
477
+ });
478
+ }
479
+
480
+ /** Markdown → HTML, **before** sanitisation. Exported so layer 1 is testable. */
481
+ export function renderMarkdown(body: string, index: WikiIndex): string {
482
+ return markdownRenderer(index).parse(body, { async: false });
483
+ }
484
+
485
+ // --- sanitisation --------------------------------------------------------------------------
486
+
487
+ /** The slice of DOMPurify this module uses. The real one satisfies it. */
488
+ export interface Purifier {
489
+ sanitize(dirty: string, config: SanitizeConfig): string;
490
+ }
491
+
492
+ /**
493
+ * The subset of DOMPurify's config object {@link SANITIZE_CONFIG} sets.
494
+ *
495
+ * The arrays are `string[]` and not `readonly string[]`, which looks like the
496
+ * wrong choice for a security-critical constant and is forced: DOMPurify's own
497
+ * `Config` declares them mutable, so a `readonly` here makes the real
498
+ * `sanitize` unassignable to {@link Purifier} and the only way through is a
499
+ * cast — trading a real type check for a cosmetic one.
500
+ *
501
+ * Immutability is therefore enforced where it actually holds, at runtime:
502
+ * {@link SANITIZE_CONFIG} and each of its arrays are frozen by {@link frozen},
503
+ * so a `push("script")` from anywhere throws in strict mode (which every ES
504
+ * module is) rather than silently widening the allowlist. That is a stronger
505
+ * property than the `readonly` would have given, since `readonly` erases at
506
+ * compile time and this does not.
507
+ */
508
+ export interface SanitizeConfig {
509
+ readonly ALLOWED_TAGS: string[];
510
+ readonly ALLOWED_ATTR: string[];
511
+ readonly ADD_URI_SAFE_ATTR: string[];
512
+ readonly ALLOW_DATA_ATTR: boolean;
513
+ readonly ALLOW_ARIA_ATTR: boolean;
514
+ readonly ALLOW_UNKNOWN_PROTOCOLS: boolean;
515
+ readonly USE_PROFILES: { readonly html: true };
516
+ }
517
+
518
+ /**
519
+ * Layer 3: what survives sanitisation.
520
+ *
521
+ * An **allowlist**, which is the whole point. A denylist (`FORBID_TAGS: […]`)
522
+ * is a list of the attacks its author had heard of, and the interesting ones
523
+ * are always the others — `<math>`/`<svg>` mXSS, `<template>`, mutation
524
+ * gadgets. Everything below is a tag marked can actually emit for the
525
+ * Markdown a note is allowed to contain, and nothing else exists.
526
+ *
527
+ * Absent and deliberately so: `script`, `iframe`, `object`, `embed`, `form`,
528
+ * `input` other than the GFM task-list checkbox, `style`, `svg`, `math`,
529
+ * `template`, `base`, `link`, `meta`. Note that a `<style>` block would be
530
+ * blocked by `style-src 'nonce-…'` as well (§5.2), so that one is defended
531
+ * twice over.
532
+ *
533
+ * `ALLOWED_ATTR` contains no `on*` handler, which is what makes
534
+ * `<img src=x onerror=alert(1)>` inert even in the world where layer 1 has
535
+ * been removed: `onerror` is not on the list, so DOMPurify drops the attribute
536
+ * and keeps the element. It also contains no `style`, so a CSS-based overlay
537
+ * attack has no attribute to live in.
538
+ *
539
+ * `ALLOW_DATA_ATTR: false` with {@link WIKILINK_ATTR} named explicitly is
540
+ * narrower than leaving data attributes on: the only `data-` attribute that
541
+ * may exist in rendered output is the one this module writes.
542
+ * `ADD_URI_SAFE_ATTR` then tells DOMPurify that the attribute's value is not a
543
+ * URL and must not be run through `IS_ALLOWED_URI` — without it a slug like
544
+ * `note-2` is fine but the check is being applied to a value that was never a
545
+ * URL, and relying on a URL grammar to accept a non-URL is the kind of
546
+ * accidental pass that stops being true in a patch release.
547
+ *
548
+ * `ALLOW_UNKNOWN_PROTOCOLS: false` is DOMPurify's default and is stated anyway,
549
+ * because it is the setting that would silently re-admit `javascript:` if some
550
+ * future config merge turned it on.
551
+ */
552
+ /**
553
+ * `Object.freeze` that keeps the value's declared type.
554
+ *
555
+ * The built-in signature returns `Readonly<T>`, which turns `string[]` into
556
+ * `readonly string[]` and then will not satisfy DOMPurify's `Config` — see
557
+ * {@link SanitizeConfig}. This freezes just as hard and says so in a way the
558
+ * compiler can still hand to `sanitize`.
559
+ */
560
+ function frozen<T>(value: T): T {
561
+ return Object.freeze(value);
562
+ }
563
+
564
+ export const SANITIZE_CONFIG: SanitizeConfig = frozen({
565
+ ALLOWED_TAGS: frozen([
566
+ "p",
567
+ "br",
568
+ "hr",
569
+ "h1",
570
+ "h2",
571
+ "h3",
572
+ "h4",
573
+ "h5",
574
+ "h6",
575
+ "em",
576
+ "strong",
577
+ "del",
578
+ "code",
579
+ "pre",
580
+ "blockquote",
581
+ "ul",
582
+ "ol",
583
+ "li",
584
+ "a",
585
+ "span",
586
+ "img",
587
+ "table",
588
+ "thead",
589
+ "tbody",
590
+ "tr",
591
+ "th",
592
+ "td",
593
+ "input",
594
+ ]),
595
+ ALLOWED_ATTR: frozen([
596
+ "href",
597
+ "src",
598
+ "alt",
599
+ "title",
600
+ "class",
601
+ "role",
602
+ "tabindex",
603
+ "colspan",
604
+ "rowspan",
605
+ "align",
606
+ "type",
607
+ "checked",
608
+ "disabled",
609
+ "start",
610
+ WIKILINK_ATTR,
611
+ ]),
612
+ ADD_URI_SAFE_ATTR: frozen([WIKILINK_ATTR]),
613
+ ALLOW_DATA_ATTR: false,
614
+ ALLOW_ARIA_ATTR: false,
615
+ ALLOW_UNKNOWN_PROTOCOLS: false,
616
+ USE_PROFILES: frozen({ html: true as const }),
617
+ });
618
+
619
+ /**
620
+ * Markdown → sanitised HTML.
621
+ *
622
+ * The sanitiser is a parameter rather than a module-level `import DOMPurify`
623
+ * call for the same reason `fetch` is injected into `api.ts`: it is the one
624
+ * thing in this file that cannot run in the test environment, so isolating it
625
+ * behind a port keeps everything else covered. `Note.tsx` passes the real
626
+ * DOMPurify; a test passes a spy and asserts that the pre-sanitiser HTML is
627
+ * what gets handed over, with {@link SANITIZE_CONFIG}.
628
+ */
629
+ export function renderNote(purify: Purifier, body: string, index: WikiIndex): string {
630
+ return purify.sanitize(renderMarkdown(body, index), SANITIZE_CONFIG);
631
+ }
632
+
633
+ // --- reading a click back out of the DOM --------------------------------------------------
634
+
635
+ /** The slice of `Element` {@link wikilinkTargetOf} walks. `HTMLElement` satisfies it. */
636
+ export interface ClosestElement {
637
+ getAttribute(name: string): string | null;
638
+ readonly parentElement: ClosestElement | null;
639
+ }
640
+
641
+ /**
642
+ * Depth limit for the ancestor walk.
643
+ *
644
+ * A malformed or adversarial DOM with a parent cycle would otherwise spin
645
+ * forever inside a click handler and hang the tab. Markdown nesting is a
646
+ * handful of levels; 32 is far past anything real.
647
+ */
648
+ const MAX_ANCESTOR_WALK = 32;
649
+
650
+ /**
651
+ * The node id a click landed on, or `null`.
652
+ *
653
+ * One delegated listener on the container rather than a listener per link:
654
+ * the body is re-rendered wholesale whenever the note changes, and per-element
655
+ * handlers on `dangerouslySetInnerHTML` output would have to be re-attached by
656
+ * hand every time — which is the sort of thing that works until the day it
657
+ * leaks. The walk is needed because the click target is usually the text node's
658
+ * parent (`<em>`, `<code>`) rather than the anchor itself.
659
+ *
660
+ * Returns a full `note:` node id, not a slug, so the caller can hand it
661
+ * straight to the §1.3 context bus without knowing the id format.
662
+ */
663
+ export function wikilinkTargetOf(from: ClosestElement | null): string | null {
664
+ let node = from;
665
+ for (let depth = 0; node !== null && depth < MAX_ANCESTOR_WALK; depth++) {
666
+ const slug = node.getAttribute(WIKILINK_ATTR);
667
+ if (slug !== null && slug !== "") return `${NOTE_PREFIX}${slug}`;
668
+ node = node.parentElement;
669
+ }
670
+ return null;
671
+ }
672
+
673
+ // --- the front-matter header --------------------------------------------------------------
674
+
675
+ /** The note header: title, provenance badge, relative time, tags. */
676
+ export interface NoteHeaderView {
677
+ readonly title: string;
678
+ readonly slug: string;
679
+ readonly provenance: WireNoteSource;
680
+ readonly provenanceGlyph: string;
681
+ readonly provenanceTitle: string;
682
+ readonly updated: string;
683
+ /** The ISO string, for a `<time datetime=…>` and the `title=` tooltip. */
684
+ readonly updatedIso: string;
685
+ readonly created: string;
686
+ readonly createdIso: string;
687
+ readonly tags: readonly string[];
688
+ }
689
+
690
+ /**
691
+ * Resolve a note's front matter for display.
692
+ *
693
+ * The provenance badge reuses the tree's glyphs rather than declaring a second
694
+ * vocabulary — AGENTS.md rule 4 is about the *reader* being able to tell
695
+ * agent-written content apart, and a reader who has learned `◐` in the tree
696
+ * should not have to learn a different mark two columns to the right.
697
+ *
698
+ * `now` is injected, per AGENTS.md, so `"12m ago"` is a tested value rather
699
+ * than whatever the clock said when the suite ran.
700
+ */
701
+ export function noteHeader(note: ViewNote, now: number): NoteHeaderView {
702
+ return {
703
+ title: note.title,
704
+ slug: note.slug,
705
+ provenance: note.source,
706
+ provenanceGlyph: provenanceGlyph(note.source),
707
+ provenanceTitle: provenanceTitle(note.source),
708
+ updated: relTime(note.updated, now),
709
+ updatedIso: note.updated,
710
+ created: relTime(note.created, now),
711
+ createdIso: note.created,
712
+ tags: note.tags,
713
+ };
714
+ }
715
+
716
+ /** `#architecture`. The `#` is display only — tags are stored bare. */
717
+ export function tagLabel(tag: string): string {
718
+ return `#${tag}`;
719
+ }
720
+
721
+ // --- empty states ----------------------------------------------------------------------------
722
+
723
+ /**
724
+ * What the column says when it has no note to render.
725
+ *
726
+ * The three cases are genuinely different and a single "nothing selected"
727
+ * message for all of them is how a user concludes the app is broken when they
728
+ * click a module and the reading pane does not change.
729
+ */
730
+ export function noteEmptyMessage(selectedId: string | null, note: ViewNote | null): string | null {
731
+ if (note !== null) return null;
732
+ if (selectedId === null) return "Select a note to read it here.";
733
+ if (selectedId.startsWith(NOTE_PREFIX)) return "Loading…";
734
+ // A module, a file, a package, the git state: real nodes with no prose
735
+ // behind them. The context rail beside this column is where their
736
+ // relationships live, so the message points at it rather than apologising.
737
+ return "This node has no note body — see the context rail for what it connects to.";
738
+ }