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,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
|
+
"&": "&",
|
|
81
|
+
"<": "<",
|
|
82
|
+
">": ">",
|
|
83
|
+
'"': """,
|
|
84
|
+
"'": "'",
|
|
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
|
+
}
|