@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.
Files changed (61) hide show
  1. package/README.md +112 -0
  2. package/README.zh.md +112 -0
  3. package/cordis.patch.yml +8 -0
  4. package/lib/commands.d.ts +21 -0
  5. package/lib/commands.js +368 -0
  6. package/lib/config.d.ts +122 -0
  7. package/lib/config.js +123 -0
  8. package/lib/delegation.d.ts +23 -0
  9. package/lib/delegation.js +21 -0
  10. package/lib/digest.d.ts +42 -0
  11. package/lib/digest.js +101 -0
  12. package/lib/distill.d.ts +186 -0
  13. package/lib/distill.js +759 -0
  14. package/lib/git.d.ts +66 -0
  15. package/lib/git.js +169 -0
  16. package/lib/ilog.d.ts +53 -0
  17. package/lib/ilog.js +70 -0
  18. package/lib/index.d.ts +73 -0
  19. package/lib/index.js +504 -0
  20. package/lib/observer.d.ts +41 -0
  21. package/lib/observer.js +128 -0
  22. package/lib/okf.d.ts +112 -0
  23. package/lib/okf.js +279 -0
  24. package/lib/onboard.d.ts +205 -0
  25. package/lib/onboard.js +664 -0
  26. package/lib/paths.d.ts +48 -0
  27. package/lib/paths.js +84 -0
  28. package/lib/retrieval.d.ts +61 -0
  29. package/lib/retrieval.js +178 -0
  30. package/lib/service.d.ts +96 -0
  31. package/lib/service.js +375 -0
  32. package/lib/store.d.ts +153 -0
  33. package/lib/store.js +489 -0
  34. package/lib/sync.d.ts +52 -0
  35. package/lib/sync.js +154 -0
  36. package/lib/tools.d.ts +8 -0
  37. package/lib/tools.js +208 -0
  38. package/lib/viz.d.ts +33 -0
  39. package/lib/viz.js +310 -0
  40. package/lib/yaml.d.ts +18 -0
  41. package/lib/yaml.js +334 -0
  42. package/package.json +97 -0
  43. package/src/commands.ts +397 -0
  44. package/src/config.ts +149 -0
  45. package/src/delegation.ts +27 -0
  46. package/src/digest.ts +125 -0
  47. package/src/distill.ts +885 -0
  48. package/src/git.ts +209 -0
  49. package/src/ilog.ts +99 -0
  50. package/src/index.ts +574 -0
  51. package/src/observer.ts +150 -0
  52. package/src/okf.ts +317 -0
  53. package/src/onboard.ts +762 -0
  54. package/src/paths.ts +104 -0
  55. package/src/retrieval.ts +219 -0
  56. package/src/service.ts +400 -0
  57. package/src/store.ts +567 -0
  58. package/src/sync.ts +170 -0
  59. package/src/tools.ts +225 -0
  60. package/src/viz.ts +333 -0
  61. 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('&', '&amp;').replaceAll('<', '&lt;').replaceAll('>', '&gt;').replaceAll('"', '&quot;')
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, '&amp;').replace(/</g, '&lt;') + '</b>' +
292
+ '<div class="s">' + n.id + ' · ' + n.status + '</div>' +
293
+ (conclusion ? '<div class="c">' + conclusion.replace(/&/g, '&amp;').replace(/</g, '&lt;').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
+ }