@tanishqkancharla/diffmap 0.1.4 → 0.2.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 (53) hide show
  1. package/README.md +69 -61
  2. package/index.html +18 -0
  3. package/package.json +7 -5
  4. package/src/annotations.ts +36 -29
  5. package/src/changes.ts +229 -0
  6. package/src/cli.test.ts +50 -0
  7. package/src/cli.ts +41 -5
  8. package/src/errors.ts +10 -0
  9. package/src/gist/GistApp.tsx +39 -20
  10. package/src/gist/GistStatus.tsx +2 -51
  11. package/src/gist/fetchGist.ts +23 -0
  12. package/src/git.test.ts +402 -0
  13. package/src/git.ts +498 -0
  14. package/src/github/GitHubApp.tsx +39 -22
  15. package/src/github/fetchRepo.test.ts +59 -1
  16. package/src/github/fetchRepo.ts +99 -43
  17. package/src/github/patch.test.ts +18 -9
  18. package/src/github/patch.ts +30 -19
  19. package/src/host/HostHome.tsx +86 -0
  20. package/src/host/files.ts +10 -0
  21. package/src/host/home.md +156 -0
  22. package/src/host/home.test.ts +83 -0
  23. package/src/index.ts +1 -0
  24. package/src/packageVersion.ts +7 -0
  25. package/src/parseFence.ts +6 -6
  26. package/src/parseViewer.test.ts +87 -0
  27. package/src/parseViewer.ts +103 -77
  28. package/src/serve.test.ts +306 -0
  29. package/src/serve.ts +48 -5
  30. package/src/share.ts +18 -0
  31. package/src/update.test.ts +19 -0
  32. package/src/update.ts +285 -0
  33. package/src/viewer/CallStackDiff.tsx +15 -3
  34. package/src/viewer/ChangesCard.tsx +267 -0
  35. package/src/viewer/CloseServerButton.tsx +16 -0
  36. package/src/viewer/ComarkView.tsx +83 -15
  37. package/src/viewer/Fence.tsx +9 -1
  38. package/src/viewer/FileExcerpt.tsx +25 -3
  39. package/src/viewer/HtmlPlaceholder.tsx +6 -1
  40. package/src/viewer/MermaidBlock.tsx +99 -11
  41. package/src/viewer/RangePicker.tsx +526 -0
  42. package/src/viewer/SourceDiffPanel.tsx +277 -121
  43. package/src/viewer/TableOfContents.tsx +313 -39
  44. package/src/viewer/ViewerApp.tsx +120 -68
  45. package/src/viewer/changesSource.ts +91 -0
  46. package/src/viewer/sourceHeader.test.ts +2 -87
  47. package/src/viewer/sourceHeader.ts +4 -63
  48. package/src/viewer/sourceNavigation.ts +33 -42
  49. package/src/viewer/viewerMode.ts +1 -1
  50. package/vite.config.ts +2 -1
  51. package/vite.gist.config.ts +57 -0
  52. package/src/viewer/Check.tsx +0 -24
  53. package/src/viewer/DoneButton.tsx +0 -32
package/src/parseFence.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import { parseCallStack, type SourceAnnotation } from "./annotations.js";
2
+ import { WORKING, type Rev } from "./changes.js";
2
3
  import {
3
4
  parseDiagramAnnotations,
4
5
  type DiagramAnnotation,
@@ -8,7 +9,7 @@ export type Fence =
8
9
  | { kind: "mermaid"; source: string; annotations: DiagramAnnotation[] }
9
10
  | { kind: "html"; source: string }
10
11
  | { kind: "callstack"; source: string; lines: SourceAnnotation[] }
11
- | { kind: "source-diff"; id: string; path: string; source: string }
12
+ | { kind: "changes"; rev: Rev; source: string }
12
13
  | { kind: "file"; path: string; start: number; end: number; source: string }
13
14
  | { kind: "diff"; path: string | undefined; source: string }
14
15
  | { kind: "code"; lang: string; source: string };
@@ -46,12 +47,11 @@ export function parseFence(lang: string, source: string): Fence {
46
47
  lines: parseCallStack(trimmed),
47
48
  };
48
49
 
49
- const sourceDiff = /^source-diff:([\w-]+):(.+)$/.exec(lang);
50
- if (sourceDiff !== null) {
50
+ if (lang === "changes") {
51
+ const rev = trimmed.trim();
51
52
  return {
52
- kind: "source-diff",
53
- id: sourceDiff[1]!,
54
- path: sourceDiff[2]!,
53
+ kind: "changes",
54
+ rev: rev.length === 0 ? WORKING : rev,
55
55
  source: trimmed,
56
56
  };
57
57
  }
@@ -74,6 +74,92 @@ test("parseViewerDocument accepts a spec with valid mermaid", async () => {
74
74
  );
75
75
  });
76
76
 
77
+ test("parseViewerDocument turns prose [[path#symbol]] into a short reference", () => {
78
+ const source =
79
+ "See [[packages/server/src/agent/Thread.ts#Thread.attach]] here, not `[[path]]` or [x [[a.ts]]](https://x.dev).\n";
80
+ const document = parseViewerDocument(source);
81
+ assert.equal(document instanceof Error, false);
82
+ if (document instanceof Error) return;
83
+ assert.equal(document.references.length, 1);
84
+ const paragraph = document.nodes[0];
85
+ assert.equal(paragraph?.type, "element");
86
+ if (paragraph?.type !== "element") return;
87
+ assert.deepEqual(
88
+ paragraph.children.map((node) =>
89
+ node.type === "element"
90
+ ? { tag: node.tag, children: node.children }
91
+ : node,
92
+ ),
93
+ [
94
+ { type: "text", value: "See " },
95
+ {
96
+ type: "reference",
97
+ annotation: {
98
+ text: "Thread.ts#Thread.attach",
99
+ references: [
100
+ {
101
+ kind: "file",
102
+ path: "packages/server/src/agent/Thread.ts",
103
+ symbol: "Thread.attach",
104
+ },
105
+ ],
106
+ },
107
+ },
108
+ { type: "text", value: " here, not " },
109
+ { tag: "code", children: [{ type: "text", value: "[[path]]" }] },
110
+ { type: "text", value: " or " },
111
+ { tag: "a", children: [{ type: "text", value: "x [[a.ts]]" }] },
112
+ { type: "text", value: "." },
113
+ ],
114
+ );
115
+ assert.equal(
116
+ parseViewerDocument("See [[missing:new:1]].\n") instanceof Error,
117
+ true,
118
+ );
119
+ });
120
+
121
+ test("parseViewerDocument reads commit references and changes fences", () => {
122
+ const document = parseViewerDocument(
123
+ [
124
+ "```changes",
125
+ "a1b2c3d..e4f5a6b",
126
+ "```",
127
+ "",
128
+ "```changes",
129
+ "```",
130
+ "",
131
+ "See [[a1b2c3d:src/store.ts#L12-L30]] and [[src/store.ts#L3]].",
132
+ "",
133
+ ].join("\n"),
134
+ );
135
+ assert.equal(document instanceof Error, false);
136
+ if (document instanceof Error) return;
137
+ assert.deepEqual(document.changeRevs, ["a1b2c3d..e4f5a6b", "working"]);
138
+ assert.deepEqual(document.references, [
139
+ { kind: "file", rev: "a1b2c3d", path: "src/store.ts", start: 12, end: 30 },
140
+ { kind: "file", path: "src/store.ts", start: 3, end: 3 },
141
+ ]);
142
+ // A commit with a symbol, or a changes fence that is not a SHA, is an error.
143
+ assert.equal(
144
+ parseViewerDocument("See [[a1b2c3d:src/store.ts#save]].\n") instanceof
145
+ Error,
146
+ true,
147
+ );
148
+ assert.equal(
149
+ parseViewerDocument("```changes\nmain\n```\n") instanceof Error,
150
+ true,
151
+ );
152
+ // The removed patch fence fails instead of rendering as code.
153
+ assert.match(
154
+ String(
155
+ parseViewerDocument(
156
+ "```source-diff:store:src/store.ts\ndiff --git a/x b/x\n```\n",
157
+ ),
158
+ ),
159
+ /source-diff:store:src\/store\.ts" fences were removed/,
160
+ );
161
+ });
162
+
77
163
  test("parseViewerDocument fails on broken mermaid with file and diagram", () => {
78
164
  const source = brokenMermaidSpec();
79
165
  const parsed = parseViewerDocument(source, "broken.md");
@@ -138,6 +224,7 @@ test("startServer does not listen when mermaid is invalid", async () => {
138
224
  test("shareMarkdownFile does not call gh when mermaid is invalid", async () => {
139
225
  await withTempSpec(brokenMermaidSpec(), async (filePath) => {
140
226
  const shared = await shareMarkdownFile({
227
+ workspaceRoot: packageRoot,
141
228
  filePath,
142
229
  isPublic: false,
143
230
  });
@@ -1,7 +1,13 @@
1
1
  import * as errore from "errore";
2
- import { parsePatchFiles, type CodeViewDiffItem } from "@pierre/diffs";
3
2
  import { parseAST } from "md4x/napi";
4
3
  import type { ComarkElement, ComarkNode } from "md4x/napi";
4
+ import {
5
+ parseReference,
6
+ referenceLabel,
7
+ type SourceAnnotation,
8
+ type SourceReference,
9
+ } from "./annotations.js";
10
+ import { isRev, type Rev } from "./changes.js";
5
11
  import { DiffmapAnnotationError, DiffmapParseError } from "./errors.js";
6
12
  import { parseMermaidDiagram } from "./mermaid.js";
7
13
  import { parseFence, type Fence } from "./parseFence.js";
@@ -9,8 +15,10 @@ import { parseFence, type Fence } from "./parseFence.js";
9
15
  export type ViewerDocument = {
10
16
  nodes: ViewerNode[];
11
17
  headings: ViewerHeading[];
12
- sourceDiffs: CodeViewDiffItem[];
13
- hasReferences: boolean;
18
+ /** Every reference in stacks, diagrams, and prose. */
19
+ references: SourceReference[];
20
+ /** Revs of `changes` fences, in document order. */
21
+ changeRevs: Rev[];
14
22
  };
15
23
 
16
24
  export type ViewerHeading = {
@@ -19,7 +27,12 @@ export type ViewerHeading = {
19
27
  id: string;
20
28
  };
21
29
 
22
- export type ViewerNode = ViewerText | ViewerElement | ViewerHtml | ViewerView;
30
+ export type ViewerNode =
31
+ | ViewerText
32
+ | ViewerElement
33
+ | ViewerHtml
34
+ | ViewerView
35
+ | ViewerReference;
23
36
 
24
37
  export type ViewerText = {
25
38
  type: "text";
@@ -44,6 +57,12 @@ export type ViewerView = {
44
57
  fence: Fence;
45
58
  };
46
59
 
60
+ /** A `[[path#symbol]]` in prose. The annotation text is the short label. */
61
+ export type ViewerReference = {
62
+ type: "reference";
63
+ annotation: SourceAnnotation;
64
+ };
65
+
47
66
  export type ViewerElementAttrs = {
48
67
  id: string | undefined;
49
68
  href: string | undefined;
@@ -64,49 +83,32 @@ export function parseViewerDocument(source: string, path = "spec") {
64
83
  catch: (cause) => new DiffmapParseError({ cause }),
65
84
  });
66
85
  if (tree instanceof Error) return tree;
67
- const nodes = tree.nodes.flatMap(fromNode);
86
+ const nodes = tree.nodes.flatMap((node) => fromNode(node));
68
87
  const fences = collectFences(nodes);
69
- const sourceDiffs: CodeViewDiffItem[] = [];
88
+ const changeRevs: Rev[] = [];
70
89
  for (const fence of fences) {
71
- if (fence.kind !== "source-diff") continue;
72
- if (sourceDiffs.some((diff) => diff.id === fence.id)) {
73
- return new DiffmapAnnotationError({
74
- reason: `Duplicate source diff ID "${fence.id}"`,
75
- });
76
- }
77
- const patches = errore.try({
78
- try: () => parsePatchFiles(fence.source, undefined, true),
79
- catch: (cause) =>
80
- new DiffmapAnnotationError({
81
- reason: `Invalid patch "${fence.id}"`,
82
- cause,
83
- }),
84
- });
85
- if (patches instanceof Error) return patches;
86
- const files = patches.flatMap((patch) => patch.files);
87
- const fileDiff = files[0];
88
- if (
89
- files.length !== 1 ||
90
- fileDiff === undefined ||
91
- fileDiff.hunks.length === 0
92
- ) {
90
+ if (fence.kind === "code" && fence.lang.startsWith("source-diff")) {
93
91
  return new DiffmapAnnotationError({
94
- reason: `Source diff "${fence.id}" must contain one file with unified diff hunks`,
92
+ 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]]`,
95
93
  });
96
94
  }
97
- if (fileDiff.name !== fence.path) {
95
+ if (fence.kind !== "changes") continue;
96
+ if (!isRev(fence.rev)) {
98
97
  return new DiffmapAnnotationError({
99
- reason: `Source diff "${fence.id}" path does not match its patch (${fileDiff.name})`,
98
+ reason: `Invalid changes "${fence.rev}". Use a commit SHA, a SHA range (a1b2c3d..e4f5a6b), or leave it empty for uncommitted changes`,
100
99
  });
101
100
  }
102
- sourceDiffs.push({ id: fence.id, type: "diff", fileDiff });
101
+ changeRevs.push(fence.rev);
103
102
  }
104
- const annotations = fences.flatMap((fence) => {
105
- if (fence.kind === "callstack") return fence.lines;
106
- if (fence.kind === "mermaid")
107
- return fence.annotations.map((link) => link.annotation);
108
- return [];
109
- });
103
+ const annotations = [
104
+ ...fences.flatMap((fence) => {
105
+ if (fence.kind === "callstack") return fence.lines;
106
+ if (fence.kind === "mermaid")
107
+ return fence.annotations.map((link) => link.annotation);
108
+ return [];
109
+ }),
110
+ ...collectReferences(nodes),
111
+ ];
110
112
  const mermaidFences = fences.flatMap((fence) =>
111
113
  fence.kind === "mermaid" ? [fence] : [],
112
114
  );
@@ -136,44 +138,24 @@ export function parseViewerDocument(source: string, path = "spec") {
136
138
  for (const line of annotations) {
137
139
  if (line.text.includes("[[")) {
138
140
  return new DiffmapAnnotationError({
139
- reason: `Invalid reference in "${line.text}". Use [[id:old|new:start-end]] or [[path#symbol]]`,
141
+ reason: `Invalid reference in "${line.text}". Use [[path]], [[path#symbol]], [[path#L12-L30]], or [[<commit>:path#L12-L30]]; source-diff patch references were removed`,
140
142
  });
141
143
  }
142
144
  for (const ref of line.references) {
143
- if (ref.kind === "file") {
144
- if (
145
- ref.start !== undefined &&
146
- (!Number.isSafeInteger(ref.start) ||
147
- !Number.isSafeInteger(ref.end) ||
148
- ref.end! < ref.start)
149
- ) {
150
- return new DiffmapAnnotationError({
151
- reason: `Invalid file range in "${ref.path}"`,
152
- });
153
- }
154
- continue;
155
- }
156
- const diff = sourceDiffs.find((item) => item.id === ref.id);
157
- if (diff === undefined) {
145
+ if (ref.kind !== "file") continue;
146
+ if (ref.rev !== undefined && ref.symbol !== undefined) {
158
147
  return new DiffmapAnnotationError({
159
- reason: `Unknown source diff "${ref.id}"`,
148
+ reason: `Symbol references read current files. Use a line range with a commit: [[${ref.rev}:${ref.path}#L12-L30]]`,
160
149
  });
161
150
  }
162
- const inHunk = diff.fileDiff.hunks.some((hunk) => {
163
- const start =
164
- ref.side === "old" ? hunk.deletionStart : hunk.additionStart;
165
- const count =
166
- ref.side === "old" ? hunk.deletionCount : hunk.additionCount;
167
- return ref.start >= start && ref.end < start + count;
168
- });
169
151
  if (
170
- !Number.isSafeInteger(ref.start) ||
171
- !Number.isSafeInteger(ref.end) ||
172
- ref.end < ref.start ||
173
- !inHunk
152
+ ref.start !== undefined &&
153
+ (!Number.isSafeInteger(ref.start) ||
154
+ !Number.isSafeInteger(ref.end) ||
155
+ ref.end! < ref.start)
174
156
  ) {
175
157
  return new DiffmapAnnotationError({
176
- reason: `Range ${ref.side}:${ref.start}-${ref.end} is not in a hunk of "${ref.id}"`,
158
+ reason: `Invalid file range in "${ref.path}"`,
177
159
  });
178
160
  }
179
161
  }
@@ -181,10 +163,8 @@ export function parseViewerDocument(source: string, path = "spec") {
181
163
  return {
182
164
  nodes,
183
165
  headings: collectHeadings(nodes),
184
- sourceDiffs,
185
- hasReferences: annotations.some(
186
- (annotation) => annotation.references.length > 0,
187
- ),
166
+ references: annotations.flatMap((annotation) => annotation.references),
167
+ changeRevs,
188
168
  };
189
169
  }
190
170
 
@@ -196,6 +176,14 @@ function collectFences(nodes: ViewerNode[]): Fence[] {
196
176
  });
197
177
  }
198
178
 
179
+ function collectReferences(nodes: ViewerNode[]): SourceAnnotation[] {
180
+ return nodes.flatMap((node) => {
181
+ if (node.type === "reference") return [node.annotation];
182
+ if (node.type === "element") return collectReferences(node.children);
183
+ return [];
184
+ });
185
+ }
186
+
199
187
  function mermaidWhere(source: string, index: number) {
200
188
  const line = mermaidFenceLine(source, index);
201
189
  if (line === undefined) return `diagram ${index}`;
@@ -283,31 +271,67 @@ function uniqueHeadingId(text: string, usedIds: Set<string>) {
283
271
 
284
272
  function viewerNodeText(node: ViewerNode): string {
285
273
  if (node.type === "text") return node.value;
274
+ if (node.type === "reference") return node.annotation.text;
286
275
  if (node.type !== "element") return "";
287
276
  return node.children.map(viewerNodeText).join("");
288
277
  }
289
278
 
290
- function fromNode(node: ComarkNode): ViewerNode[] {
279
+ function fromNode(node: ComarkNode, literal = false): ViewerNode[] {
291
280
  if (!Array.isArray(node)) {
292
- return [{ type: "text", value: node }];
281
+ return literal ? [{ type: "text", value: node }] : proseText(node);
293
282
  }
294
- const converted = fromElement(node);
283
+ const converted = fromElement(node, literal);
295
284
  if (converted === undefined) return [];
296
285
  if (Array.isArray(converted)) return converted;
297
286
  return [converted];
298
287
  }
299
288
 
289
+ /** Splits `[[path#symbol]]` references out of prose text. */
290
+ function proseText(value: string): ViewerNode[] {
291
+ const nodes: ViewerNode[] = [];
292
+ let last = 0;
293
+ for (const match of value.matchAll(/\[\[([^[\]\n]+)\]\]/g)) {
294
+ const reference = parseReference(match[1]!);
295
+ // The removed `[[id:old|new:N]]` patch syntax must fail, not render as text.
296
+ const removed =
297
+ reference === undefined && /^[\w-]+:(old|new):\d/.test(match[1]!);
298
+ if (reference === undefined && !removed) continue;
299
+ if (match.index > last)
300
+ nodes.push({ type: "text", value: value.slice(last, match.index) });
301
+ nodes.push({
302
+ type: "reference",
303
+ annotation:
304
+ reference === undefined
305
+ ? { text: match[0], references: [] }
306
+ : { text: referenceLabel(reference, true), references: [reference] },
307
+ });
308
+ last = match.index + match[0].length;
309
+ }
310
+ if (last < value.length)
311
+ nodes.push({ type: "text", value: value.slice(last) });
312
+ return nodes;
313
+ }
314
+
300
315
  function fromElement(
301
316
  element: ComarkElement,
317
+ literal: boolean,
302
318
  ): ViewerNode | ViewerNode[] | undefined {
303
319
  const [tag, , ...children] = element;
304
320
  if (tag === null) return undefined;
305
- if (tag === "template") return children.flatMap(fromNode);
321
+ // Code spans show the syntax literally; links cannot nest a button.
322
+ const literalChildren = literal || tag === "code" || tag === "a";
323
+ if (tag === "template")
324
+ return children.flatMap((child) => fromNode(child, literalChildren));
306
325
  if (tag === "pre") {
307
326
  return fenceView(stringAttr(element, "language"), comarkText(children));
308
327
  }
309
328
  if (tag === "mermaid") return fenceView("mermaid", comarkText(children));
310
329
  if (tag === "callstack") return fenceView("callstack", comarkText(children));
330
+ if (tag === "changes")
331
+ return fenceView(
332
+ "changes",
333
+ stringAttr(element, "rev") ?? comarkText(children),
334
+ );
311
335
  if (tag === "diff") {
312
336
  const path = stringAttr(element, "path");
313
337
  const lang = path === undefined ? "diff" : `diff:${path}`;
@@ -315,7 +339,9 @@ function fromElement(
315
339
  }
316
340
  if (tag === "file") return fileView(element, children);
317
341
  if (tag === "html") return htmlView(element, children);
318
- const convertedChildren = children.flatMap(fromNode);
342
+ const convertedChildren = children.flatMap((child) =>
343
+ fromNode(child, literalChildren),
344
+ );
319
345
  return {
320
346
  type: "element",
321
347
  tag,
@@ -352,7 +378,7 @@ function fileView(
352
378
  children: ComarkNode[],
353
379
  ): ViewerView | ViewerNode[] {
354
380
  const path = stringAttr(element, "path");
355
- if (path === undefined) return children.flatMap(fromNode);
381
+ if (path === undefined) return children.flatMap((child) => fromNode(child));
356
382
  const start = lineAttr(element, "start");
357
383
  const end = lineAttr(element, "end");
358
384
  // MDC ::file with no range loads the whole file.