@tanishqkancharla/diffmap 0.2.0 → 0.4.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 +107 -4
- package/index.html +6 -0
- package/package.json +3 -2
- package/src/changes.ts +21 -0
- package/src/codeNotes.ts +123 -0
- package/src/errors.ts +5 -0
- package/src/gist/GistApp.tsx +23 -20
- package/src/gist/fetchGist.ts +42 -18
- package/src/git.test.ts +13 -1
- package/src/git.ts +15 -2
- package/src/host/HostHome.tsx +22 -6
- package/src/host/files.ts +5 -1
- package/src/host/home.md +148 -102
- package/src/host/home.test.ts +6 -7
- package/src/parseFence.ts +57 -17
- package/src/parseViewer.test.ts +269 -2
- package/src/parseViewer.ts +86 -20
- package/src/services.ts +289 -0
- package/src/share.ts +50 -7
- package/src/viewer/CallStackDiff.tsx +90 -24
- package/src/viewer/ChangesCard.tsx +133 -43
- package/src/viewer/CodeBlock.tsx +131 -0
- package/src/viewer/Fence.tsx +22 -2
- package/src/viewer/ServiceDiagramBlock.tsx +323 -0
- package/src/viewer/ServiceStage.tsx +988 -0
- package/src/viewer/SourceDiffPanel.tsx +13 -9
- package/src/viewer/TableOfContents.tsx +3 -0
- package/src/viewer/ViewerApp.tsx +18 -3
- package/src/viewer/notes.tsx +126 -0
- package/src/viewer/serviceLayout.ts +189 -0
- package/vite.config.ts +1 -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[]) {
|
|
26
|
+
return parseViewerDocument(
|
|
27
|
+
["# Flow", "", "```services", ...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,187 @@ 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 {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
|
+
},
|
|
229
|
+
});
|
|
230
|
+
// Symbols match whole words, so `save` is not found inside `saveAll`.
|
|
231
|
+
assert.match(
|
|
232
|
+
codeFenceProblem(["store.saveAll(x);", "// [!note save] Saves."]),
|
|
233
|
+
/"save" does not appear/,
|
|
234
|
+
);
|
|
235
|
+
assert.match(
|
|
236
|
+
codeFenceProblem(["const x = 1;", "// [!note L1-L2] Too long."]),
|
|
237
|
+
/outside the 1-line code fence/,
|
|
238
|
+
);
|
|
239
|
+
assert.match(
|
|
240
|
+
String(parseViewerDocument("```ts {3}\nconst x = 1;\n```\n")),
|
|
241
|
+
/Highlighted line 3 is outside/,
|
|
242
|
+
);
|
|
243
|
+
// Call stacks number rows, and a symbol must appear in visible row text.
|
|
244
|
+
const stack = parseViewerDocument(
|
|
245
|
+
[
|
|
246
|
+
"```callstack {2}",
|
|
247
|
+
" run() [[src/a.ts#run]]",
|
|
248
|
+
"+└── save(record)",
|
|
249
|
+
"# [!note save] Writes the record.",
|
|
250
|
+
"```",
|
|
251
|
+
"",
|
|
252
|
+
].join("\n"),
|
|
253
|
+
);
|
|
254
|
+
if (stack instanceof Error) throw stack;
|
|
255
|
+
const fence =
|
|
256
|
+
stack.nodes[0]?.type === "view" ? stack.nodes[0].fence : undefined;
|
|
257
|
+
assert.equal(fence?.kind, "callstack");
|
|
258
|
+
if (fence?.kind !== "callstack") return;
|
|
259
|
+
assert.equal(fence.lines.length, 2);
|
|
260
|
+
assert.deepEqual(fence.highlights, [2]);
|
|
261
|
+
assert.deepEqual(fence.notes, [
|
|
262
|
+
{ target: { kind: "symbol", symbol: "save" }, text: "Writes the record." },
|
|
263
|
+
]);
|
|
264
|
+
assert.match(
|
|
265
|
+
String(
|
|
266
|
+
parseViewerDocument(
|
|
267
|
+
"```callstack\n run() [[src/a.ts#Store.save]]\n# [!note save] Hidden.\n```\n",
|
|
268
|
+
),
|
|
269
|
+
),
|
|
270
|
+
/"save" does not appear/,
|
|
271
|
+
);
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
test("parseViewerDocument reads services, steps, and their references", () => {
|
|
275
|
+
const document = parseViewerDocument(
|
|
276
|
+
[
|
|
277
|
+
"```services",
|
|
278
|
+
"service API [[src/api.ts]]",
|
|
279
|
+
" > Routes requests.",
|
|
280
|
+
" service Auth",
|
|
281
|
+
" state attempts = 0",
|
|
282
|
+
" > Failures since the last success.",
|
|
283
|
+
" > Five locks the account.",
|
|
284
|
+
"service Store",
|
|
285
|
+
" state rows = none",
|
|
286
|
+
"",
|
|
287
|
+
'step "Verify"',
|
|
288
|
+
" API -> Auth: verify(alice) [[src/api.ts#login]]",
|
|
289
|
+
" > Sends the password hash.",
|
|
290
|
+
" Auth.attempts = 1",
|
|
291
|
+
" > One failure so far.",
|
|
292
|
+
'step "Auth stores the row"',
|
|
293
|
+
" Auth -> Store: insert",
|
|
294
|
+
" Store.rows = 1 row",
|
|
295
|
+
" Auth.attempts = 0",
|
|
296
|
+
"```",
|
|
297
|
+
"",
|
|
298
|
+
].join("\n"),
|
|
299
|
+
);
|
|
300
|
+
assert.equal(document instanceof Error, false);
|
|
301
|
+
if (document instanceof Error) return;
|
|
302
|
+
const fence = document.nodes[0];
|
|
303
|
+
assert.equal(fence?.type === "view" && fence.fence.kind, "services");
|
|
304
|
+
if (fence?.type !== "view" || fence.fence.kind !== "services") return;
|
|
305
|
+
assert.equal(fence.fence.parsed.ok, true);
|
|
306
|
+
if (!fence.fence.parsed.ok) return;
|
|
307
|
+
const diagram = fence.fence.parsed.diagram;
|
|
308
|
+
// Dedenting after Auth's state closes both Auth and API.
|
|
309
|
+
assert.deepEqual(
|
|
310
|
+
diagram.services.map((service) => [
|
|
311
|
+
service.name,
|
|
312
|
+
service.children.map((child) => child.name),
|
|
313
|
+
]),
|
|
314
|
+
[
|
|
315
|
+
["API", ["Auth"]],
|
|
316
|
+
["Store", []],
|
|
317
|
+
],
|
|
318
|
+
);
|
|
319
|
+
// A description belongs to the line above it and joins continued lines.
|
|
320
|
+
assert.equal(diagram.services[0]!.description, "Routes requests.");
|
|
321
|
+
assert.equal(
|
|
322
|
+
diagram.services[0]!.children[0]!.state[0]!.description,
|
|
323
|
+
"Failures since the last success. Five locks the account.",
|
|
324
|
+
);
|
|
325
|
+
assert.equal(diagram.steps[0]!.events[0]!.note, "Sends the password hash.");
|
|
326
|
+
assert.equal(diagram.steps[0]!.updates[0]!.note, "One failure so far.");
|
|
327
|
+
assert.equal(diagram.steps[1]!.updates[0]!.note, undefined);
|
|
328
|
+
const at = (step: number) =>
|
|
329
|
+
Object.fromEntries(
|
|
330
|
+
[...serviceStateAt(diagram, step)].map(([name, state]) => [
|
|
331
|
+
name,
|
|
332
|
+
Object.fromEntries(state),
|
|
333
|
+
]),
|
|
334
|
+
);
|
|
335
|
+
assert.deepEqual(at(0), {
|
|
336
|
+
API: {},
|
|
337
|
+
Auth: { attempts: "0" },
|
|
338
|
+
Store: { rows: "none" },
|
|
339
|
+
});
|
|
340
|
+
assert.deepEqual(at(1), {
|
|
341
|
+
API: {},
|
|
342
|
+
Auth: { attempts: "1" },
|
|
343
|
+
Store: { rows: "none" },
|
|
344
|
+
});
|
|
345
|
+
assert.deepEqual(at(2), {
|
|
346
|
+
API: {},
|
|
347
|
+
Auth: { attempts: "0" },
|
|
348
|
+
Store: { rows: "1 row" },
|
|
349
|
+
});
|
|
350
|
+
assert.deepEqual(document.references, [
|
|
351
|
+
{ kind: "file", path: "src/api.ts" },
|
|
352
|
+
{ kind: "file", path: "src/api.ts", symbol: "login" },
|
|
353
|
+
]);
|
|
354
|
+
});
|
|
355
|
+
|
|
356
|
+
test("parseViewerDocument fails on services errors with the file line", () => {
|
|
357
|
+
const unknown = parseServicesSpec([
|
|
358
|
+
"service API",
|
|
359
|
+
"",
|
|
360
|
+
'step "Say hi"',
|
|
361
|
+
" API -> Auth: hi",
|
|
362
|
+
]);
|
|
363
|
+
assert.equal(unknown instanceof DiffmapServicesError, true);
|
|
364
|
+
assert.match(String(unknown), /spec\.md \(line 7\): Unknown service Auth/);
|
|
365
|
+
const undeclared = parseServicesSpec([
|
|
366
|
+
"service API",
|
|
367
|
+
'step "Count"',
|
|
368
|
+
" API.count = 1",
|
|
369
|
+
]);
|
|
370
|
+
assert.match(String(undeclared), /line 6\): API has no state count/);
|
|
371
|
+
const uncaptioned = parseServicesSpec(["service API", "step"]);
|
|
372
|
+
assert.match(String(uncaptioned), /line 5\): Give the step a caption/);
|
|
373
|
+
const orphan = parseServicesSpec(["service API", "> Not indented."]);
|
|
374
|
+
assert.match(String(orphan), /line 5\): Put a > line directly below/);
|
|
375
|
+
});
|
|
376
|
+
|
|
163
377
|
test("parseViewerDocument fails on broken mermaid with file and diagram", () => {
|
|
164
378
|
const source = brokenMermaidSpec();
|
|
165
379
|
const parsed = parseViewerDocument(source, "broken.md");
|
|
@@ -234,6 +448,59 @@ test("shareMarkdownFile does not call gh when mermaid is invalid", async () => {
|
|
|
234
448
|
});
|
|
235
449
|
});
|
|
236
450
|
|
|
451
|
+
test("shareMarkdownFile uploads the files excerpts and rev-less references read", async () => {
|
|
452
|
+
const spec = [
|
|
453
|
+
"# Shared",
|
|
454
|
+
"",
|
|
455
|
+
"See [[src/share.ts#shareMarkdownFile]].",
|
|
456
|
+
"",
|
|
457
|
+
"```1:3:src/changes.ts",
|
|
458
|
+
"```",
|
|
459
|
+
"",
|
|
460
|
+
].join("\n");
|
|
461
|
+
await withTempSpec(spec, async (filePath) => {
|
|
462
|
+
const dir = path.dirname(filePath);
|
|
463
|
+
const uploaded = path.join(dir, "uploaded");
|
|
464
|
+
await fs.mkdir(uploaded);
|
|
465
|
+
const gh = path.join(dir, "gh");
|
|
466
|
+
await fs.writeFile(
|
|
467
|
+
gh,
|
|
468
|
+
[
|
|
469
|
+
"#!/bin/sh",
|
|
470
|
+
'if [ "$1" = auth ]; then exit 0; fi',
|
|
471
|
+
`for arg; do case "$arg" in *.json) cp "$arg" ${JSON.stringify(uploaded)};; esac; done`,
|
|
472
|
+
"echo https://gist.github.com/0123456789abcdef0123456789abcdef",
|
|
473
|
+
].join("\n"),
|
|
474
|
+
{ mode: 0o755 },
|
|
475
|
+
);
|
|
476
|
+
const originalPath = process.env.PATH;
|
|
477
|
+
process.env.PATH = `${dir}${path.delimiter}${originalPath ?? ""}`;
|
|
478
|
+
try {
|
|
479
|
+
const shared = await shareMarkdownFile({
|
|
480
|
+
workspaceRoot: packageRoot,
|
|
481
|
+
filePath,
|
|
482
|
+
isPublic: false,
|
|
483
|
+
});
|
|
484
|
+
assert.equal(shared instanceof Error, false, String(shared));
|
|
485
|
+
} finally {
|
|
486
|
+
process.env.PATH = originalPath;
|
|
487
|
+
}
|
|
488
|
+
const files: unknown = JSON.parse(
|
|
489
|
+
await fs.readFile(path.join(uploaded, "diffmap-files.json"), "utf8"),
|
|
490
|
+
);
|
|
491
|
+
assert.deepEqual(files, {
|
|
492
|
+
"src/share.ts": await fs.readFile(
|
|
493
|
+
path.join(packageRoot, "src/share.ts"),
|
|
494
|
+
"utf8",
|
|
495
|
+
),
|
|
496
|
+
"src/changes.ts": await fs.readFile(
|
|
497
|
+
path.join(packageRoot, "src/changes.ts"),
|
|
498
|
+
"utf8",
|
|
499
|
+
),
|
|
500
|
+
});
|
|
501
|
+
});
|
|
502
|
+
});
|
|
503
|
+
|
|
237
504
|
function mermaidCliMessage(filePath: string) {
|
|
238
505
|
return `diffmap could not parse mermaid in ${filePath} (diagram 1, line 3): Invalid mermaid header: "flwochart TD". Expected "graph TD", "flowchart LR", "stateDiagram-v2", etc.`;
|
|
239
506
|
}
|
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;
|
|
@@ -85,30 +94,69 @@ export function parseViewerDocument(source: string, path = "spec") {
|
|
|
85
94
|
if (tree instanceof Error) return tree;
|
|
86
95
|
const nodes = tree.nodes.flatMap((node) => fromNode(node));
|
|
87
96
|
const fences = collectFences(nodes);
|
|
88
|
-
const
|
|
97
|
+
const changeCards: ChangeCard[] = [];
|
|
89
98
|
for (const fence of fences) {
|
|
90
99
|
if (fence.kind === "code" && fence.lang.startsWith("source-diff")) {
|
|
91
100
|
return new DiffmapAnnotationError({
|
|
92
101
|
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
102
|
});
|
|
94
103
|
}
|
|
104
|
+
if (fence.kind === "code" || fence.kind === "callstack") {
|
|
105
|
+
const problem = notesProblem({
|
|
106
|
+
// Call stack symbols must appear in the row text readers see, not in a hidden [[reference]].
|
|
107
|
+
lines:
|
|
108
|
+
fence.kind === "code"
|
|
109
|
+
? fence.source.split("\n")
|
|
110
|
+
: fence.lines.map((line) => line.text),
|
|
111
|
+
highlights: fence.highlights,
|
|
112
|
+
notes: fence.notes,
|
|
113
|
+
});
|
|
114
|
+
if (problem !== undefined)
|
|
115
|
+
return new DiffmapAnnotationError({ reason: problem });
|
|
116
|
+
continue;
|
|
117
|
+
}
|
|
95
118
|
if (fence.kind !== "changes") continue;
|
|
96
119
|
if (!isRev(fence.rev)) {
|
|
97
120
|
return new DiffmapAnnotationError({
|
|
98
121
|
reason: `Invalid changes "${fence.rev}". Use a commit SHA, a SHA range (a1b2c3d..e4f5a6b), or leave it empty for uncommitted changes`,
|
|
99
122
|
});
|
|
100
123
|
}
|
|
101
|
-
|
|
124
|
+
const duplicate = fence.review.find(
|
|
125
|
+
(reviewPath, index) => fence.review.indexOf(reviewPath) !== index,
|
|
126
|
+
);
|
|
127
|
+
if (duplicate !== undefined) {
|
|
128
|
+
return new DiffmapAnnotationError({
|
|
129
|
+
reason: `changes "${fence.rev}" lists ${duplicate} twice. List each file to review once`,
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
changeCards.push({ rev: fence.rev, review: fence.review });
|
|
102
133
|
}
|
|
103
134
|
const annotations = [
|
|
104
135
|
...fences.flatMap((fence) => {
|
|
105
136
|
if (fence.kind === "callstack") return fence.lines;
|
|
106
137
|
if (fence.kind === "mermaid")
|
|
107
138
|
return fence.annotations.map((link) => link.annotation);
|
|
139
|
+
if (fence.kind === "services" && fence.parsed.ok)
|
|
140
|
+
return serviceAnnotations(fence.parsed.diagram);
|
|
108
141
|
return [];
|
|
109
142
|
}),
|
|
110
143
|
...collectReferences(nodes),
|
|
111
144
|
];
|
|
145
|
+
const servicesFences = fences.flatMap((fence) =>
|
|
146
|
+
fence.kind === "services" ? [fence] : [],
|
|
147
|
+
);
|
|
148
|
+
for (const [offset, fence] of servicesFences.entries()) {
|
|
149
|
+
if (fence.parsed.ok) continue;
|
|
150
|
+
const start = fenceLine(source, "services", offset + 1);
|
|
151
|
+
return new DiffmapServicesError({
|
|
152
|
+
path,
|
|
153
|
+
where:
|
|
154
|
+
start === undefined
|
|
155
|
+
? `diagram ${offset + 1}, line ${fence.parsed.line}`
|
|
156
|
+
: `line ${start + fence.parsed.line}`,
|
|
157
|
+
reason: fence.parsed.reason,
|
|
158
|
+
});
|
|
159
|
+
}
|
|
112
160
|
const mermaidFences = fences.flatMap((fence) =>
|
|
113
161
|
fence.kind === "mermaid" ? [fence] : [],
|
|
114
162
|
);
|
|
@@ -164,11 +212,11 @@ export function parseViewerDocument(source: string, path = "spec") {
|
|
|
164
212
|
nodes,
|
|
165
213
|
headings: collectHeadings(nodes),
|
|
166
214
|
references: annotations.flatMap((annotation) => annotation.references),
|
|
167
|
-
|
|
215
|
+
changeCards,
|
|
168
216
|
};
|
|
169
217
|
}
|
|
170
218
|
|
|
171
|
-
function collectFences(nodes: ViewerNode[]): Fence[] {
|
|
219
|
+
export function collectFences(nodes: ViewerNode[]): Fence[] {
|
|
172
220
|
return nodes.flatMap((node) => {
|
|
173
221
|
if (node.type === "view") return [node.fence];
|
|
174
222
|
if (node.type === "element") return collectFences(node.children);
|
|
@@ -185,15 +233,18 @@ function collectReferences(nodes: ViewerNode[]): SourceAnnotation[] {
|
|
|
185
233
|
}
|
|
186
234
|
|
|
187
235
|
function mermaidWhere(source: string, index: number) {
|
|
188
|
-
const line =
|
|
236
|
+
const line = fenceLine(source, "mermaid", index);
|
|
189
237
|
if (line === undefined) return `diagram ${index}`;
|
|
190
238
|
return `diagram ${index}, line ${line}`;
|
|
191
239
|
}
|
|
192
240
|
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
241
|
+
/** Line of the `index`th (1-based) fence or MDC block named `lang`. */
|
|
242
|
+
function fenceLine(source: string, lang: string, index: number) {
|
|
243
|
+
const pattern = new RegExp(
|
|
244
|
+
String.raw`^[ \t]*(\`{3,}${lang}\b|::${lang}\b)`,
|
|
245
|
+
"gm",
|
|
246
|
+
);
|
|
247
|
+
const match = [...source.matchAll(pattern)][index - 1];
|
|
197
248
|
if (match === undefined || match.index === undefined) return undefined;
|
|
198
249
|
return source.slice(0, match.index).split("\n").length;
|
|
199
250
|
}
|
|
@@ -323,15 +374,19 @@ function fromElement(
|
|
|
323
374
|
if (tag === "template")
|
|
324
375
|
return children.flatMap((child) => fromNode(child, literalChildren));
|
|
325
376
|
if (tag === "pre") {
|
|
326
|
-
return fenceView(
|
|
377
|
+
return fenceView(
|
|
378
|
+
stringAttr(element, "language"),
|
|
379
|
+
comarkText(children),
|
|
380
|
+
highlightsAttr(element),
|
|
381
|
+
);
|
|
327
382
|
}
|
|
328
383
|
if (tag === "mermaid") return fenceView("mermaid", comarkText(children));
|
|
329
384
|
if (tag === "callstack") return fenceView("callstack", comarkText(children));
|
|
330
|
-
if (tag === "changes")
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
385
|
+
if (tag === "changes") {
|
|
386
|
+
const rev = stringAttr(element, "rev");
|
|
387
|
+
const body = comarkText(children);
|
|
388
|
+
return fenceView("changes", rev === undefined ? body : `${rev}\n${body}`);
|
|
389
|
+
}
|
|
335
390
|
if (tag === "diff") {
|
|
336
391
|
const path = stringAttr(element, "path");
|
|
337
392
|
const lang = path === undefined ? "diff" : `diff:${path}`;
|
|
@@ -366,10 +421,14 @@ function unwrapTaskParagraph(children: ViewerNode[]): ViewerNode[] {
|
|
|
366
421
|
return children;
|
|
367
422
|
}
|
|
368
423
|
|
|
369
|
-
function fenceView(
|
|
424
|
+
function fenceView(
|
|
425
|
+
lang: string | undefined,
|
|
426
|
+
source: string,
|
|
427
|
+
highlights?: number[],
|
|
428
|
+
): ViewerView {
|
|
370
429
|
return {
|
|
371
430
|
type: "view",
|
|
372
|
-
fence: parseFence(lang === undefined ? "" : lang, source),
|
|
431
|
+
fence: parseFence(lang === undefined ? "" : lang, source, highlights),
|
|
373
432
|
};
|
|
374
433
|
}
|
|
375
434
|
|
|
@@ -430,6 +489,13 @@ function stringAttr(element: ComarkElement, key: string) {
|
|
|
430
489
|
return value as string;
|
|
431
490
|
}
|
|
432
491
|
|
|
492
|
+
/** md4x parses ```ts {2,4-5} into `highlights: [2, 4, 5]`. */
|
|
493
|
+
function highlightsAttr(element: ComarkElement) {
|
|
494
|
+
const value = element[1].highlights;
|
|
495
|
+
if (!Array.isArray(value)) return undefined;
|
|
496
|
+
return value.filter((line): line is number => typeof line === "number");
|
|
497
|
+
}
|
|
498
|
+
|
|
433
499
|
function lineAttr(element: ComarkElement, key: string) {
|
|
434
500
|
const value = stringAttr(element, key);
|
|
435
501
|
if (value === undefined) return undefined;
|