@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.
@@ -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
- assert.deepEqual(document.changeRevs, ["a1b2c3d..e4f5a6b", "working"]);
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");
@@ -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 { DiffmapAnnotationError, DiffmapParseError } from "./errors.js";
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
- /** Revs of `changes` fences, in document order. */
21
- changeRevs: Rev[];
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 changeRevs: Rev[] = [];
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
- changeRevs.push(fence.rev);
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 parsedMermaid = parseMermaidDiagram({
118
- source: fence.source,
119
- path,
120
- where: mermaidWhere(source, mermaidIndex),
121
- });
122
- if (parsedMermaid instanceof Error) return parsedMermaid;
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
- changeRevs,
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 = mermaidFenceLine(source, index);
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
- function mermaidFenceLine(source: string, index: number) {
194
- const match = [...source.matchAll(/^[ \t]*(`{3,}mermaid\b|::mermaid\b)/gm)][
195
- index - 1
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
- return fenceView(stringAttr(element, "language"), comarkText(children));
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") return fenceView("mermaid", comarkText(children));
329
- if (tag === "callstack") return fenceView("callstack", comarkText(children));
330
- if (tag === "changes")
397
+ if (tag === "mermaid")
331
398
  return fenceView(
332
- "changes",
333
- stringAttr(element, "rev") ?? comarkText(children),
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(lang: string | undefined, source: string): ViewerView {
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(lang === undefined ? "" : lang, source),
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 },