@tanishqkancharla/diffmap 0.3.0 → 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 +171 -1
- package/index.html +6 -0
- package/package.json +3 -2
- package/src/annotations.ts +17 -1
- package/src/changes.ts +15 -0
- package/src/codeNotes.ts +123 -0
- package/src/errors.ts +5 -0
- package/src/extractDocument.ts +10 -2
- package/src/git.test.ts +13 -1
- package/src/git.ts +15 -2
- package/src/host/HostHome.tsx +22 -6
- package/src/host/home.md +154 -109
- package/src/host/home.test.ts +6 -8
- package/src/parseFence.ts +132 -23
- package/src/parseViewer.test.ts +359 -2
- package/src/parseViewer.ts +126 -25
- package/src/serve.test.ts +22 -1
- package/src/services.ts +396 -0
- package/src/share.ts +1 -1
- package/src/viewer/CallStackDiff.tsx +119 -55
- package/src/viewer/ChangesCard.tsx +133 -45
- package/src/viewer/CodeBlock.tsx +204 -0
- package/src/viewer/ComarkView.tsx +34 -0
- package/src/viewer/Fence.tsx +26 -2
- package/src/viewer/HtmlBlock.tsx +2 -1
- package/src/viewer/MermaidBlock.tsx +55 -14
- package/src/viewer/ServiceDiagramBlock.tsx +358 -0
- package/src/viewer/ServiceStage.tsx +1129 -0
- package/src/viewer/SourceDiffPanel.tsx +147 -48
- package/src/viewer/TableOfContents.tsx +47 -18
- package/src/viewer/TocButton.tsx +28 -0
- package/src/viewer/ViewerApp.tsx +28 -3
- package/src/viewer/diagramDiff.ts +69 -0
- package/src/viewer/diagramLinks.ts +54 -6
- package/src/viewer/notes.tsx +126 -0
- package/src/viewer/pierre.ts +20 -2
- package/src/viewer/serviceLayout.ts +200 -0
- package/vite.config.ts +5 -0
- package/vite.gist.config.ts +1 -0
package/src/parseViewer.test.ts
CHANGED
|
@@ -6,8 +6,9 @@ import path from "node:path";
|
|
|
6
6
|
import { fileURLToPath } from "node:url";
|
|
7
7
|
import { promisify } from "node:util";
|
|
8
8
|
import test from "node:test";
|
|
9
|
-
import { DiffmapMermaidError } from "./errors.js";
|
|
9
|
+
import { DiffmapMermaidError, DiffmapServicesError } from "./errors.js";
|
|
10
10
|
import { parseViewerDocument } from "./parseViewer.js";
|
|
11
|
+
import { serviceStateAt } from "./services.js";
|
|
11
12
|
import { shareMarkdownFile } from "./share.js";
|
|
12
13
|
import { startServer } from "./serve.js";
|
|
13
14
|
|
|
@@ -21,6 +22,17 @@ function brokenMermaidSpec(body = "flwochart TD\n A --> B") {
|
|
|
21
22
|
return `# Broken mermaid\n\n\`\`\`mermaid\n${body}\n\`\`\`\n`;
|
|
22
23
|
}
|
|
23
24
|
|
|
25
|
+
function parseServicesSpec(body: string[], info = "services") {
|
|
26
|
+
return parseViewerDocument(
|
|
27
|
+
["# Flow", "", `\`\`\`${info}`, ...body, "```", ""].join("\n"),
|
|
28
|
+
"spec.md",
|
|
29
|
+
);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function codeFenceProblem(lines: string[]) {
|
|
33
|
+
return String(parseViewerDocument(["```ts", ...lines, "```", ""].join("\n")));
|
|
34
|
+
}
|
|
35
|
+
|
|
24
36
|
async function withTempSpec(
|
|
25
37
|
source: string,
|
|
26
38
|
run: (filePath: string) => Promise<void>,
|
|
@@ -128,13 +140,30 @@ test("parseViewerDocument reads commit references and changes fences", () => {
|
|
|
128
140
|
"```changes",
|
|
129
141
|
"```",
|
|
130
142
|
"",
|
|
143
|
+
"```changes",
|
|
144
|
+
"a1b2c3d",
|
|
145
|
+
"src/store.ts",
|
|
146
|
+
" src/request.ts ",
|
|
147
|
+
"```",
|
|
148
|
+
"",
|
|
149
|
+
"```changes",
|
|
150
|
+
"",
|
|
151
|
+
"src/store.ts",
|
|
152
|
+
"```",
|
|
153
|
+
"",
|
|
131
154
|
"See [[a1b2c3d:src/store.ts#L12-L30]] and [[src/store.ts#L3]].",
|
|
132
155
|
"",
|
|
133
156
|
].join("\n"),
|
|
134
157
|
);
|
|
135
158
|
assert.equal(document instanceof Error, false);
|
|
136
159
|
if (document instanceof Error) return;
|
|
137
|
-
|
|
160
|
+
// Lines after the rev list files to review in order; a blank rev is uncommitted.
|
|
161
|
+
assert.deepEqual(document.changeCards, [
|
|
162
|
+
{ rev: "a1b2c3d..e4f5a6b", review: [] },
|
|
163
|
+
{ rev: "working", review: [] },
|
|
164
|
+
{ rev: "a1b2c3d", review: ["src/store.ts", "src/request.ts"] },
|
|
165
|
+
{ rev: "working", review: ["src/store.ts"] },
|
|
166
|
+
]);
|
|
138
167
|
assert.deepEqual(document.references, [
|
|
139
168
|
{ kind: "file", rev: "a1b2c3d", path: "src/store.ts", start: 12, end: 30 },
|
|
140
169
|
{ kind: "file", path: "src/store.ts", start: 3, end: 3 },
|
|
@@ -149,6 +178,10 @@ test("parseViewerDocument reads commit references and changes fences", () => {
|
|
|
149
178
|
parseViewerDocument("```changes\nmain\n```\n") instanceof Error,
|
|
150
179
|
true,
|
|
151
180
|
);
|
|
181
|
+
assert.match(
|
|
182
|
+
String(parseViewerDocument("```changes\na1b2c3d\na.ts\na.ts\n```\n")),
|
|
183
|
+
/lists a\.ts twice/,
|
|
184
|
+
);
|
|
152
185
|
// The removed patch fence fails instead of rendering as code.
|
|
153
186
|
assert.match(
|
|
154
187
|
String(
|
|
@@ -160,6 +193,330 @@ test("parseViewerDocument reads commit references and changes fences", () => {
|
|
|
160
193
|
);
|
|
161
194
|
});
|
|
162
195
|
|
|
196
|
+
test("parseViewerDocument reads code highlights and trailing notes", () => {
|
|
197
|
+
const document = parseViewerDocument(
|
|
198
|
+
[
|
|
199
|
+
"```py [src/save.py] {2}",
|
|
200
|
+
"def save(record):",
|
|
201
|
+
" storage.save(record)",
|
|
202
|
+
"",
|
|
203
|
+
"# [!note L1-L2] Saves the record.",
|
|
204
|
+
"# [!note storage.save] Writes `record` to disk.",
|
|
205
|
+
"```",
|
|
206
|
+
"",
|
|
207
|
+
].join("\n"),
|
|
208
|
+
);
|
|
209
|
+
assert.equal(document instanceof Error, false);
|
|
210
|
+
if (document instanceof Error) return;
|
|
211
|
+
assert.deepEqual(document.nodes[0], {
|
|
212
|
+
type: "view",
|
|
213
|
+
fence: {
|
|
214
|
+
kind: "code",
|
|
215
|
+
lang: "py",
|
|
216
|
+
source: "def save(record):\n storage.save(record)",
|
|
217
|
+
highlights: [2],
|
|
218
|
+
notes: [
|
|
219
|
+
{
|
|
220
|
+
target: { kind: "lines", start: 1, end: 2 },
|
|
221
|
+
text: "Saves the record.",
|
|
222
|
+
},
|
|
223
|
+
{
|
|
224
|
+
target: { kind: "symbol", symbol: "storage.save" },
|
|
225
|
+
text: "Writes `record` to disk.",
|
|
226
|
+
},
|
|
227
|
+
],
|
|
228
|
+
title: "src/save.py",
|
|
229
|
+
},
|
|
230
|
+
});
|
|
231
|
+
// Symbols match whole words, so `save` is not found inside `saveAll`.
|
|
232
|
+
assert.match(
|
|
233
|
+
codeFenceProblem(["store.saveAll(x);", "// [!note save] Saves."]),
|
|
234
|
+
/"save" does not appear/,
|
|
235
|
+
);
|
|
236
|
+
assert.match(
|
|
237
|
+
codeFenceProblem(["const x = 1;", "// [!note L1-L2] Too long."]),
|
|
238
|
+
/outside the 1-line code fence/,
|
|
239
|
+
);
|
|
240
|
+
assert.match(
|
|
241
|
+
String(parseViewerDocument("```ts {3}\nconst x = 1;\n```\n")),
|
|
242
|
+
/Highlighted line 3 is outside/,
|
|
243
|
+
);
|
|
244
|
+
// Call stacks number rows, and a symbol must appear in visible row text.
|
|
245
|
+
const stack = parseViewerDocument(
|
|
246
|
+
[
|
|
247
|
+
"```callstack {2} [Save flow]",
|
|
248
|
+
" run() [[src/a.ts#run]]",
|
|
249
|
+
"+└── save(record)",
|
|
250
|
+
"# [!note save] Writes the record.",
|
|
251
|
+
"```",
|
|
252
|
+
"",
|
|
253
|
+
].join("\n"),
|
|
254
|
+
);
|
|
255
|
+
if (stack instanceof Error) throw stack;
|
|
256
|
+
const fence =
|
|
257
|
+
stack.nodes[0]?.type === "view" ? stack.nodes[0].fence : undefined;
|
|
258
|
+
assert.equal(fence?.kind, "callstack");
|
|
259
|
+
if (fence?.kind !== "callstack") return;
|
|
260
|
+
assert.equal(fence.lines.length, 2);
|
|
261
|
+
assert.deepEqual(fence.highlights, [2]);
|
|
262
|
+
assert.equal(fence.title, "Save flow");
|
|
263
|
+
const mdc = parseViewerDocument(
|
|
264
|
+
'::callstack{title="Save flow"}\n run()\n└── save(record)\n::\n',
|
|
265
|
+
);
|
|
266
|
+
if (mdc instanceof Error) throw mdc;
|
|
267
|
+
assert.deepEqual(
|
|
268
|
+
mdc.nodes[0]?.type === "view" && mdc.nodes[0].fence.kind === "callstack"
|
|
269
|
+
? mdc.nodes[0].fence.title
|
|
270
|
+
: undefined,
|
|
271
|
+
"Save flow",
|
|
272
|
+
);
|
|
273
|
+
assert.deepEqual(fence.notes, [
|
|
274
|
+
{ target: { kind: "symbol", symbol: "save" }, text: "Writes the record." },
|
|
275
|
+
]);
|
|
276
|
+
assert.match(
|
|
277
|
+
String(
|
|
278
|
+
parseViewerDocument(
|
|
279
|
+
"```callstack\n run() [[src/a.ts#Store.save]]\n# [!note save] Hidden.\n```\n",
|
|
280
|
+
),
|
|
281
|
+
),
|
|
282
|
+
/"save" does not appear/,
|
|
283
|
+
);
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
test("parseViewerDocument reads services, steps, and their references", () => {
|
|
287
|
+
const document = parseViewerDocument(
|
|
288
|
+
[
|
|
289
|
+
"```services",
|
|
290
|
+
"service API [[src/api.ts]]",
|
|
291
|
+
" > Routes requests.",
|
|
292
|
+
" service Auth",
|
|
293
|
+
" state attempts = 0",
|
|
294
|
+
" > Failures since the last success.",
|
|
295
|
+
" > Five locks the account.",
|
|
296
|
+
"service Store",
|
|
297
|
+
" state rows = none",
|
|
298
|
+
"",
|
|
299
|
+
'start "Alice has not logged in"',
|
|
300
|
+
" > No rows yet.",
|
|
301
|
+
'step "Verify"',
|
|
302
|
+
" API -> Auth: verify(alice) [[src/api.ts#login]]",
|
|
303
|
+
" > Sends the password hash.",
|
|
304
|
+
" Auth.attempts = 1",
|
|
305
|
+
" > One failure so far.",
|
|
306
|
+
'step "Auth stores the row"',
|
|
307
|
+
" Auth -> Store: insert",
|
|
308
|
+
" Store.rows = 1 row",
|
|
309
|
+
" Auth.attempts = 0",
|
|
310
|
+
"```",
|
|
311
|
+
"",
|
|
312
|
+
].join("\n"),
|
|
313
|
+
);
|
|
314
|
+
assert.equal(document instanceof Error, false);
|
|
315
|
+
if (document instanceof Error) return;
|
|
316
|
+
const fence = document.nodes[0];
|
|
317
|
+
assert.equal(fence?.type === "view" && fence.fence.kind, "services");
|
|
318
|
+
if (fence?.type !== "view" || fence.fence.kind !== "services") return;
|
|
319
|
+
assert.equal(fence.fence.parsed.ok, true);
|
|
320
|
+
if (!fence.fence.parsed.ok) return;
|
|
321
|
+
const diagram = fence.fence.parsed.diagram;
|
|
322
|
+
// Dedenting after Auth's state closes both Auth and API.
|
|
323
|
+
assert.deepEqual(
|
|
324
|
+
diagram.services.map((service) => [
|
|
325
|
+
service.name,
|
|
326
|
+
service.children.map((child) => child.name),
|
|
327
|
+
]),
|
|
328
|
+
[
|
|
329
|
+
["API", ["Auth"]],
|
|
330
|
+
["Store", []],
|
|
331
|
+
],
|
|
332
|
+
);
|
|
333
|
+
// A description belongs to the line above it and joins continued lines.
|
|
334
|
+
assert.equal(diagram.services[0]!.description, "Routes requests.");
|
|
335
|
+
assert.deepEqual(diagram.start, {
|
|
336
|
+
caption: "Alice has not logged in",
|
|
337
|
+
description: "No rows yet.",
|
|
338
|
+
});
|
|
339
|
+
assert.equal(
|
|
340
|
+
diagram.services[0]!.children[0]!.state[0]!.description,
|
|
341
|
+
"Failures since the last success. Five locks the account.",
|
|
342
|
+
);
|
|
343
|
+
assert.equal(diagram.steps[0]!.events[0]!.note, "Sends the password hash.");
|
|
344
|
+
assert.equal(diagram.steps[0]!.updates[0]!.note, "One failure so far.");
|
|
345
|
+
assert.equal(diagram.steps[1]!.updates[0]!.note, undefined);
|
|
346
|
+
const at = (step: number) =>
|
|
347
|
+
Object.fromEntries(
|
|
348
|
+
[...serviceStateAt(diagram, step)].map(([name, state]) => [
|
|
349
|
+
name,
|
|
350
|
+
Object.fromEntries(state),
|
|
351
|
+
]),
|
|
352
|
+
);
|
|
353
|
+
assert.deepEqual(at(0), {
|
|
354
|
+
API: {},
|
|
355
|
+
Auth: { attempts: "0" },
|
|
356
|
+
Store: { rows: "none" },
|
|
357
|
+
});
|
|
358
|
+
assert.deepEqual(at(1), {
|
|
359
|
+
API: {},
|
|
360
|
+
Auth: { attempts: "1" },
|
|
361
|
+
Store: { rows: "none" },
|
|
362
|
+
});
|
|
363
|
+
assert.deepEqual(at(2), {
|
|
364
|
+
API: {},
|
|
365
|
+
Auth: { attempts: "0" },
|
|
366
|
+
Store: { rows: "1 row" },
|
|
367
|
+
});
|
|
368
|
+
assert.deepEqual(document.references, [
|
|
369
|
+
{ kind: "file", path: "src/api.ts" },
|
|
370
|
+
{ kind: "file", path: "src/api.ts", symbol: "login" },
|
|
371
|
+
]);
|
|
372
|
+
});
|
|
373
|
+
|
|
374
|
+
test("a services diff replays Today and Proposed separately", () => {
|
|
375
|
+
const document = parseViewerDocument(
|
|
376
|
+
[
|
|
377
|
+
"```services diff",
|
|
378
|
+
" service API",
|
|
379
|
+
"- state count = 0",
|
|
380
|
+
"+ state count = 1",
|
|
381
|
+
"-service Cache",
|
|
382
|
+
" state hits = 0",
|
|
383
|
+
"+service Queue",
|
|
384
|
+
"+ state jobs = 0",
|
|
385
|
+
"",
|
|
386
|
+
' step "Handle"',
|
|
387
|
+
"- API -> Cache: get",
|
|
388
|
+
"+ API -> Queue: push",
|
|
389
|
+
"- Cache.hits = 1",
|
|
390
|
+
"+ Queue.jobs = 1",
|
|
391
|
+
" API.count = 5",
|
|
392
|
+
'-step "Old cleanup"',
|
|
393
|
+
" API.count = 9",
|
|
394
|
+
"```",
|
|
395
|
+
"",
|
|
396
|
+
].join("\n"),
|
|
397
|
+
);
|
|
398
|
+
if (document instanceof Error) throw document;
|
|
399
|
+
const fence = document.nodes[0];
|
|
400
|
+
if (fence?.type !== "view" || fence.fence.kind !== "services")
|
|
401
|
+
throw new Error("expected a services fence");
|
|
402
|
+
if (!fence.fence.parsed.ok) throw new Error(fence.fence.parsed.reason);
|
|
403
|
+
const diagram = fence.fence.parsed.diagram;
|
|
404
|
+
// Unmarked lines inside a removed service or step take its sign.
|
|
405
|
+
assert.equal(diagram.services[1]!.state[0]!.sign, "-");
|
|
406
|
+
assert.equal(diagram.steps[1]!.updates[0]!.sign, "-");
|
|
407
|
+
assert.deepEqual(
|
|
408
|
+
diagram.steps[0]!.events.map((event) => event.sign),
|
|
409
|
+
["-", "+"],
|
|
410
|
+
);
|
|
411
|
+
const at = (step: number, side: "today" | "proposed") =>
|
|
412
|
+
Object.fromEntries(
|
|
413
|
+
[...serviceStateAt(diagram, step, side)].map(([name, state]) => [
|
|
414
|
+
name,
|
|
415
|
+
Object.fromEntries(state),
|
|
416
|
+
]),
|
|
417
|
+
);
|
|
418
|
+
// A - and a + declaration of one key give each side its own start value.
|
|
419
|
+
assert.deepEqual(at(0, "today"), {
|
|
420
|
+
API: { count: "0" },
|
|
421
|
+
Cache: { hits: "0" },
|
|
422
|
+
Queue: {},
|
|
423
|
+
});
|
|
424
|
+
assert.deepEqual(at(0, "proposed"), {
|
|
425
|
+
API: { count: "1" },
|
|
426
|
+
Cache: {},
|
|
427
|
+
Queue: { jobs: "0" },
|
|
428
|
+
});
|
|
429
|
+
assert.deepEqual(at(2, "today"), {
|
|
430
|
+
API: { count: "9" },
|
|
431
|
+
Cache: { hits: "1" },
|
|
432
|
+
Queue: {},
|
|
433
|
+
});
|
|
434
|
+
assert.deepEqual(at(2, "proposed"), {
|
|
435
|
+
API: { count: "5" },
|
|
436
|
+
Cache: {},
|
|
437
|
+
Queue: { jobs: "1" },
|
|
438
|
+
});
|
|
439
|
+
});
|
|
440
|
+
|
|
441
|
+
test("a services diff rejects unmarked lines and contradictory signs", () => {
|
|
442
|
+
assert.match(
|
|
443
|
+
String(
|
|
444
|
+
parseServicesSpec([" service API", "service Store"], "services diff"),
|
|
445
|
+
),
|
|
446
|
+
/line 5\): Start every line of a services diff/,
|
|
447
|
+
);
|
|
448
|
+
assert.match(
|
|
449
|
+
String(
|
|
450
|
+
parseServicesSpec(
|
|
451
|
+
[" service API", '-step "Old"', "+ API -> API: new"],
|
|
452
|
+
"services diff",
|
|
453
|
+
),
|
|
454
|
+
),
|
|
455
|
+
/line 6\): A \+ line can't sit inside a removed step/,
|
|
456
|
+
);
|
|
457
|
+
});
|
|
458
|
+
|
|
459
|
+
test("a mermaid diff keeps every line and splits out each side", () => {
|
|
460
|
+
const document = parseViewerDocument(
|
|
461
|
+
[
|
|
462
|
+
"```mermaid diff [Request flow]",
|
|
463
|
+
" flowchart LR",
|
|
464
|
+
"- A --> B[Old]",
|
|
465
|
+
"+ A --> B[New]",
|
|
466
|
+
"+ A --> C",
|
|
467
|
+
" %% ref node:A [[src/a.ts]]",
|
|
468
|
+
"```",
|
|
469
|
+
"",
|
|
470
|
+
].join("\n"),
|
|
471
|
+
);
|
|
472
|
+
if (document instanceof Error) throw document;
|
|
473
|
+
const fence = document.nodes[0];
|
|
474
|
+
if (fence?.type !== "view" || fence.fence.kind !== "mermaid")
|
|
475
|
+
throw new Error("expected a mermaid fence");
|
|
476
|
+
// Mermaid keeps a node's first label, so a change's + lines come first.
|
|
477
|
+
assert.equal(
|
|
478
|
+
fence.fence.source,
|
|
479
|
+
"flowchart LR\n A --> B[New]\n A --> C\n A --> B[Old]\n %% ref node:A [[src/a.ts]]",
|
|
480
|
+
);
|
|
481
|
+
assert.deepEqual(fence.fence.sides, {
|
|
482
|
+
today: "flowchart LR\n A --> B[Old]\n %% ref node:A [[src/a.ts]]",
|
|
483
|
+
proposed:
|
|
484
|
+
"flowchart LR\n A --> B[New]\n A --> C\n %% ref node:A [[src/a.ts]]",
|
|
485
|
+
});
|
|
486
|
+
assert.equal(fence.fence.annotations.length, 1);
|
|
487
|
+
assert.equal(fence.fence.title, "Request flow");
|
|
488
|
+
});
|
|
489
|
+
|
|
490
|
+
test("parseViewerDocument fails on services errors with the file line", () => {
|
|
491
|
+
const unknown = parseServicesSpec([
|
|
492
|
+
"service API",
|
|
493
|
+
"",
|
|
494
|
+
'step "Say hi"',
|
|
495
|
+
" API -> Auth: hi",
|
|
496
|
+
]);
|
|
497
|
+
assert.equal(unknown instanceof DiffmapServicesError, true);
|
|
498
|
+
assert.match(String(unknown), /spec\.md \(line 7\): Unknown service Auth/);
|
|
499
|
+
const undeclared = parseServicesSpec([
|
|
500
|
+
"service API",
|
|
501
|
+
'step "Count"',
|
|
502
|
+
" API.count = 1",
|
|
503
|
+
]);
|
|
504
|
+
assert.match(String(undeclared), /line 6\): API has no state count/);
|
|
505
|
+
const uncaptioned = parseServicesSpec(["service API", "step"]);
|
|
506
|
+
assert.match(String(uncaptioned), /line 5\): Give the step a caption/);
|
|
507
|
+
const lateStart = parseServicesSpec([
|
|
508
|
+
"service API",
|
|
509
|
+
'step "Go"',
|
|
510
|
+
'start "Too late"',
|
|
511
|
+
]);
|
|
512
|
+
assert.match(
|
|
513
|
+
String(lateStart),
|
|
514
|
+
/line 6\): Write one start line, before the first step/,
|
|
515
|
+
);
|
|
516
|
+
const orphan = parseServicesSpec(["service API", "> Not indented."]);
|
|
517
|
+
assert.match(String(orphan), /line 5\): Put a > line directly below/);
|
|
518
|
+
});
|
|
519
|
+
|
|
163
520
|
test("parseViewerDocument fails on broken mermaid with file and diagram", () => {
|
|
164
521
|
const source = brokenMermaidSpec();
|
|
165
522
|
const parsed = parseViewerDocument(source, "broken.md");
|
package/src/parseViewer.ts
CHANGED
|
@@ -8,19 +8,28 @@ import {
|
|
|
8
8
|
type SourceReference,
|
|
9
9
|
} from "./annotations.js";
|
|
10
10
|
import { isRev, type Rev } from "./changes.js";
|
|
11
|
-
import {
|
|
11
|
+
import { notesProblem } from "./codeNotes.js";
|
|
12
|
+
import {
|
|
13
|
+
DiffmapAnnotationError,
|
|
14
|
+
DiffmapParseError,
|
|
15
|
+
DiffmapServicesError,
|
|
16
|
+
} from "./errors.js";
|
|
12
17
|
import { parseMermaidDiagram } from "./mermaid.js";
|
|
13
18
|
import { parseFence, type Fence } from "./parseFence.js";
|
|
19
|
+
import { serviceAnnotations } from "./services.js";
|
|
14
20
|
|
|
15
21
|
export type ViewerDocument = {
|
|
16
22
|
nodes: ViewerNode[];
|
|
17
23
|
headings: ViewerHeading[];
|
|
18
24
|
/** Every reference in stacks, diagrams, and prose. */
|
|
19
25
|
references: SourceReference[];
|
|
20
|
-
/**
|
|
21
|
-
|
|
26
|
+
/** `changes` fences, in document order. */
|
|
27
|
+
changeCards: ChangeCard[];
|
|
22
28
|
};
|
|
23
29
|
|
|
30
|
+
/** A `changes` fence's rev and the paths it lists to review first. */
|
|
31
|
+
export type ChangeCard = { rev: Rev; review: string[] };
|
|
32
|
+
|
|
24
33
|
export type ViewerHeading = {
|
|
25
34
|
level: 1 | 2 | 3 | 4 | 5 | 6;
|
|
26
35
|
text: string;
|
|
@@ -75,6 +84,8 @@ export type ViewerElementAttrs = {
|
|
|
75
84
|
task: boolean | undefined;
|
|
76
85
|
alertType: string | undefined;
|
|
77
86
|
align: string | undefined;
|
|
87
|
+
/** The label of an MDC `::details{summary="…"}` toggle. */
|
|
88
|
+
summary: string | undefined;
|
|
78
89
|
};
|
|
79
90
|
|
|
80
91
|
export function parseViewerDocument(source: string, path = "spec") {
|
|
@@ -85,41 +96,89 @@ export function parseViewerDocument(source: string, path = "spec") {
|
|
|
85
96
|
if (tree instanceof Error) return tree;
|
|
86
97
|
const nodes = tree.nodes.flatMap((node) => fromNode(node));
|
|
87
98
|
const fences = collectFences(nodes);
|
|
88
|
-
const
|
|
99
|
+
const changeCards: ChangeCard[] = [];
|
|
89
100
|
for (const fence of fences) {
|
|
90
101
|
if (fence.kind === "code" && fence.lang.startsWith("source-diff")) {
|
|
91
102
|
return new DiffmapAnnotationError({
|
|
92
103
|
reason: `"${fence.lang}" fences were removed. Commit the change, then use a changes fence with the commit SHA and link lines with [[<sha>:path#L12-L30]]`,
|
|
93
104
|
});
|
|
94
105
|
}
|
|
106
|
+
if (fence.kind === "code" || fence.kind === "callstack") {
|
|
107
|
+
const problem = notesProblem({
|
|
108
|
+
// Call stack symbols must appear in the row text readers see, not in a hidden [[reference]].
|
|
109
|
+
lines:
|
|
110
|
+
fence.kind === "code"
|
|
111
|
+
? fence.source.split("\n")
|
|
112
|
+
: fence.lines.map((line) => line.text),
|
|
113
|
+
highlights: fence.highlights,
|
|
114
|
+
notes: fence.notes,
|
|
115
|
+
});
|
|
116
|
+
if (problem !== undefined)
|
|
117
|
+
return new DiffmapAnnotationError({ reason: problem });
|
|
118
|
+
continue;
|
|
119
|
+
}
|
|
95
120
|
if (fence.kind !== "changes") continue;
|
|
96
121
|
if (!isRev(fence.rev)) {
|
|
97
122
|
return new DiffmapAnnotationError({
|
|
98
123
|
reason: `Invalid changes "${fence.rev}". Use a commit SHA, a SHA range (a1b2c3d..e4f5a6b), or leave it empty for uncommitted changes`,
|
|
99
124
|
});
|
|
100
125
|
}
|
|
101
|
-
|
|
126
|
+
const duplicate = fence.review.find(
|
|
127
|
+
(reviewPath, index) => fence.review.indexOf(reviewPath) !== index,
|
|
128
|
+
);
|
|
129
|
+
if (duplicate !== undefined) {
|
|
130
|
+
return new DiffmapAnnotationError({
|
|
131
|
+
reason: `changes "${fence.rev}" lists ${duplicate} twice. List each file to review once`,
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
changeCards.push({ rev: fence.rev, review: fence.review });
|
|
102
135
|
}
|
|
103
136
|
const annotations = [
|
|
104
137
|
...fences.flatMap((fence) => {
|
|
105
138
|
if (fence.kind === "callstack") return fence.lines;
|
|
106
139
|
if (fence.kind === "mermaid")
|
|
107
140
|
return fence.annotations.map((link) => link.annotation);
|
|
141
|
+
if (fence.kind === "services" && fence.parsed.ok)
|
|
142
|
+
return serviceAnnotations(fence.parsed.diagram);
|
|
108
143
|
return [];
|
|
109
144
|
}),
|
|
110
145
|
...collectReferences(nodes),
|
|
111
146
|
];
|
|
147
|
+
const servicesFences = fences.flatMap((fence) =>
|
|
148
|
+
fence.kind === "services" ? [fence] : [],
|
|
149
|
+
);
|
|
150
|
+
for (const [offset, fence] of servicesFences.entries()) {
|
|
151
|
+
if (fence.parsed.ok) continue;
|
|
152
|
+
const start = fenceLine(source, "services", offset + 1);
|
|
153
|
+
return new DiffmapServicesError({
|
|
154
|
+
path,
|
|
155
|
+
where:
|
|
156
|
+
start === undefined
|
|
157
|
+
? `diagram ${offset + 1}, line ${fence.parsed.line}`
|
|
158
|
+
: `line ${start + fence.parsed.line}`,
|
|
159
|
+
reason: fence.parsed.reason,
|
|
160
|
+
});
|
|
161
|
+
}
|
|
112
162
|
const mermaidFences = fences.flatMap((fence) =>
|
|
113
163
|
fence.kind === "mermaid" ? [fence] : [],
|
|
114
164
|
);
|
|
115
165
|
for (const [offset, fence] of mermaidFences.entries()) {
|
|
116
166
|
const mermaidIndex = offset + 1;
|
|
117
|
-
const
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
167
|
+
const where = mermaidWhere(source, mermaidIndex);
|
|
168
|
+
// A diff draws every line, and each side must also stand on its own.
|
|
169
|
+
for (const [side, diagram] of [
|
|
170
|
+
["", fence.source],
|
|
171
|
+
[", Today side", fence.sides?.today],
|
|
172
|
+
[", Proposed side", fence.sides?.proposed],
|
|
173
|
+
] as const) {
|
|
174
|
+
if (diagram === undefined) continue;
|
|
175
|
+
const parsedMermaid = parseMermaidDiagram({
|
|
176
|
+
source: diagram,
|
|
177
|
+
path,
|
|
178
|
+
where: `${where}${side}`,
|
|
179
|
+
});
|
|
180
|
+
if (parsedMermaid instanceof Error) return parsedMermaid;
|
|
181
|
+
}
|
|
123
182
|
const targets = new Set<string>();
|
|
124
183
|
for (const link of fence.annotations) {
|
|
125
184
|
const target = `${link.target}:${link.id}`;
|
|
@@ -164,7 +223,7 @@ export function parseViewerDocument(source: string, path = "spec") {
|
|
|
164
223
|
nodes,
|
|
165
224
|
headings: collectHeadings(nodes),
|
|
166
225
|
references: annotations.flatMap((annotation) => annotation.references),
|
|
167
|
-
|
|
226
|
+
changeCards,
|
|
168
227
|
};
|
|
169
228
|
}
|
|
170
229
|
|
|
@@ -185,15 +244,18 @@ function collectReferences(nodes: ViewerNode[]): SourceAnnotation[] {
|
|
|
185
244
|
}
|
|
186
245
|
|
|
187
246
|
function mermaidWhere(source: string, index: number) {
|
|
188
|
-
const line =
|
|
247
|
+
const line = fenceLine(source, "mermaid", index);
|
|
189
248
|
if (line === undefined) return `diagram ${index}`;
|
|
190
249
|
return `diagram ${index}, line ${line}`;
|
|
191
250
|
}
|
|
192
251
|
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
252
|
+
/** Line of the `index`th (1-based) fence or MDC block named `lang`. */
|
|
253
|
+
function fenceLine(source: string, lang: string, index: number) {
|
|
254
|
+
const pattern = new RegExp(
|
|
255
|
+
String.raw`^[ \t]*(\`{3,}${lang}\b|::${lang}\b)`,
|
|
256
|
+
"gm",
|
|
257
|
+
);
|
|
258
|
+
const match = [...source.matchAll(pattern)][index - 1];
|
|
197
259
|
if (match === undefined || match.index === undefined) return undefined;
|
|
198
260
|
return source.slice(0, match.index).split("\n").length;
|
|
199
261
|
}
|
|
@@ -323,15 +385,34 @@ function fromElement(
|
|
|
323
385
|
if (tag === "template")
|
|
324
386
|
return children.flatMap((child) => fromNode(child, literalChildren));
|
|
325
387
|
if (tag === "pre") {
|
|
326
|
-
|
|
388
|
+
// md4x parses ```ts [src/request.ts] into `filename`; diffmap shows it as the title.
|
|
389
|
+
return fenceView(
|
|
390
|
+
stringAttr(element, "language"),
|
|
391
|
+
comarkText(children),
|
|
392
|
+
highlightsAttr(element),
|
|
393
|
+
stringAttr(element, "filename"),
|
|
394
|
+
stringAttr(element, "meta"),
|
|
395
|
+
);
|
|
327
396
|
}
|
|
328
|
-
if (tag === "mermaid")
|
|
329
|
-
if (tag === "callstack") return fenceView("callstack", comarkText(children));
|
|
330
|
-
if (tag === "changes")
|
|
397
|
+
if (tag === "mermaid")
|
|
331
398
|
return fenceView(
|
|
332
|
-
"
|
|
333
|
-
|
|
399
|
+
"mermaid",
|
|
400
|
+
comarkText(children),
|
|
401
|
+
undefined,
|
|
402
|
+
stringAttr(element, "title"),
|
|
334
403
|
);
|
|
404
|
+
if (tag === "callstack")
|
|
405
|
+
return fenceView(
|
|
406
|
+
"callstack",
|
|
407
|
+
comarkText(children),
|
|
408
|
+
undefined,
|
|
409
|
+
stringAttr(element, "title"),
|
|
410
|
+
);
|
|
411
|
+
if (tag === "changes") {
|
|
412
|
+
const rev = stringAttr(element, "rev");
|
|
413
|
+
const body = comarkText(children);
|
|
414
|
+
return fenceView("changes", rev === undefined ? body : `${rev}\n${body}`);
|
|
415
|
+
}
|
|
335
416
|
if (tag === "diff") {
|
|
336
417
|
const path = stringAttr(element, "path");
|
|
337
418
|
const lang = path === undefined ? "diff" : `diff:${path}`;
|
|
@@ -366,10 +447,22 @@ function unwrapTaskParagraph(children: ViewerNode[]): ViewerNode[] {
|
|
|
366
447
|
return children;
|
|
367
448
|
}
|
|
368
449
|
|
|
369
|
-
function fenceView(
|
|
450
|
+
function fenceView(
|
|
451
|
+
lang: string | undefined,
|
|
452
|
+
source: string,
|
|
453
|
+
highlights?: number[],
|
|
454
|
+
title?: string,
|
|
455
|
+
meta?: string,
|
|
456
|
+
): ViewerView {
|
|
370
457
|
return {
|
|
371
458
|
type: "view",
|
|
372
|
-
fence: parseFence(
|
|
459
|
+
fence: parseFence(
|
|
460
|
+
lang === undefined ? "" : lang,
|
|
461
|
+
source,
|
|
462
|
+
highlights,
|
|
463
|
+
title,
|
|
464
|
+
meta,
|
|
465
|
+
),
|
|
373
466
|
};
|
|
374
467
|
}
|
|
375
468
|
|
|
@@ -420,6 +513,7 @@ function elementAttrs(element: ComarkElement): ViewerElementAttrs {
|
|
|
420
513
|
task: element[1].task === true ? true : undefined,
|
|
421
514
|
alertType: stringAttr(element, "type"),
|
|
422
515
|
align: stringAttr(element, "align"),
|
|
516
|
+
summary: stringAttr(element, "summary"),
|
|
423
517
|
};
|
|
424
518
|
}
|
|
425
519
|
|
|
@@ -430,6 +524,13 @@ function stringAttr(element: ComarkElement, key: string) {
|
|
|
430
524
|
return value as string;
|
|
431
525
|
}
|
|
432
526
|
|
|
527
|
+
/** md4x parses ```ts {2,4-5} into `highlights: [2, 4, 5]`. */
|
|
528
|
+
function highlightsAttr(element: ComarkElement) {
|
|
529
|
+
const value = element[1].highlights;
|
|
530
|
+
if (!Array.isArray(value)) return undefined;
|
|
531
|
+
return value.filter((line): line is number => typeof line === "number");
|
|
532
|
+
}
|
|
533
|
+
|
|
433
534
|
function lineAttr(element: ComarkElement, key: string) {
|
|
434
535
|
const value = stringAttr(element, key);
|
|
435
536
|
if (value === undefined) return undefined;
|
package/src/serve.test.ts
CHANGED
|
@@ -7,7 +7,7 @@ import path from "node:path";
|
|
|
7
7
|
import { fileURLToPath } from "node:url";
|
|
8
8
|
import { promisify } from "node:util";
|
|
9
9
|
import { setTimeout as delay } from "node:timers/promises";
|
|
10
|
-
import test from "node:test";
|
|
10
|
+
import test, { before } from "node:test";
|
|
11
11
|
import { DiffmapServeError } from "./errors.js";
|
|
12
12
|
import { startServer, type DiffmapServer } from "./serve.js";
|
|
13
13
|
|
|
@@ -92,6 +92,27 @@ async function waitForClosed(server: DiffmapServer, timeoutMs = 10_000) {
|
|
|
92
92
|
]);
|
|
93
93
|
}
|
|
94
94
|
|
|
95
|
+
// These tests run several servers at once. On a cold or stale cache each one
|
|
96
|
+
// prebundles dependencies on its own, at over 1 GB each, which can exhaust the
|
|
97
|
+
// machine's memory. Vite holds requests for a prebundled file until the bundle
|
|
98
|
+
// is on disk, so fetching one here leaves a warm cache for the servers below.
|
|
99
|
+
before(async () => {
|
|
100
|
+
const server = assertStarted(
|
|
101
|
+
await startServer({ filePath: sampleSpec, workspaceRoot: packageRoot }),
|
|
102
|
+
);
|
|
103
|
+
try {
|
|
104
|
+
const entry = await fetch(`${server.url}/src/viewer/main.tsx`);
|
|
105
|
+
const dep = /"(\/node_modules\/\.vite\/deps\/[^"]+)"/.exec(
|
|
106
|
+
await entry.text(),
|
|
107
|
+
)?.[1];
|
|
108
|
+
assert.ok(dep !== undefined, "viewer entry imports no prebundled module");
|
|
109
|
+
assert.equal((await fetch(`${server.url}${dep}`)).ok, true);
|
|
110
|
+
} finally {
|
|
111
|
+
await server.shutdown();
|
|
112
|
+
await waitForClosed(server);
|
|
113
|
+
}
|
|
114
|
+
});
|
|
115
|
+
|
|
95
116
|
test(
|
|
96
117
|
"startServer binds distinct free ports when 4177 is taken",
|
|
97
118
|
{ timeout: 90_000 },
|