@aiwayds/dsh-topics-memory 0.6.0
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 +112 -0
- package/README.zh.md +112 -0
- package/cordis.patch.yml +8 -0
- package/lib/commands.d.ts +21 -0
- package/lib/commands.js +368 -0
- package/lib/config.d.ts +122 -0
- package/lib/config.js +123 -0
- package/lib/delegation.d.ts +23 -0
- package/lib/delegation.js +21 -0
- package/lib/digest.d.ts +42 -0
- package/lib/digest.js +101 -0
- package/lib/distill.d.ts +186 -0
- package/lib/distill.js +759 -0
- package/lib/git.d.ts +66 -0
- package/lib/git.js +169 -0
- package/lib/ilog.d.ts +53 -0
- package/lib/ilog.js +70 -0
- package/lib/index.d.ts +73 -0
- package/lib/index.js +504 -0
- package/lib/observer.d.ts +41 -0
- package/lib/observer.js +128 -0
- package/lib/okf.d.ts +112 -0
- package/lib/okf.js +279 -0
- package/lib/onboard.d.ts +205 -0
- package/lib/onboard.js +664 -0
- package/lib/paths.d.ts +48 -0
- package/lib/paths.js +84 -0
- package/lib/retrieval.d.ts +61 -0
- package/lib/retrieval.js +178 -0
- package/lib/service.d.ts +96 -0
- package/lib/service.js +375 -0
- package/lib/store.d.ts +153 -0
- package/lib/store.js +489 -0
- package/lib/sync.d.ts +52 -0
- package/lib/sync.js +154 -0
- package/lib/tools.d.ts +8 -0
- package/lib/tools.js +208 -0
- package/lib/viz.d.ts +33 -0
- package/lib/viz.js +310 -0
- package/lib/yaml.d.ts +18 -0
- package/lib/yaml.js +334 -0
- package/package.json +97 -0
- package/src/commands.ts +397 -0
- package/src/config.ts +149 -0
- package/src/delegation.ts +27 -0
- package/src/digest.ts +125 -0
- package/src/distill.ts +885 -0
- package/src/git.ts +209 -0
- package/src/ilog.ts +99 -0
- package/src/index.ts +574 -0
- package/src/observer.ts +150 -0
- package/src/okf.ts +317 -0
- package/src/onboard.ts +762 -0
- package/src/paths.ts +104 -0
- package/src/retrieval.ts +219 -0
- package/src/service.ts +400 -0
- package/src/store.ts +567 -0
- package/src/sync.ts +170 -0
- package/src/tools.ts +225 -0
- package/src/viz.ts +333 -0
- package/src/yaml.ts +322 -0
package/src/viz.ts
ADDED
|
@@ -0,0 +1,333 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bundle relationship graph visualization (ADR 0005 graph-shaped bundle).
|
|
3
|
+
*
|
|
4
|
+
* `buildGraph` turns the roster into nodes + edges (`depends` + body links);
|
|
5
|
+
* `renderGraphHtml` emits a fully self-contained HTML page — hand-rolled
|
|
6
|
+
* force-directed SVG, zero external scripts, works offline in any browser.
|
|
7
|
+
* The graph JSON is embedded as `application/json` with `<` escaped so a
|
|
8
|
+
* topic title can never break out of the script tag.
|
|
9
|
+
*
|
|
10
|
+
* @module viz
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import type { RetrievableTopic } from './retrieval.ts'
|
|
14
|
+
|
|
15
|
+
export interface VizNode {
|
|
16
|
+
id: string
|
|
17
|
+
title: string
|
|
18
|
+
status: string
|
|
19
|
+
tags: string[]
|
|
20
|
+
degree: number
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface VizEdge {
|
|
24
|
+
from: string
|
|
25
|
+
to: string
|
|
26
|
+
via: 'depends' | 'link'
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface VizGraph {
|
|
30
|
+
nodes: VizNode[]
|
|
31
|
+
edges: VizEdge[]
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Build the relationship graph: depends edges + body-link edges, no self-loops. */
|
|
35
|
+
export function buildGraph(roster: readonly RetrievableTopic[]): VizGraph {
|
|
36
|
+
const bySlug = new Set(roster.map((r) => r.slug))
|
|
37
|
+
const degree = new Map<string, number>()
|
|
38
|
+
const bump = (slug: string): void => {
|
|
39
|
+
degree.set(slug, (degree.get(slug) ?? 0) + 1)
|
|
40
|
+
}
|
|
41
|
+
const edges: VizEdge[] = []
|
|
42
|
+
const seen = new Set<string>()
|
|
43
|
+
const addEdge = (from: string, to: string, via: 'depends' | 'link'): void => {
|
|
44
|
+
if (from === to || !bySlug.has(to)) return
|
|
45
|
+
const key = `${from}->${to}:${via}`
|
|
46
|
+
if (seen.has(key)) return
|
|
47
|
+
seen.add(key)
|
|
48
|
+
edges.push({ from, to, via })
|
|
49
|
+
bump(from)
|
|
50
|
+
bump(to)
|
|
51
|
+
}
|
|
52
|
+
for (const topic of roster) {
|
|
53
|
+
for (const dep of topic.depends) {
|
|
54
|
+
const target = dep.replace(/^topics\//, '').replace(/\.md$/, '')
|
|
55
|
+
addEdge(topic.slug, target, 'depends')
|
|
56
|
+
}
|
|
57
|
+
for (const link of topic.links ?? []) addEdge(topic.slug, link, 'link')
|
|
58
|
+
}
|
|
59
|
+
const nodes: VizNode[] = roster.map((r) => ({
|
|
60
|
+
id: r.slug,
|
|
61
|
+
title: r.title,
|
|
62
|
+
status: r.status,
|
|
63
|
+
tags: r.tags,
|
|
64
|
+
degree: degree.get(r.slug) ?? 0,
|
|
65
|
+
}))
|
|
66
|
+
return { nodes, edges }
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function escapeHtml(s: string): string {
|
|
70
|
+
return s.replaceAll('&', '&').replaceAll('<', '<').replaceAll('>', '>').replaceAll('"', '"')
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Embed JSON safely inside a <script> tag. */
|
|
74
|
+
function embedJson(value: unknown): string {
|
|
75
|
+
return JSON.stringify(value).replaceAll('<', '\\u003c').replaceAll('>', '\\u003e').replaceAll('&', '\\u0026')
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const PAGE = `<!DOCTYPE html>
|
|
79
|
+
<html lang="zh-CN">
|
|
80
|
+
<head>
|
|
81
|
+
<meta charset="utf-8">
|
|
82
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
83
|
+
<title>dsh-topics-memory 关系图</title>
|
|
84
|
+
<style>
|
|
85
|
+
:root { color-scheme: dark; }
|
|
86
|
+
* { box-sizing: border-box; margin: 0; }
|
|
87
|
+
body { background: #101418; color: #d7dee6; font: 14px/1.5 -apple-system, "PingFang SC", "Microsoft YaHei", sans-serif; overflow: hidden; }
|
|
88
|
+
#bar { position: fixed; top: 0; left: 0; right: 0; z-index: 5; display: flex; gap: 14px; align-items: center; padding: 10px 16px; background: rgba(16,20,24,.92); border-bottom: 1px solid #232b33; flex-wrap: wrap; }
|
|
89
|
+
#bar h1 { font-size: 14px; font-weight: 600; color: #8ec7ff; margin-right: 6px; }
|
|
90
|
+
#bar input, #bar select { background: #1a2129; color: #d7dee6; border: 1px solid #2c3742; border-radius: 6px; padding: 4px 10px; font-size: 13px; }
|
|
91
|
+
#bar input { width: 200px; }
|
|
92
|
+
.legend { display: flex; gap: 10px; font-size: 12px; color: #8a97a3; }
|
|
93
|
+
.legend i { display: inline-block; width: 10px; height: 10px; border-radius: 50%; margin-right: 4px; }
|
|
94
|
+
#meta { color: #66737f; font-size: 12px; }
|
|
95
|
+
svg { display: block; width: 100vw; height: 100vh; cursor: grab; }
|
|
96
|
+
svg.dragging { cursor: grabbing; }
|
|
97
|
+
line.edge { stroke: #3a4652; stroke-opacity: .55; }
|
|
98
|
+
line.edge.via-link { stroke-dasharray: 5 4; }
|
|
99
|
+
line.edge.hot { stroke: #f5a623; stroke-opacity: .95; }
|
|
100
|
+
line.edge.dim { stroke-opacity: .08; }
|
|
101
|
+
g.node circle { stroke: #101418; stroke-width: 1.5; cursor: pointer; }
|
|
102
|
+
g.node text { fill: #aeb9c4; font-size: 11px; text-anchor: middle; pointer-events: none; paint-order: stroke; stroke: #101418; stroke-width: 3px; }
|
|
103
|
+
g.node.hot circle { stroke: #f5a623; stroke-width: 2.5; }
|
|
104
|
+
g.node.hot text { fill: #ffd27a; }
|
|
105
|
+
g.node.dim { opacity: .12; }
|
|
106
|
+
#tip { position: fixed; z-index: 9; display: none; max-width: 340px; background: #1c242d; border: 1px solid #33404c; border-radius: 8px; padding: 10px 12px; pointer-events: none; box-shadow: 0 6px 24px rgba(0,0,0,.5); }
|
|
107
|
+
#tip b { color: #8ec7ff; }
|
|
108
|
+
#tip .s { color: #9aa7b3; font-size: 12px; }
|
|
109
|
+
#tip .c { color: #c4cdd6; font-size: 12px; margin-top: 4px; white-space: pre-wrap; }
|
|
110
|
+
#tip .tags { color: #6f8fae; font-size: 11px; margin-top: 4px; }
|
|
111
|
+
</style>
|
|
112
|
+
</head>
|
|
113
|
+
<body>
|
|
114
|
+
<div id="bar">
|
|
115
|
+
<h1>dsh-topics-memory 关系图</h1>
|
|
116
|
+
<input id="q" type="search" placeholder="搜索标题 / slug…">
|
|
117
|
+
<select id="tag"><option value="">全部标签</option></select>
|
|
118
|
+
<div class="legend">
|
|
119
|
+
<span><i style="background:#4caf7d"></i>stable</span>
|
|
120
|
+
<span><i style="background:#e0a63f"></i>draft</span>
|
|
121
|
+
<span><i style="background:#6b7682"></i>deprecated</span>
|
|
122
|
+
<span style="color:#66737f">实线=depends · 虚线=正文链接 · 拖拽节点 · 悬停看详情</span>
|
|
123
|
+
</div>
|
|
124
|
+
<span id="meta"></span>
|
|
125
|
+
</div>
|
|
126
|
+
<svg id="view"></svg>
|
|
127
|
+
<div id="tip"></div>
|
|
128
|
+
<script type="application/json" id="graph-data">__GRAPH_JSON__</script>
|
|
129
|
+
<script>
|
|
130
|
+
(function () {
|
|
131
|
+
var data = JSON.parse(document.getElementById('graph-data').textContent);
|
|
132
|
+
var svg = document.getElementById('view');
|
|
133
|
+
var NS = 'http://www.w3.org/2000/svg';
|
|
134
|
+
var W = window.innerWidth, H = window.innerHeight;
|
|
135
|
+
var nodes = data.nodes.map(function (n, i) {
|
|
136
|
+
var a = (i / Math.max(1, data.nodes.length)) * Math.PI * 2;
|
|
137
|
+
return { id: n.id, title: n.title, status: n.status, tags: n.tags, degree: n.degree,
|
|
138
|
+
x: W / 2 + Math.cos(a) * Math.min(W, H) * 0.32, y: H / 2 + Math.sin(a) * Math.min(W, H) * 0.32,
|
|
139
|
+
vx: 0, vy: 0, el: null };
|
|
140
|
+
});
|
|
141
|
+
var byId = {};
|
|
142
|
+
nodes.forEach(function (n) { byId[n.id] = n; });
|
|
143
|
+
var edges = data.edges.filter(function (e) { return byId[e.from] && byId[e.to]; });
|
|
144
|
+
|
|
145
|
+
var STATUS_FILL = { stable: '#4caf7d', draft: '#e0a63f', deprecated: '#6b7682' };
|
|
146
|
+
|
|
147
|
+
var defs = document.createElementNS(NS, 'defs');
|
|
148
|
+
defs.innerHTML = '<marker id="arrow" viewBox="0 0 8 8" refX="14" refY="4" markerWidth="7" markerHeight="7" orient="auto-start-reverse"><path d="M0,0 L8,4 L0,8 z" fill="#3a4652"/></marker>';
|
|
149
|
+
svg.appendChild(defs);
|
|
150
|
+
|
|
151
|
+
var edgeEls = edges.map(function (e) {
|
|
152
|
+
var l = document.createElementNS(NS, 'line');
|
|
153
|
+
l.setAttribute('class', 'edge' + (e.via === 'link' ? ' via-link' : ''));
|
|
154
|
+
if (e.via === 'depends') l.setAttribute('marker-end', 'url(#arrow)');
|
|
155
|
+
l.dataset.from = e.from; l.dataset.to = e.to;
|
|
156
|
+
svg.appendChild(l);
|
|
157
|
+
return l;
|
|
158
|
+
});
|
|
159
|
+
var nodeEls = nodes.map(function (n) {
|
|
160
|
+
var g = document.createElementNS(NS, 'g');
|
|
161
|
+
g.setAttribute('class', 'node');
|
|
162
|
+
var r = 6 + Math.min(10, n.degree * 2);
|
|
163
|
+
var c = document.createElementNS(NS, 'circle');
|
|
164
|
+
c.setAttribute('r', r);
|
|
165
|
+
c.setAttribute('fill', STATUS_FILL[n.status] || '#4a90d9');
|
|
166
|
+
var t = document.createElementNS(NS, 'text');
|
|
167
|
+
t.setAttribute('y', r + 13);
|
|
168
|
+
t.textContent = n.title.length > 18 ? n.title.slice(0, 17) + '…' : n.title;
|
|
169
|
+
g.appendChild(c); g.appendChild(t);
|
|
170
|
+
n.el = g; n.circle = c;
|
|
171
|
+
g.dataset.id = n.id;
|
|
172
|
+
svg.appendChild(g);
|
|
173
|
+
return g;
|
|
174
|
+
});
|
|
175
|
+
document.getElementById('meta').textContent = nodes.length + ' 节点 · ' + edges.length + ' 边';
|
|
176
|
+
|
|
177
|
+
var tagSel = document.getElementById('tag');
|
|
178
|
+
var tagSet = {};
|
|
179
|
+
nodes.forEach(function (n) { n.tags.forEach(function (t) { tagSet[t] = 1; }); });
|
|
180
|
+
Object.keys(tagSet).sort().forEach(function (t) {
|
|
181
|
+
var o = document.createElement('option'); o.value = t; o.textContent = '#' + t; tagSel.appendChild(o);
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
// force simulation: charge repulsion + edge springs + gentle centering
|
|
185
|
+
var alpha = 1;
|
|
186
|
+
function tick() {
|
|
187
|
+
var i, j, a, b, dx, dy, d2;
|
|
188
|
+
for (i = 0; i < nodes.length; i++) {
|
|
189
|
+
for (j = i + 1; j < nodes.length; j++) {
|
|
190
|
+
a = nodes[i]; b = nodes[j];
|
|
191
|
+
dx = a.x - b.x; dy = a.y - b.y;
|
|
192
|
+
d2 = dx * dx + dy * dy + 0.01;
|
|
193
|
+
var f = 2600 / d2 * alpha;
|
|
194
|
+
var d = Math.sqrt(d2);
|
|
195
|
+
a.vx += dx / d * f; a.vy += dy / d * f;
|
|
196
|
+
b.vx -= dx / d * f; b.vy -= dy / d * f;
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
edges.forEach(function (e) {
|
|
200
|
+
a = byId[e.from]; b = byId[e.to];
|
|
201
|
+
dx = b.x - a.x; dy = b.y - a.y;
|
|
202
|
+
d = Math.sqrt(dx * dx + dy * dy) || 1;
|
|
203
|
+
var want = 130;
|
|
204
|
+
var k = (d - want) * 0.012 * alpha;
|
|
205
|
+
a.vx += dx / d * k * d * 0.01; a.vy += dy / d * k * d * 0.01;
|
|
206
|
+
b.vx -= dx / d * k * d * 0.01; b.vy -= dy / d * k * d * 0.01;
|
|
207
|
+
});
|
|
208
|
+
nodes.forEach(function (n) {
|
|
209
|
+
n.vx += (W / 2 - n.x) * 0.0015 * alpha;
|
|
210
|
+
n.vy += (H / 2 - n.y) * 0.0015 * alpha;
|
|
211
|
+
n.vx *= 0.82; n.vy *= 0.82;
|
|
212
|
+
n.x += Math.max(-14, Math.min(14, n.vx));
|
|
213
|
+
n.y += Math.max(-14, Math.min(14, n.vy));
|
|
214
|
+
n.x = Math.max(24, Math.min(W - 24, n.x));
|
|
215
|
+
n.y = Math.max(52, Math.min(H - 30, n.y));
|
|
216
|
+
});
|
|
217
|
+
alpha *= 0.995;
|
|
218
|
+
}
|
|
219
|
+
function draw() {
|
|
220
|
+
edges.forEach(function (e, i) {
|
|
221
|
+
var a = byId[e.from], b = byId[e.to], l = edgeEls[i];
|
|
222
|
+
l.setAttribute('x1', a.x); l.setAttribute('y1', a.y);
|
|
223
|
+
l.setAttribute('x2', b.x); l.setAttribute('y2', b.y);
|
|
224
|
+
});
|
|
225
|
+
nodes.forEach(function (n) { n.el.setAttribute('transform', 'translate(' + n.x + ',' + n.y + ')'); });
|
|
226
|
+
}
|
|
227
|
+
(function loop() { if (alpha > 0.02) { tick(); } draw(); requestAnimationFrame(loop); })();
|
|
228
|
+
|
|
229
|
+
// pan + drag
|
|
230
|
+
var viewBox = { x: 0, y: 0, w: W, h: H };
|
|
231
|
+
svg.setAttribute('viewBox', '0 0 ' + W + ' ' + H);
|
|
232
|
+
var dragNode = null, panning = false, px = 0, py = 0;
|
|
233
|
+
svg.addEventListener('pointerdown', function (ev) {
|
|
234
|
+
var g = ev.target.closest ? ev.target.closest('g.node') : null;
|
|
235
|
+
if (g) {
|
|
236
|
+
dragNode = byId[g.dataset.id];
|
|
237
|
+
svg.classList.add('dragging');
|
|
238
|
+
} else { panning = true; px = ev.clientX; py = ev.clientY; svg.classList.add('dragging'); }
|
|
239
|
+
});
|
|
240
|
+
window.addEventListener('pointermove', function (ev) {
|
|
241
|
+
if (dragNode) {
|
|
242
|
+
dragNode.x = viewBox.x + ev.clientX / viewBox.w * W;
|
|
243
|
+
dragNode.y = viewBox.y + ev.clientY / viewBox.h * H;
|
|
244
|
+
dragNode.vx = dragNode.vy = 0; draw(); return;
|
|
245
|
+
}
|
|
246
|
+
if (panning) {
|
|
247
|
+
viewBox.x -= (ev.clientX - px) * viewBox.w / W;
|
|
248
|
+
viewBox.y -= (ev.clientY - py) * viewBox.h / H;
|
|
249
|
+
px = ev.clientX; py = ev.clientY;
|
|
250
|
+
svg.setAttribute('viewBox', viewBox.x + ' ' + viewBox.y + ' ' + viewBox.w + ' ' + viewBox.h);
|
|
251
|
+
}
|
|
252
|
+
});
|
|
253
|
+
window.addEventListener('pointerup', function () { dragNode = null; panning = false; svg.classList.remove('dragging'); });
|
|
254
|
+
svg.addEventListener('wheel', function (ev) {
|
|
255
|
+
ev.preventDefault();
|
|
256
|
+
var k = ev.deltaY > 0 ? 1.1 : 0.9;
|
|
257
|
+
viewBox.w *= k; viewBox.h *= k;
|
|
258
|
+
svg.setAttribute('viewBox', viewBox.x + ' ' + viewBox.y + ' ' + viewBox.w + ' ' + viewBox.h);
|
|
259
|
+
}, { passive: false });
|
|
260
|
+
|
|
261
|
+
// hover: highlight neighbors + tooltip (pointermove — synthetic and real
|
|
262
|
+
// pointers both dispatch it; pointerover alone misses programmatic moves)
|
|
263
|
+
var tip = document.getElementById('tip');
|
|
264
|
+
var hotId = null;
|
|
265
|
+
function setHot(id) {
|
|
266
|
+
var hot = {};
|
|
267
|
+
if (id) {
|
|
268
|
+
hot[id] = 1;
|
|
269
|
+
edges.forEach(function (e) {
|
|
270
|
+
if (e.from === id) hot[e.to] = 1;
|
|
271
|
+
if (e.to === id) hot[e.from] = 1;
|
|
272
|
+
});
|
|
273
|
+
}
|
|
274
|
+
edges.forEach(function (e, i) {
|
|
275
|
+
var on = id && (e.from === id || e.to === id);
|
|
276
|
+
edgeEls[i].classList.toggle('hot', !!on);
|
|
277
|
+
});
|
|
278
|
+
nodeEls.forEach(function (g) { g.classList.toggle('hot', id ? !!hot[g.dataset.id] : false); });
|
|
279
|
+
}
|
|
280
|
+
svg.addEventListener('pointermove', function (ev) {
|
|
281
|
+
var g = ev.target && ev.target.closest ? ev.target.closest('g.node') : null;
|
|
282
|
+
if (!g) {
|
|
283
|
+
if (hotId !== null) { setHot(null); tip.style.display = 'none'; hotId = null; }
|
|
284
|
+
return;
|
|
285
|
+
}
|
|
286
|
+
if (g.dataset.id === hotId) return; // already showing this node
|
|
287
|
+
hotId = g.dataset.id;
|
|
288
|
+
var n = byId[hotId];
|
|
289
|
+
setHot(n.id);
|
|
290
|
+
var conclusion = n.conclusion || '';
|
|
291
|
+
tip.innerHTML = '<b>' + n.title.replace(/&/g, '&').replace(/</g, '<') + '</b>' +
|
|
292
|
+
'<div class="s">' + n.id + ' · ' + n.status + '</div>' +
|
|
293
|
+
(conclusion ? '<div class="c">' + conclusion.replace(/&/g, '&').replace(/</g, '<').slice(0, 260) + '</div>' : '') +
|
|
294
|
+
(n.tags.length ? '<div class="tags">#' + n.tags.join(' #') + '</div>' : '');
|
|
295
|
+
tip.style.display = 'block';
|
|
296
|
+
tip.style.left = Math.min(window.innerWidth - 360, ev.clientX + 16) + 'px';
|
|
297
|
+
tip.style.top = (ev.clientY + 12) + 'px';
|
|
298
|
+
});
|
|
299
|
+
svg.addEventListener('pointerleave', function () { setHot(null); tip.style.display = 'none'; hotId = null; });
|
|
300
|
+
|
|
301
|
+
// search + tag filter → dim non-matches
|
|
302
|
+
function applyFilter() {
|
|
303
|
+
var q = document.getElementById('q').value.trim().toLowerCase();
|
|
304
|
+
var tag = tagSel.value;
|
|
305
|
+
nodeEls.forEach(function (g) {
|
|
306
|
+
var n = byId[g.dataset.id];
|
|
307
|
+
var okQ = !q || n.title.toLowerCase().indexOf(q) >= 0 || n.id.indexOf(q) >= 0;
|
|
308
|
+
var okT = !tag || n.tags.indexOf(tag) >= 0;
|
|
309
|
+
g.classList.toggle('dim', !okQ || !okT);
|
|
310
|
+
});
|
|
311
|
+
var any = !q && !tag;
|
|
312
|
+
edges.forEach(function (e, i) {
|
|
313
|
+
if (any) { edgeEls[i].classList.remove('dim'); return; }
|
|
314
|
+
var a = byId[e.from], b = byId[e.to];
|
|
315
|
+
var okQ = !q || [a, b].some(function (n) { return n.title.toLowerCase().indexOf(q) >= 0 || n.id.indexOf(q) >= 0; });
|
|
316
|
+
var okT = !tag || [a, b].some(function (n) { return n.tags.indexOf(tag) >= 0; });
|
|
317
|
+
edgeEls[i].classList.toggle('dim', !(okQ && okT));
|
|
318
|
+
});
|
|
319
|
+
}
|
|
320
|
+
document.getElementById('q').addEventListener('input', applyFilter);
|
|
321
|
+
tagSel.addEventListener('change', applyFilter);
|
|
322
|
+
})();
|
|
323
|
+
</script>
|
|
324
|
+
</body>
|
|
325
|
+
</html>
|
|
326
|
+
`
|
|
327
|
+
|
|
328
|
+
export function renderGraphHtml(graph: VizGraph, opts: { conclusions?: Record<string, string> } = {}): string {
|
|
329
|
+
// Tooltip conclusions ride on the nodes (kept out of VizGraph for typing
|
|
330
|
+
// clarity at the command layer).
|
|
331
|
+
const nodes = graph.nodes.map((n) => ({ ...n, conclusion: opts.conclusions?.[n.id] ?? '' }))
|
|
332
|
+
return PAGE.replace('__GRAPH_JSON__', embedJson({ nodes, edges: graph.edges }))
|
|
333
|
+
}
|
package/src/yaml.ts
ADDED
|
@@ -0,0 +1,322 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Minimal YAML subset for OKF frontmatter — parse and serialize, zero deps.
|
|
3
|
+
*
|
|
4
|
+
* Scope is deliberately narrow: exactly what OKF v0.2 frontmatter needs and
|
|
5
|
+
* nothing more. Maps (nested), block sequences, inline sequences `[a, b]`,
|
|
6
|
+
* inline maps `{by: x, at: y}`, plain/quoted scalars, numbers, booleans, null.
|
|
7
|
+
* Comments: full-line only (trailing comments are unsafe — URLs may contain `#`).
|
|
8
|
+
* Block scalars (`|`, `>`) are NOT supported; never emitted by this plugin.
|
|
9
|
+
*
|
|
10
|
+
* @module yaml
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
export class YamlError extends Error {
|
|
14
|
+
override name = 'YamlError'
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
interface Line {
|
|
18
|
+
indent: number
|
|
19
|
+
text: string
|
|
20
|
+
/** Original 1-based line number, for error messages. */
|
|
21
|
+
no: number
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function stripComments(raw: string): Line[] {
|
|
25
|
+
const lines: Line[] = []
|
|
26
|
+
raw.split('\n').forEach((l, i) => {
|
|
27
|
+
if (l.trim() === '' || l.trimStart().startsWith('#')) return
|
|
28
|
+
const indent = l.length - l.trimStart().length
|
|
29
|
+
lines.push({ indent, text: l.trim(), no: i + 1 })
|
|
30
|
+
})
|
|
31
|
+
return lines
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function unquote(s: string): string {
|
|
35
|
+
if (s.length >= 2 && ((s.startsWith('"') && s.endsWith('"')) || (s.startsWith("'") && s.endsWith("'")))) {
|
|
36
|
+
const inner = s.slice(1, -1)
|
|
37
|
+
// Double quotes: unescape \" and \\; single quotes: '' is a literal '.
|
|
38
|
+
if (s.startsWith('"')) return inner.replaceAll('\\"', '"').replaceAll('\\\\', '\\')
|
|
39
|
+
return inner.replaceAll("''", "'")
|
|
40
|
+
}
|
|
41
|
+
return s
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** A fully quoted scalar: balanced matching quote pair, allowing surrounding whitespace. */
|
|
45
|
+
function isQuotedScalar(s: string): boolean {
|
|
46
|
+
return s.length >= 2 && ((s.startsWith('"') && s.endsWith('"')) || (s.startsWith("'") && s.endsWith("'")))
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function scalar(raw: string): unknown {
|
|
50
|
+
const s = raw.trim()
|
|
51
|
+
if (s === '' || s === '~' || s === 'null' || s === 'Null' || s === 'NULL') return null
|
|
52
|
+
if ((s.startsWith('"') && s.endsWith('"')) || (s.startsWith("'") && s.endsWith("'"))) return unquote(s)
|
|
53
|
+
if (s === 'true' || s === 'True' || s === 'TRUE') return true
|
|
54
|
+
if (s === 'false' || s === 'False' || s === 'FALSE') return false
|
|
55
|
+
if (/^-?\d+$/.test(s)) return Number(s)
|
|
56
|
+
if (/^-?\d+\.\d+$/.test(s)) return Number(s)
|
|
57
|
+
// Dates, URLs, versions: stay strings (no implicit date coercion).
|
|
58
|
+
return s
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function splitInlineItems(s: string): string[] {
|
|
62
|
+
// Split on commas not inside quotes or nested brackets.
|
|
63
|
+
const items: string[] = []
|
|
64
|
+
let depth = 0
|
|
65
|
+
let quote: string | undefined
|
|
66
|
+
let cur = ''
|
|
67
|
+
for (const ch of s) {
|
|
68
|
+
if (quote !== undefined) {
|
|
69
|
+
cur += ch
|
|
70
|
+
if (ch === quote) quote = undefined
|
|
71
|
+
continue
|
|
72
|
+
}
|
|
73
|
+
if (ch === '"' || ch === "'") {
|
|
74
|
+
quote = ch
|
|
75
|
+
cur += ch
|
|
76
|
+
continue
|
|
77
|
+
}
|
|
78
|
+
if (ch === '[' || ch === '{') depth += 1
|
|
79
|
+
if (ch === ']' || ch === '}') depth -= 1
|
|
80
|
+
if (ch === ',' && depth === 0) {
|
|
81
|
+
items.push(cur)
|
|
82
|
+
cur = ''
|
|
83
|
+
continue
|
|
84
|
+
}
|
|
85
|
+
cur += ch
|
|
86
|
+
}
|
|
87
|
+
if (cur.trim() !== '') items.push(cur)
|
|
88
|
+
return items.map((x) => x.trim()).filter((x) => x !== '')
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Split `key: value` at the first `: ` (or trailing `:`); key may be quoted. */
|
|
92
|
+
function splitKeyValue(text: string, no: number): { key: string; value: string } {
|
|
93
|
+
const m = /^("([^"]*)"|'([^']*)'|[^:]+):(?::?\s(.*))?$/.exec(text)
|
|
94
|
+
if (m === null || (m[4] === undefined && !text.endsWith(':'))) {
|
|
95
|
+
throw new YamlError(`yaml: line ${no}: expected "key: value", got "${text}"`)
|
|
96
|
+
}
|
|
97
|
+
const key = m[2] ?? m[3] ?? m[1]
|
|
98
|
+
const value = (m[4] ?? '').trim()
|
|
99
|
+
return { key: unquote(key), value }
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function parseBlock(lines: Line[], start: number, indent: number): { value: unknown; next: number } {
|
|
103
|
+
if (start >= lines.length) return { value: null, next: start }
|
|
104
|
+
const first = lines[start]
|
|
105
|
+
if (first.text.startsWith('- ') || first.text === '-') {
|
|
106
|
+
return parseBlockArray(lines, start, indent)
|
|
107
|
+
}
|
|
108
|
+
return parseBlockMap(lines, start, indent)
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** Parse a sequence starting at `start`; items are scalars, nested blocks, or inline-key maps. */
|
|
112
|
+
function parseBlockArray(lines: Line[], start: number, indent: number): { value: unknown[]; next: number } {
|
|
113
|
+
const arr: unknown[] = []
|
|
114
|
+
let i = start
|
|
115
|
+
while (i < lines.length && lines[i].indent === indent && (lines[i].text.startsWith('- ') || lines[i].text === '-')) {
|
|
116
|
+
const raw = lines[i].text === '-' ? '' : lines[i].text.slice(2).trim()
|
|
117
|
+
if (raw === '') {
|
|
118
|
+
// Bare "-": nested block belongs to this item.
|
|
119
|
+
if (i + 1 < lines.length && lines[i + 1].indent > indent) {
|
|
120
|
+
const nested = parseBlock(lines, i + 1, lines[i + 1].indent)
|
|
121
|
+
arr.push(nested.value)
|
|
122
|
+
i = nested.next
|
|
123
|
+
} else {
|
|
124
|
+
arr.push(null)
|
|
125
|
+
i += 1
|
|
126
|
+
}
|
|
127
|
+
continue
|
|
128
|
+
}
|
|
129
|
+
if (isQuotedScalar(raw)) {
|
|
130
|
+
// Fully quoted scalar first: a quoted item containing `: ` is a string
|
|
131
|
+
// (e.g. `- "id-token: write"`), never an inline map. The writer quotes
|
|
132
|
+
// such scalars (needsQuote), so parse must read back exactly what it wrote.
|
|
133
|
+
arr.push(scalar(raw))
|
|
134
|
+
i += 1
|
|
135
|
+
continue
|
|
136
|
+
}
|
|
137
|
+
if (raw.includes(': ') || raw.endsWith(':')) {
|
|
138
|
+
// Map item: first key inline after "- ", continuation keys at indent+2.
|
|
139
|
+
const item: Record<string, unknown> = {}
|
|
140
|
+
const { key, value } = splitKeyValue(raw, lines[i].no)
|
|
141
|
+
i += 1
|
|
142
|
+
if (value === '') {
|
|
143
|
+
if (i < lines.length && lines[i].indent > indent) {
|
|
144
|
+
const nested = parseBlock(lines, i, lines[i].indent)
|
|
145
|
+
item[key] = nested.value
|
|
146
|
+
i = nested.next
|
|
147
|
+
} else {
|
|
148
|
+
item[key] = null
|
|
149
|
+
}
|
|
150
|
+
} else if (value.startsWith('[') && value.endsWith(']')) {
|
|
151
|
+
const inner = value.slice(1, -1).trim()
|
|
152
|
+
item[key] = inner === '' ? [] : splitInlineItems(inner).map((x) => scalar(x))
|
|
153
|
+
} else if (value.startsWith('{') && value.endsWith('}')) {
|
|
154
|
+
item[key] = parseInlineMap(value)
|
|
155
|
+
} else {
|
|
156
|
+
item[key] = scalar(value)
|
|
157
|
+
}
|
|
158
|
+
while (i < lines.length && lines[i].indent === indent + 2 && !lines[i].text.startsWith('- ')) {
|
|
159
|
+
const consumed = parseMapEntryInto(item, lines, i)
|
|
160
|
+
i = consumed
|
|
161
|
+
}
|
|
162
|
+
arr.push(item)
|
|
163
|
+
continue
|
|
164
|
+
}
|
|
165
|
+
arr.push(scalar(raw))
|
|
166
|
+
i += 1
|
|
167
|
+
}
|
|
168
|
+
return { value: arr, next: i }
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function parseInlineMap(value: string): Record<string, unknown> {
|
|
172
|
+
const inner = value.slice(1, -1).trim()
|
|
173
|
+
const item: Record<string, unknown> = {}
|
|
174
|
+
if (inner !== '') {
|
|
175
|
+
for (const pair of splitInlineItems(inner)) {
|
|
176
|
+
const kv = splitKeyValue(pair, 0)
|
|
177
|
+
item[kv.key] = scalar(kv.value)
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
return item
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/** Parse one `key: value` line into `obj`; returns the next line index. */
|
|
184
|
+
function parseMapEntryInto(obj: Record<string, unknown>, lines: Line[], i: number): number {
|
|
185
|
+
const { key, value } = splitKeyValue(lines[i].text, lines[i].no)
|
|
186
|
+
if (value === '') {
|
|
187
|
+
if (i + 1 < lines.length && lines[i + 1].indent > lines[i].indent) {
|
|
188
|
+
const nested = parseBlock(lines, i + 1, lines[i + 1].indent)
|
|
189
|
+
obj[key] = nested.value
|
|
190
|
+
return nested.next
|
|
191
|
+
}
|
|
192
|
+
obj[key] = null
|
|
193
|
+
return i + 1
|
|
194
|
+
}
|
|
195
|
+
if (value.startsWith('[') && value.endsWith(']')) {
|
|
196
|
+
const inner = value.slice(1, -1).trim()
|
|
197
|
+
obj[key] = inner === '' ? [] : splitInlineItems(inner).map((x) => scalar(x))
|
|
198
|
+
return i + 1
|
|
199
|
+
}
|
|
200
|
+
if (value.startsWith('{') && value.endsWith('}')) {
|
|
201
|
+
obj[key] = parseInlineMap(value)
|
|
202
|
+
return i + 1
|
|
203
|
+
}
|
|
204
|
+
obj[key] = scalar(value)
|
|
205
|
+
return i + 1
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function parseBlockMap(lines: Line[], start: number, indent: number): { value: Record<string, unknown>; next: number } {
|
|
209
|
+
const obj: Record<string, unknown> = {}
|
|
210
|
+
let i = start
|
|
211
|
+
while (i < lines.length && lines[i].indent === indent && !lines[i].text.startsWith('- ')) {
|
|
212
|
+
i = parseMapEntryInto(obj, lines, i)
|
|
213
|
+
}
|
|
214
|
+
return { value: obj, next: i }
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/** Parse the YAML subset into a plain JS value (object at top level). */
|
|
218
|
+
export function parseYaml(text: string): Record<string, unknown> {
|
|
219
|
+
const lines = stripComments(text)
|
|
220
|
+
if (lines.length === 0) return {}
|
|
221
|
+
const { value, next } = parseBlock(lines, 0, lines[0].indent)
|
|
222
|
+
if (next < lines.length) {
|
|
223
|
+
throw new YamlError(`yaml: line ${lines[next].no}: inconsistent indentation near "${lines[next].text}"`)
|
|
224
|
+
}
|
|
225
|
+
if (value === null || typeof value !== 'object' || Array.isArray(value)) {
|
|
226
|
+
throw new YamlError('yaml: top-level must be a mapping')
|
|
227
|
+
}
|
|
228
|
+
return value as Record<string, unknown>
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/** True when a scalar needs quoting on output (empty, special, or ambiguous). */
|
|
232
|
+
function needsQuote(s: string): boolean {
|
|
233
|
+
if (s === '') return true
|
|
234
|
+
if (/^[\s]|[\s]$/.test(s)) return true
|
|
235
|
+
if (/^(true|false|null|True|False|Null|TRUE|FALSE|NULL|~|yes|no|on|off)$/i.test(s)) return true
|
|
236
|
+
if (/^-?\d+(\.\d+)?$/.test(s)) return true
|
|
237
|
+
if (/^202\d-/.test(s)) return false // ISO dates are unambiguous and safe unquoted
|
|
238
|
+
if (/[:#{}[\],&*'?"|>%@`!]/.test(s)) return true
|
|
239
|
+
if (/^\s+-/.test(s)) return true
|
|
240
|
+
return false
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
function quote(s: string): string {
|
|
244
|
+
return `"${s.replaceAll('\\', '\\\\').replaceAll('"', '\\"')}"`
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
function scalarOut(v: unknown): string {
|
|
248
|
+
if (v === null || v === undefined) return 'null'
|
|
249
|
+
if (typeof v === 'boolean') return v ? 'true' : 'false'
|
|
250
|
+
if (typeof v === 'number') return String(v)
|
|
251
|
+
const s = String(v)
|
|
252
|
+
return needsQuote(s) ? quote(s) : s
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
function isPlainKey(k: string): boolean {
|
|
256
|
+
return k !== '' && !/[:#\s]/.test(k)
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
function writeMap(obj: Record<string, unknown>, indent: number, out: string[]): void {
|
|
260
|
+
const pad = ' '.repeat(indent)
|
|
261
|
+
for (const [key, value] of Object.entries(obj)) {
|
|
262
|
+
const k = isPlainKey(key) ? key : quote(key)
|
|
263
|
+
if (value === null || value === undefined) {
|
|
264
|
+
out.push(`${pad}${k}: null`)
|
|
265
|
+
} else if (Array.isArray(value)) {
|
|
266
|
+
if (value.length === 0) {
|
|
267
|
+
out.push(`${pad}${k}: []`)
|
|
268
|
+
} else {
|
|
269
|
+
out.push(`${pad}${k}:`)
|
|
270
|
+
writeArray(value, indent + 1, out)
|
|
271
|
+
}
|
|
272
|
+
} else if (typeof value === 'object') {
|
|
273
|
+
const entries = Object.entries(value as Record<string, unknown>)
|
|
274
|
+
if (entries.length === 0) {
|
|
275
|
+
out.push(`${pad}${k}: {}`)
|
|
276
|
+
} else {
|
|
277
|
+
out.push(`${pad}${k}:`)
|
|
278
|
+
writeMap(value as Record<string, unknown>, indent + 1, out)
|
|
279
|
+
}
|
|
280
|
+
} else {
|
|
281
|
+
out.push(`${pad}${k}: ${scalarOut(value)}`)
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
function writeArray(arr: unknown[], indent: number, out: string[]): void {
|
|
287
|
+
const pad = ' '.repeat(indent)
|
|
288
|
+
for (const item of arr) {
|
|
289
|
+
if (item === null || item === undefined) {
|
|
290
|
+
out.push(`${pad}- null`)
|
|
291
|
+
} else if (typeof item === 'object' && !Array.isArray(item)) {
|
|
292
|
+
const entries = Object.entries(item as Record<string, unknown>)
|
|
293
|
+
if (entries.length === 0) {
|
|
294
|
+
out.push(`${pad}- {}`)
|
|
295
|
+
continue
|
|
296
|
+
}
|
|
297
|
+
entries.forEach(([k, v], idx) => {
|
|
298
|
+
const kk = isPlainKey(k) ? k : quote(k)
|
|
299
|
+
if (v === null || v === undefined) {
|
|
300
|
+
out.push(`${pad}${idx === 0 ? '- ' : ' '}${kk}: null`)
|
|
301
|
+
} else if (typeof v === 'object') {
|
|
302
|
+
out.push(`${pad}${idx === 0 ? '- ' : ' '}${kk}:`)
|
|
303
|
+
writeMap(v as Record<string, unknown>, indent + 2, out)
|
|
304
|
+
} else {
|
|
305
|
+
out.push(`${pad}${idx === 0 ? '- ' : ' '}${kk}: ${scalarOut(v)}`)
|
|
306
|
+
}
|
|
307
|
+
})
|
|
308
|
+
} else if (Array.isArray(item)) {
|
|
309
|
+
out.push(`${pad}-`)
|
|
310
|
+
writeArray(item, indent + 1, out)
|
|
311
|
+
} else {
|
|
312
|
+
out.push(`${pad}- ${scalarOut(item)}`)
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/** Serialize a plain JS value into the YAML subset (deterministic, stable). */
|
|
318
|
+
export function stringifyYaml(value: Record<string, unknown>): string {
|
|
319
|
+
const out: string[] = []
|
|
320
|
+
writeMap(value, 0, out)
|
|
321
|
+
return out.join('\n') + '\n'
|
|
322
|
+
}
|