@tasker-systems/temper-design 0.0.0-stage → 0.6.3

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/dist/graph.js ADDED
@@ -0,0 +1,293 @@
1
+ // graph.ts — the graph's geometry and its edge grammar: where nodes settle, which captions can be
2
+ // drawn, how a line ends at its marks, and how a relation is stroked. One policy on every surface;
3
+ // a surface supplies only its plane (the space the simulation runs in) and how many captions it
4
+ // has room for.
5
+ //
6
+ // Nothing here knows a doc type, a home or a colour. A node is an id and whether it holds the
7
+ // core; an edge is two ids and what the answer holds of its relation. A stroke names a ROLE
8
+ // (`structural`, `derived`, `contradicts`), which the contract's `graph.css` recipe paints.
9
+ //
10
+ // The policy, each rule for a reason a reader would otherwise pay for:
11
+ //
12
+ // - **Deterministic.** The simulation starts from a ring in arrival order and runs a fixed number
13
+ // of ticks; captions are ranked by degree, ties broken by code-unit order, never by
14
+ // `localeCompare`, which differs between machines. A view the reader returns to draws the same
15
+ // picture, wherever it is drawn.
16
+ // - **What is unconnected is not placed.** A position claims a relation, so a node with no edge
17
+ // among those carried is returned in `unconnected` for the surface to list beneath the drawing.
18
+ // - **One line per pair.** Every relation between two nodes is joined onto one line, which
19
+ // carries all of them; lines drawn per relation overlay exactly and hide all but the top one.
20
+ // An edge joining a node to itself has no line, and is returned in `selfJoined` to be named.
21
+ // - **Degree counts neighbours**, the lines drawn, not relations. It sizes the mark and its
22
+ // collision radius. A surface that says how many relations a node has keeps its own count.
23
+ // - **Radial only on a contrast.** A radial layout settles the core at the centre and rings the
24
+ // rest round it, and runs only when the input holds both. Asked of an input that is all core or
25
+ // all ring it settles as force: pulling every node toward one radius packs the drawing into a
26
+ // disc and tells the reader nothing.
27
+ import { forceCenter, forceCollide, forceLink, forceManyBody, forceRadial, forceSimulation, forceX, forceY, } from 'd3-force';
28
+ export const DEFAULT_PLANE = { width: 640, height: 400 };
29
+ const TICKS = 300;
30
+ /** A mark's radius: more connected, a little larger, and never past a bound. */
31
+ export const nodeRadius = (degree) => 7 + Math.min(9, degree * 0.6);
32
+ /**
33
+ * How many other carried nodes each node is joined to. Neighbours, not edges: two relations
34
+ * between the same pair are one line, and a node joined only to itself has none.
35
+ */
36
+ export function degrees(nodes, edges) {
37
+ const neighbours = new Map(nodes.map((n) => [n.id, new Set()]));
38
+ for (const e of edges) {
39
+ if (e.source === e.target)
40
+ continue;
41
+ neighbours.get(e.source)?.add(e.target);
42
+ neighbours.get(e.target)?.add(e.source);
43
+ }
44
+ return new Map([...neighbours].map(([id, set]) => [
45
+ id,
46
+ [...set].filter((other) => neighbours.has(other)).length,
47
+ ]));
48
+ }
49
+ /** Settles the connected nodes; see the module header for the policy. */
50
+ export function settle(input, edges, layout, plane = DEFAULT_PLANE) {
51
+ const { width: W, height: H } = plane;
52
+ const degree = degrees(input, edges);
53
+ const connected = input.filter((n) => (degree.get(n.id) ?? 0) > 0);
54
+ const count = Math.max(1, connected.length);
55
+ const nodes = connected.map((n, i) => ({
56
+ id: n.id,
57
+ degree: degree.get(n.id) ?? 0,
58
+ x: W / 2 + Math.cos((i / count) * 2 * Math.PI) * 120,
59
+ y: H / 2 + Math.sin((i / count) * 2 * Math.PI) * 120,
60
+ }));
61
+ const byId = new Map(nodes.map((n) => [n.id, n]));
62
+ const pairs = new Map();
63
+ for (const edge of edges) {
64
+ const source = byId.get(edge.source);
65
+ const target = byId.get(edge.target);
66
+ if (!source || !target || source === target)
67
+ continue;
68
+ const key = JSON.stringify([edge.source, edge.target].sort());
69
+ const pair = pairs.get(key);
70
+ if (pair)
71
+ pair.edges.push({ edge, reversed: pair.source !== source });
72
+ else
73
+ pairs.set(key, { edges: [{ edge, reversed: false }], source, target });
74
+ }
75
+ const joined = [...pairs.values()];
76
+ const core = new Set(connected.filter((n) => n.core).map((n) => n.id));
77
+ const radial = layout === 'radial' && core.size > 0 && core.size < connected.length;
78
+ // A link between the core and the ring runs looser and weaker, so the radial force can hold the
79
+ // two apart; links within either keep their structure.
80
+ const crosses = (j) => radial && core.has(j.source.id) !== core.has(j.target.id);
81
+ const sim = forceSimulation(nodes)
82
+ .force('link', forceLink(joined.map((j) => ({ source: j.source, target: j.target })))
83
+ .distance((_l, i) => (crosses(joined[i]) ? 150 : 80))
84
+ .strength((_l, i) => (crosses(joined[i]) ? 0.15 : 0.6)))
85
+ .force('charge', forceManyBody().strength(-260).distanceMax(240))
86
+ .force('center', forceCenter(W / 2, H / 2))
87
+ // A weak pull to the middle keeps separate clusters near one another, so a drawing fitted to
88
+ // where nodes land does not shrink every mark, and a fixed canvas does not lose a cluster.
89
+ .force('x', forceX(W / 2).strength(0.08))
90
+ .force('y', forceY(H / 2).strength(0.08))
91
+ .force('collide', forceCollide().radius((n) => nodeRadius(n.degree) + 6))
92
+ .stop();
93
+ if (radial) {
94
+ const minDim = Math.min(W, H);
95
+ sim.force('radial', forceRadial((n) => (core.has(n.id) ? minDim * 0.06 : minDim * 0.44), W / 2, H / 2).strength(0.6));
96
+ }
97
+ for (let i = 0; i < TICKS; i++)
98
+ sim.tick();
99
+ return {
100
+ nodes,
101
+ edges: joined,
102
+ selfJoined: edges.filter((e) => e.source === e.target && degree.has(e.source)),
103
+ unconnected: input.filter((n) => (degree.get(n.id) ?? 0) === 0).map((n) => n.id),
104
+ radial,
105
+ };
106
+ }
107
+ const overlaps = (a, b) => a.x1 < b.x2 && b.x1 < a.x2 && a.y1 < b.y2 && b.y1 < a.y2;
108
+ /** Truncate to `max` characters (code points, so no character is split) with a trailing ellipsis. */
109
+ export const truncate = (text, max) => [...text].length <= max ? text : `${[...text].slice(0, max - 1).join('')}…`;
110
+ /** Code-unit order: the same on every machine, unlike `localeCompare`. */
111
+ const byCodeUnit = (a, b) => (a < b ? -1 : a > b ? 1 : 0);
112
+ export const CAPTION = {
113
+ max: 24,
114
+ charWidth: 5.4,
115
+ lineHeight: 12,
116
+ cap: 28,
117
+ padding: 2,
118
+ };
119
+ /**
120
+ * Which captions can be drawn without landing on another caption or on another node's mark.
121
+ * Greedy, most connected first, ties broken by code-unit order on id. A caption that would collide
122
+ * is dropped, not nudged: a label moved off its node reads as belonging to whatever it landed
123
+ * near. Every node is still drawn and still named elsewhere (a tooltip, a hover card, a list).
124
+ */
125
+ export function placeCaptions(nodes, options = {}) {
126
+ const o = { ...CAPTION, ...options };
127
+ const marks = new Map(nodes.map((n) => {
128
+ const r = nodeRadius(n.degree) + o.padding;
129
+ return [n.id, { x1: n.x - r, y1: n.y - r, x2: n.x + r, y2: n.y + r }];
130
+ }));
131
+ const ranked = [...nodes].sort((a, b) => b.degree - a.degree || byCodeUnit(a.id, b.id));
132
+ const taken = [];
133
+ const placed = [];
134
+ for (const n of ranked) {
135
+ if (placed.length >= o.max)
136
+ break;
137
+ const text = truncate(n.label, o.cap);
138
+ const half = ([...text].length * o.charWidth) / 2;
139
+ const baseline = n.y + nodeRadius(n.degree) + o.lineHeight;
140
+ const box = {
141
+ x1: n.x - half - o.padding,
142
+ y1: baseline - o.lineHeight * 0.75,
143
+ x2: n.x + half + o.padding,
144
+ y2: baseline + o.padding,
145
+ };
146
+ const hitsAMark = [...marks].some(([id, b]) => id !== n.id && overlaps(box, b));
147
+ if (hitsAMark || taken.some((b) => overlaps(box, b)))
148
+ continue;
149
+ taken.push(box);
150
+ placed.push({ id: n.id, x: n.x, y: baseline, text });
151
+ }
152
+ return placed;
153
+ }
154
+ /**
155
+ * The drawing's frame: every mark and caption, with a margin. A surface that fits its viewBox to
156
+ * the drawing reads it; one with a fixed canvas does not need it. Captions are measured with the
157
+ * same options `placeCaptions` was given, so the frame fits what was placed.
158
+ */
159
+ export function frame(nodes, captions, { margin = 12, plane = DEFAULT_PLANE, caption = {}, } = {}) {
160
+ const o = { ...CAPTION, ...caption };
161
+ if (nodes.length === 0)
162
+ return { x1: 0, y1: 0, x2: plane.width, y2: plane.height };
163
+ let box = { x1: Infinity, y1: Infinity, x2: -Infinity, y2: -Infinity };
164
+ const grow = (b) => {
165
+ box = {
166
+ x1: Math.min(box.x1, b.x1),
167
+ y1: Math.min(box.y1, b.y1),
168
+ x2: Math.max(box.x2, b.x2),
169
+ y2: Math.max(box.y2, b.y2),
170
+ };
171
+ };
172
+ for (const n of nodes) {
173
+ const r = nodeRadius(n.degree);
174
+ grow({ x1: n.x - r, y1: n.y - r, x2: n.x + r, y2: n.y + r });
175
+ }
176
+ for (const c of captions) {
177
+ const half = ([...c.text].length * o.charWidth) / 2;
178
+ grow({ x1: c.x - half, y1: c.y - o.lineHeight, x2: c.x + half, y2: c.y + 3 });
179
+ }
180
+ return { x1: box.x1 - margin, y1: box.y1 - margin, x2: box.x2 + margin, y2: box.y2 + margin };
181
+ }
182
+ /** How far from its centre a line ends at a mark: just past the mark's own rim. */
183
+ export const rimOf = (degree) => nodeRadius(degree) + 1.5;
184
+ /**
185
+ * A pair's line, trimmed to the rims of its two marks, and an arrowhead at each end an edge
186
+ * points to. Heads are shapes, not SVG markers, so a drawing holds no ids and any number of
187
+ * graphs share a page. A line run centre to centre beneath its marks would end every head under
188
+ * the node it points at. `rims` overrides where each end stops, for a mark that is not a circle.
189
+ */
190
+ export function edgeGeometry(source, target, ends, rims = {
191
+ source: rimOf(source.degree),
192
+ target: rimOf(target.degree),
193
+ }) {
194
+ const dx = target.x - source.x;
195
+ const dy = target.y - source.y;
196
+ const len = Math.hypot(dx, dy) || 1;
197
+ const ux = dx / len;
198
+ const uy = dy / len;
199
+ // Marks closer than their two rims would trim the line past itself and turn a head backwards;
200
+ // the trims shrink together until they meet, so the line collapses instead.
201
+ const fit = Math.min(1, len / (rims.source + rims.target || 1));
202
+ const x1 = source.x + ux * rims.source * fit;
203
+ const y1 = source.y + uy * rims.source * fit;
204
+ const x2 = target.x - ux * rims.target * fit;
205
+ const y2 = target.y - uy * rims.target * fit;
206
+ const head = (tipX, tipY, dirX, dirY) => {
207
+ const size = 6;
208
+ const bx = tipX - dirX * size;
209
+ const by = tipY - dirY * size;
210
+ const px = -dirY * size * 0.5;
211
+ const py = dirX * size * 0.5;
212
+ const f = (v) => v.toFixed(2);
213
+ return `M${f(tipX)},${f(tipY)} L${f(bx + px)},${f(by + py)} L${f(bx - px)},${f(by - py)} Z`;
214
+ };
215
+ const heads = [
216
+ ...(ends.atTarget ? [head(x2, y2, ux, uy)] : []),
217
+ ...(ends.atSource ? [head(x1, y1, -ux, -uy)] : []),
218
+ ];
219
+ return { x1, y1, x2, y2, heads };
220
+ }
221
+ /** An edge with no `edgeKind` draws as the structural default: solid, headed by its polarity. */
222
+ const kindOf = (edge) => edge.edgeKind ?? 'contains';
223
+ const KIND_DASH = {
224
+ contains: null,
225
+ leads_to: '7 4',
226
+ express: '1 4',
227
+ near: '4 4',
228
+ };
229
+ /**
230
+ * Stroke for an edge whose weight is absent. A width cannot say "not reported" by itself, so a
231
+ * surface names the weight, or its absence, where it names the relation.
232
+ */
233
+ export const UNWEIGHTED_WIDTH = 1.4;
234
+ /**
235
+ * Weight → width over the range weights take: asserted at 1.0 by default and only lowered by
236
+ * decay, so (0, 1] spans 1 to 2.5. A weight outside it is held to the bounds, so a stray value
237
+ * cannot draw a line that dominates the drawing.
238
+ */
239
+ export const widthOf = (weight) => 1 + 1.5 * Math.min(1, Math.max(0, weight));
240
+ export function stroke(edge) {
241
+ const role = edge.label === 'contradicts'
242
+ ? 'contradicts'
243
+ : edge.label === 'derived_from'
244
+ ? 'derived'
245
+ : 'structural';
246
+ // The label dashes first: a `derived_from` line dashes whatever its kind.
247
+ const dash = edge.label === 'derived_from' ? '7 4' : KIND_DASH[kindOf(edge)];
248
+ const width = edge.weight == null ? UNWEIGHTED_WIDTH : widthOf(edge.weight);
249
+ // A `near` relation points both ways at once, so it heads neither end.
250
+ const headed = kindOf(edge) !== 'near';
251
+ const forward = (edge.polarity ?? 'forward') === 'forward';
252
+ return {
253
+ role,
254
+ dash,
255
+ width,
256
+ atSource: headed && !forward,
257
+ atTarget: headed && forward,
258
+ cross: role === 'contradicts',
259
+ };
260
+ }
261
+ /** Where a pair's arrowheads go: at each end some edge between them points to. */
262
+ export function pairEnds(edges) {
263
+ const ends = { atSource: false, atTarget: false };
264
+ for (const { edge, reversed } of edges) {
265
+ const s = stroke(edge);
266
+ if (!s.atSource && !s.atTarget)
267
+ continue;
268
+ if (s.atTarget !== reversed)
269
+ ends.atTarget = true;
270
+ else
271
+ ends.atSource = true;
272
+ }
273
+ return ends;
274
+ }
275
+ /**
276
+ * How a pair's line is drawn when its edges differ: a contradiction is never hidden behind a
277
+ * plainer relation, a line dashes only when every edge on it dashes — with the dash the first
278
+ * dashing edge names — and it is never thinner than the heaviest relation on it.
279
+ */
280
+ export function pairStroke(edges) {
281
+ const strokes = edges.map(({ edge }) => stroke(edge));
282
+ const first = strokes[0];
283
+ if (first === undefined)
284
+ throw new Error('pairStroke: a pair carries at least one edge');
285
+ const role = strokes.some((s) => s.role === 'contradicts')
286
+ ? 'contradicts'
287
+ : strokes.every((s) => s.role === 'derived')
288
+ ? 'derived'
289
+ : 'structural';
290
+ const dash = strokes.every((s) => s.dash) ? first.dash : null;
291
+ const width = Math.max(...strokes.map((s) => s.width));
292
+ return { role, dash, width, cross: role === 'contradicts' };
293
+ }
@@ -0,0 +1,22 @@
1
+ /**
2
+ * The latest handoff: the reader's most recently updated session, and the next steps it recorded.
3
+ * Both surfaces show it on arrival under the same name. `nextSection` is copied here from the
4
+ * desktop, its heading parse rewritten without the backtracking pattern but answering identically, (`temper-contrib`, `apps/desktop/src/lib/shell/next-section.ts`); once the
5
+ * desktop imports it from here instead of keeping its own copy, the two cannot quote the same
6
+ * session differently. Until then they can drift.
7
+ *
8
+ * The next steps a session recorded: the section under the first heading that starts with "Next"
9
+ * ("Next", "Next steps", "Next: the home view"), at any level, up to the next heading of the same
10
+ * or a higher level. Quoted as written, trimmed and bounded — never summarised, and `null` when the
11
+ * session has no such heading, so nothing is ever invented in its place.
12
+ */
13
+ /** The name both surfaces give the latest handoff. */
14
+ export declare const HANDOFF_LABEL = "latest handoff";
15
+ /** How much of the section is quoted before it says it continues. */
16
+ export declare const NEXT_BOUND = 600;
17
+ export type NextSection = {
18
+ heading: string;
19
+ text: string;
20
+ truncated: boolean;
21
+ };
22
+ export declare function nextSection(markdown: string, bound?: number): NextSection | null;
@@ -0,0 +1,70 @@
1
+ /**
2
+ * The latest handoff: the reader's most recently updated session, and the next steps it recorded.
3
+ * Both surfaces show it on arrival under the same name. `nextSection` is copied here from the
4
+ * desktop, its heading parse rewritten without the backtracking pattern but answering identically, (`temper-contrib`, `apps/desktop/src/lib/shell/next-section.ts`); once the
5
+ * desktop imports it from here instead of keeping its own copy, the two cannot quote the same
6
+ * session differently. Until then they can drift.
7
+ *
8
+ * The next steps a session recorded: the section under the first heading that starts with "Next"
9
+ * ("Next", "Next steps", "Next: the home view"), at any level, up to the next heading of the same
10
+ * or a higher level. Quoted as written, trimmed and bounded — never summarised, and `null` when the
11
+ * session has no such heading, so nothing is ever invented in its place.
12
+ */
13
+ /** The name both surfaces give the latest handoff. */
14
+ export const HANDOFF_LABEL = 'latest handoff';
15
+ /** How much of the section is quoted before it says it continues. */
16
+ export const NEXT_BOUND = 600;
17
+ /** An ATX heading's opener: one to six `#`, then whitespace. Anchored and unambiguous. */
18
+ const OPENER = /^(#{1,6})\s+/;
19
+ /**
20
+ * A line read as an ATX heading — its level and its text, with any closing `#`s and the space
21
+ * around them dropped — or `null`. The text is taken by slicing and trimming rather than by a
22
+ * pattern with adjacent optional runs (`(.*?)\s*#*\s*$`), which can backtrack polynomially on a
23
+ * long run of spaces in an uncontrolled body.
24
+ */
25
+ function heading(line) {
26
+ const open = OPENER.exec(line);
27
+ if (!open)
28
+ return null;
29
+ let text = line.slice(open[0].length).trimEnd();
30
+ let end = text.length;
31
+ while (end > 0 && text[end - 1] === '#')
32
+ end--;
33
+ text = text.slice(0, end).trimEnd();
34
+ return { level: open[1].length, text };
35
+ }
36
+ export function nextSection(markdown, bound = NEXT_BOUND) {
37
+ const lines = markdown.split(/\r?\n/);
38
+ let fence = false;
39
+ let start = -1;
40
+ let level = 0;
41
+ let title = '';
42
+ const body = [];
43
+ for (let i = 0; i < lines.length; i++) {
44
+ const line = lines[i];
45
+ if (/^\s*(```|~~~)/.test(line))
46
+ fence = !fence;
47
+ const match = fence ? null : heading(line);
48
+ if (start < 0) {
49
+ if (match && /^next\b/i.test(match.text)) {
50
+ start = i;
51
+ level = match.level;
52
+ title = match.text;
53
+ }
54
+ continue;
55
+ }
56
+ if (match && match.level <= level)
57
+ break;
58
+ body.push(line);
59
+ }
60
+ if (start < 0)
61
+ return null;
62
+ const text = body.join('\n').trim();
63
+ if (!text)
64
+ return null;
65
+ if (text.length <= bound)
66
+ return { heading: title, text, truncated: false };
67
+ const cut = text.slice(0, bound);
68
+ const atWord = cut.slice(0, Math.max(cut.lastIndexOf(' '), bound - 40));
69
+ return { heading: title, text: `${atWord.trimEnd()}…`, truncated: true };
70
+ }
@@ -0,0 +1,2 @@
1
+ /** Highlight `code` as `lang` (or plaintext when unregistered) into safe hljs HTML. */
2
+ export declare function highlightCode(code: string, lang?: string): string;
@@ -0,0 +1,48 @@
1
+ // highlight.ts — the one highlight.js registration, and the direct-code highlight helper.
2
+ //
3
+ // `markdown.ts` (fenced blocks in document bodies) and any surface's direct-code viewers share
4
+ // this: one registration list, one set of span classes, one theme wherever `.hljs` appears.
5
+ //
6
+ // **Why `{@html highlightCode(...)}` is safe by construction:** hljs escapes the input text
7
+ // and emits only its own `<span class="hljs-*">` wrappers — no input byte can become markup.
8
+ // This is the documented property that makes it the standard choice for code viewers that
9
+ // render arbitrary stored content.
10
+ //
11
+ // Unknown languages are PLAINTEXT, never auto-detected: a wrong guess colors prose as code,
12
+ // which reads as wrong more often than it reads as helpful. `plaintext` is registered
13
+ // explicitly — `hljs/lib/core` does not ship it pre-registered (a full-bundle behavior the
14
+ // core entry silently lacks).
15
+ import hljs from 'highlight.js/lib/core';
16
+ import bash from 'highlight.js/lib/languages/bash';
17
+ import diff from 'highlight.js/lib/languages/diff';
18
+ import javascript from 'highlight.js/lib/languages/javascript';
19
+ import json from 'highlight.js/lib/languages/json';
20
+ import plaintext from 'highlight.js/lib/languages/plaintext';
21
+ import rust from 'highlight.js/lib/languages/rust';
22
+ import sql from 'highlight.js/lib/languages/sql';
23
+ import typescript from 'highlight.js/lib/languages/typescript';
24
+ import xml from 'highlight.js/lib/languages/xml';
25
+ import yaml from 'highlight.js/lib/languages/yaml';
26
+ hljs.registerLanguage('bash', bash);
27
+ hljs.registerLanguage('diff', diff);
28
+ hljs.registerLanguage('javascript', javascript);
29
+ hljs.registerLanguage('json', json);
30
+ hljs.registerLanguage('plaintext', plaintext);
31
+ hljs.registerLanguage('rust', rust);
32
+ hljs.registerLanguage('sql', sql);
33
+ hljs.registerLanguage('typescript', typescript);
34
+ hljs.registerLanguage('xml', xml);
35
+ hljs.registerLanguage('yaml', yaml);
36
+ // Aliases the fences actually use — `js`, `ts`, `sh` and friends map onto registered grammars.
37
+ hljs.registerAliases(['js', 'jsx'], { languageName: 'javascript' });
38
+ hljs.registerAliases(['ts', 'tsx'], { languageName: 'typescript' });
39
+ hljs.registerAliases(['sh', 'shell', 'zsh'], { languageName: 'bash' });
40
+ hljs.registerAliases(['yml'], { languageName: 'yaml' });
41
+ hljs.registerAliases(['html', 'svelte'], { languageName: 'xml' });
42
+ /** Highlight `code` as `lang` (or plaintext when unregistered) into safe hljs HTML. */
43
+ export function highlightCode(code, lang) {
44
+ if (lang && hljs.getLanguage(lang)) {
45
+ return hljs.highlight(code, { language: lang }).value;
46
+ }
47
+ return hljs.highlight(code, { language: 'plaintext' }).value;
48
+ }
@@ -0,0 +1,32 @@
1
+ /**
2
+ * The most links one body resolves: the id-set bound the server's `ids` filter enforces (the
3
+ * `IdSet` schema's `maxItems` in the published contract), so one body is one request. Links past
4
+ * it render unchecked.
5
+ */
6
+ export declare const BODY_LINK_CAP = 256;
7
+ /** What temper answered for one id. Nothing answered is not an entry: it renders unchecked. */
8
+ export type LinkResolution = {
9
+ state: 'resolved';
10
+ title: string;
11
+ } | {
12
+ state: 'absent';
13
+ };
14
+ /** Answers by lowercase id. */
15
+ export type LinkTable = ReadonlyMap<string, LinkResolution>;
16
+ /**
17
+ * Hover words for a link temper answered for with nothing the reader can see. One sentence for
18
+ * every cause: a reader must not be able to tell a deleted resource from one they may not read.
19
+ */
20
+ export declare const ABSENT_WORDS = "Not found, or not visible to you";
21
+ /** Hover words for a link temper did not answer for: nothing was verified either way. */
22
+ export declare const UNCHECKED_WORDS = "Not checked against temper";
23
+ /** The lowercase id an href cites, or `null` when it is not exactly a vault link. */
24
+ export declare function vaultLinkId(href: string): string | null;
25
+ /**
26
+ * The distinct vault-link ids in a body, in first-occurrence order, at most
27
+ * {@link BODY_LINK_CAP}; `dropped` counts the distinct ids past the cap.
28
+ */
29
+ export declare function extractVaultLinks(markdown: string): {
30
+ ids: string[];
31
+ dropped: number;
32
+ };
package/dist/links.js ADDED
@@ -0,0 +1,65 @@
1
+ // links.ts — body links: which hrefs in a body name a temper resource, and the words a link says
2
+ // when temper did not vouch for it.
3
+ //
4
+ // A body cites another resource as `[<title>](./<uuidv7>)`. The typed title is a hint, written
5
+ // once and never updated, so a surface resolves the ids against temper and the sanitize pass
6
+ // (`sanitize.ts`) rewrites each link from that answer. This module owns the half that needs no
7
+ // DOM: the matcher both halves agree on, and extraction, which a surface runs before it renders —
8
+ // on the web, in the page's server load.
9
+ //
10
+ // **Exact form only.** `./` and a 36-character UUID, with an optional trailing `/` because the
11
+ // desktop's router follows that form too; nothing looser. A decorated `slug-<uuid>` ref, an
12
+ // absolute URL or a bare UUID is left alone — reading any of them as a citation would be a guess.
13
+ //
14
+ // **Extraction is bounded and total.** It carries `prepareMarkdown`'s source bound, since it now
15
+ // runs on the server where any reader can make a body be parsed, and a body marked cannot lex
16
+ // yields no ids rather than a throw: every link in it then renders unchecked, which is what an
17
+ // unanswered link is.
18
+ import { Marked } from 'marked';
19
+ import { MAX_SOURCE_LENGTH } from './markdown.js';
20
+ /**
21
+ * The most links one body resolves: the id-set bound the server's `ids` filter enforces (the
22
+ * `IdSet` schema's `maxItems` in the published contract), so one body is one request. Links past
23
+ * it render unchecked.
24
+ */
25
+ export const BODY_LINK_CAP = 256;
26
+ /**
27
+ * Hover words for a link temper answered for with nothing the reader can see. One sentence for
28
+ * every cause: a reader must not be able to tell a deleted resource from one they may not read.
29
+ */
30
+ export const ABSENT_WORDS = 'Not found, or not visible to you';
31
+ /** Hover words for a link temper did not answer for: nothing was verified either way. */
32
+ export const UNCHECKED_WORDS = 'Not checked against temper';
33
+ const VAULT_LINK = /^\.\/([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\/?$/i;
34
+ /** The lowercase id an href cites, or `null` when it is not exactly a vault link. */
35
+ export function vaultLinkId(href) {
36
+ const m = VAULT_LINK.exec(href);
37
+ return m ? m[1].toLowerCase() : null;
38
+ }
39
+ // Lexing only. The renderer's instance (`markdown.ts`) adds highlighting, which changes how code
40
+ // renders, not what the lexer finds — so a plain instance sees the same links.
41
+ const lexer = new Marked();
42
+ /**
43
+ * The distinct vault-link ids in a body, in first-occurrence order, at most
44
+ * {@link BODY_LINK_CAP}; `dropped` counts the distinct ids past the cap.
45
+ */
46
+ export function extractVaultLinks(markdown) {
47
+ const none = { ids: [], dropped: 0 };
48
+ if (typeof markdown !== 'string' || markdown.length > MAX_SOURCE_LENGTH)
49
+ return none;
50
+ const seen = new Set();
51
+ try {
52
+ lexer.walkTokens(lexer.lexer(markdown), (token) => {
53
+ if (token.type !== 'link')
54
+ return;
55
+ const id = vaultLinkId(token.href);
56
+ if (id)
57
+ seen.add(id);
58
+ });
59
+ }
60
+ catch {
61
+ return none;
62
+ }
63
+ const all = [...seen];
64
+ return { ids: all.slice(0, BODY_LINK_CAP), dropped: Math.max(0, all.length - BODY_LINK_CAP) };
65
+ }
@@ -0,0 +1,23 @@
1
+ /** Parse markdown to HTML, with fenced code blocks highlighted per the registered set. */
2
+ export declare function parseMarkdown(markdown: string): string;
3
+ /**
4
+ * Cap on source markdown entering the parse. Bounds marked's superlinear worst cases (deep
5
+ * nesting, emphasis runs) at the gate; real documents sit orders of magnitude below it.
6
+ */
7
+ export declare const MAX_SOURCE_LENGTH = 262144;
8
+ /**
9
+ * What renders when source is past the bound or shaped so marked cannot parse it. Static, so
10
+ * `{@html}` receives exactly this string and nothing user-shaped; it passes through the
11
+ * sanitizer unchanged.
12
+ */
13
+ export declare const REFUSAL_HTML = "<p class=\"md-refusal\">This document could not be rendered.</p>";
14
+ /**
15
+ * Markdown → HTML without the sanitizer, in one synchronous pass. Total: source past the bound,
16
+ * or a shape marked cannot parse, renders {@link REFUSAL_HTML} instead of throwing out of the
17
+ * caller's derive.
18
+ *
19
+ * The result is NOT safe for `{@html}` on its own — the sanitizer owns that step. This is still
20
+ * the sanctioned source of markdown HTML: parse and the bound are composed here, never
21
+ * separately at a call site.
22
+ */
23
+ export declare function prepareMarkdown(markdown: string): string;
@@ -0,0 +1,61 @@
1
+ // markdown.ts — the one Marked instance, with fenced-block syntax highlighting, and the one
2
+ // markdown→HTML preparation.
3
+ //
4
+ // An isolated `Marked` instance rather than `marked.use()` on the module singleton: a global
5
+ // mutation would silently reconfigure every other consumer of the package, and the instance
6
+ // makes the highlighting pipeline unit-testable without a component or a DOM.
7
+ //
8
+ // highlight.js over Shiki here because the pipeline is synchronous end to end — `parse` feeds
9
+ // the sanitizer pass, and an async highlighter would force that whole chain to restructure for
10
+ // prettier output. Language registration and the unknown-language-means-plaintext rule live in
11
+ // `highlight.ts`, shared with any surface's direct-code viewers.
12
+ //
13
+ // This module is DOM-FREE, deliberately, so it is servable wherever a surface renders. The
14
+ // sanitizer (`sanitize.ts`) needs a DOM and is loaded on the client only; a surface's renderer
15
+ // gates its {@html} on that sanitizer and never falls back to unsanitized output — which is why
16
+ // `prepareMarkdown` may return parse output that only the sanitizer may render.
17
+ import { Marked } from 'marked';
18
+ import { markedHighlight } from 'marked-highlight';
19
+ import { highlightCode } from './highlight.js';
20
+ const marked = new Marked(markedHighlight({
21
+ // Both classes: `language-*` is what marked emits (and what copy-to-clipboard
22
+ // affordances key on); `hljs` is the theme hook.
23
+ langPrefix: 'hljs language-',
24
+ highlight(code, lang) {
25
+ return highlightCode(code, lang || undefined);
26
+ },
27
+ }));
28
+ /** Parse markdown to HTML, with fenced code blocks highlighted per the registered set. */
29
+ export function parseMarkdown(markdown) {
30
+ return marked.parse(markdown, { async: false });
31
+ }
32
+ /**
33
+ * Cap on source markdown entering the parse. Bounds marked's superlinear worst cases (deep
34
+ * nesting, emphasis runs) at the gate; real documents sit orders of magnitude below it.
35
+ */
36
+ export const MAX_SOURCE_LENGTH = 262_144;
37
+ /**
38
+ * What renders when source is past the bound or shaped so marked cannot parse it. Static, so
39
+ * `{@html}` receives exactly this string and nothing user-shaped; it passes through the
40
+ * sanitizer unchanged.
41
+ */
42
+ export const REFUSAL_HTML = '<p class="md-refusal">This document could not be rendered.</p>';
43
+ /**
44
+ * Markdown → HTML without the sanitizer, in one synchronous pass. Total: source past the bound,
45
+ * or a shape marked cannot parse, renders {@link REFUSAL_HTML} instead of throwing out of the
46
+ * caller's derive.
47
+ *
48
+ * The result is NOT safe for `{@html}` on its own — the sanitizer owns that step. This is still
49
+ * the sanctioned source of markdown HTML: parse and the bound are composed here, never
50
+ * separately at a call site.
51
+ */
52
+ export function prepareMarkdown(markdown) {
53
+ if (markdown.length > MAX_SOURCE_LENGTH)
54
+ return REFUSAL_HTML;
55
+ try {
56
+ return parseMarkdown(markdown);
57
+ }
58
+ catch {
59
+ return REFUSAL_HTML;
60
+ }
61
+ }