@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/README.md +330 -2
- package/brand/mark.svg +4 -0
- package/brand/wordmark.svg +5 -0
- package/contract/base.css +221 -0
- package/contract/graph.css +53 -0
- package/contract/markdown.css +279 -0
- package/contract/region.css +98 -0
- package/contract/shadcn-bridge.css +86 -0
- package/contract/tailwind-roles.css +67 -0
- package/contract/tailwind.css +70 -0
- package/contract/theme.schema.json +444 -0
- package/dist/graph.d.ts +191 -0
- package/dist/graph.js +293 -0
- package/dist/handoff.d.ts +22 -0
- package/dist/handoff.js +70 -0
- package/dist/highlight.d.ts +2 -0
- package/dist/highlight.js +48 -0
- package/dist/links.d.ts +32 -0
- package/dist/links.js +65 -0
- package/dist/markdown.d.ts +23 -0
- package/dist/markdown.js +61 -0
- package/dist/omission.d.ts +81 -0
- package/dist/omission.js +84 -0
- package/dist/region.d.ts +16 -0
- package/dist/region.js +59 -0
- package/dist/sanitize.d.ts +22 -0
- package/dist/sanitize.js +205 -0
- package/dist/theme.d.ts +29 -0
- package/dist/theme.js +37 -0
- package/dist/vocabulary.d.ts +35 -0
- package/dist/vocabulary.js +74 -0
- package/dist/ways-in.d.ts +36 -0
- package/dist/ways-in.js +35 -0
- package/guard/colours.mjs +347 -0
- package/package.json +97 -4
- package/quiet-instrument/theme.css +66 -0
- package/quiet-instrument/theme.json +85 -0
- package/quiet-instrument-paper/theme.css +66 -0
- package/quiet-instrument-paper/theme.json +85 -0
- package/themes.json +14 -0
- package/toolchain/check.mjs +76 -0
- package/toolchain/majors.json +18 -0
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;
|
package/dist/handoff.js
ADDED
|
@@ -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,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
|
+
}
|
package/dist/links.d.ts
ADDED
|
@@ -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;
|
package/dist/markdown.js
ADDED
|
@@ -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
|
+
}
|