@tanishqkancharla/diffmap 0.1.5 → 0.3.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 (42) hide show
  1. package/README.md +64 -57
  2. package/index.html +18 -0
  3. package/package.json +7 -5
  4. package/src/annotations.ts +36 -29
  5. package/src/changes.ts +235 -0
  6. package/src/cli.ts +1 -0
  7. package/src/errors.ts +5 -0
  8. package/src/gist/GistApp.tsx +38 -17
  9. package/src/gist/fetchGist.ts +47 -0
  10. package/src/git.test.ts +402 -0
  11. package/src/git.ts +498 -0
  12. package/src/github/GitHubApp.tsx +39 -22
  13. package/src/github/fetchRepo.test.ts +59 -1
  14. package/src/github/fetchRepo.ts +99 -43
  15. package/src/github/patch.test.ts +18 -9
  16. package/src/github/patch.ts +30 -19
  17. package/src/host/HostHome.tsx +20 -7
  18. package/src/host/files.ts +5 -1
  19. package/src/host/home.md +17 -55
  20. package/src/host/home.test.ts +4 -3
  21. package/src/index.ts +1 -0
  22. package/src/parseFence.ts +6 -6
  23. package/src/parseViewer.test.ts +140 -0
  24. package/src/parseViewer.ts +104 -78
  25. package/src/serve.ts +34 -1
  26. package/src/share.ts +62 -1
  27. package/src/viewer/CallStackDiff.tsx +15 -3
  28. package/src/viewer/ChangesCard.tsx +267 -0
  29. package/src/viewer/ComarkView.tsx +83 -15
  30. package/src/viewer/Fence.tsx +9 -1
  31. package/src/viewer/MermaidBlock.tsx +99 -11
  32. package/src/viewer/RangePicker.tsx +526 -0
  33. package/src/viewer/SourceDiffPanel.tsx +254 -116
  34. package/src/viewer/TableOfContents.tsx +177 -93
  35. package/src/viewer/ViewerApp.tsx +34 -114
  36. package/src/viewer/changesSource.ts +91 -0
  37. package/src/viewer/sourceHeader.test.ts +2 -87
  38. package/src/viewer/sourceHeader.ts +4 -63
  39. package/src/viewer/sourceNavigation.ts +0 -43
  40. package/vite.config.ts +1 -0
  41. package/vite.gist.config.ts +57 -0
  42. package/src/viewer/TocButton.tsx +0 -27
@@ -4,8 +4,8 @@
4
4
  * fetch `ref=main` / latest for spec markdown, [[path]] targets, excerpts, or
5
5
  * hunks. Private repos are out of scope (GitHub 404).
6
6
  */
7
- import type { CodeViewDiffItem } from "@pierre/diffs";
8
- import { pullFilesToSourceDiffs, type GitHubPullFile } from "./patch.ts";
7
+ import { WORKING, type Changes, type Rev } from "../changes.ts";
8
+ import { githubChangedFiles, type GitHubFile } from "./patch.ts";
9
9
 
10
10
  const GITHUB_API = "https://api.github.com";
11
11
  const GITHUB_JSON = {
@@ -21,7 +21,8 @@ export type PinnedSpec = {
21
21
  htmlUrl: string;
22
22
  pullNumber?: number;
23
23
  pullUrl?: string;
24
- pullDiffs: CodeViewDiffItem[];
24
+ /** `base..head` of the pull request, for the Diff panel's default view. */
25
+ pullRev?: Rev;
25
26
  };
26
27
 
27
28
  export type GitHubSpecResult =
@@ -33,6 +34,20 @@ export type GitHubSpecResult =
33
34
  type PullApi = {
34
35
  html_url?: string;
35
36
  head?: { sha?: string };
37
+ base?: { sha?: string };
38
+ };
39
+
40
+ type CommitApi = {
41
+ sha?: string;
42
+ html_url?: string;
43
+ commit?: { message?: string };
44
+ files?: GitHubFile[];
45
+ };
46
+
47
+ type CompareApi = {
48
+ html_url?: string;
49
+ total_commits?: number;
50
+ files?: GitHubFile[];
36
51
  };
37
52
 
38
53
  type ContentsApi = {
@@ -56,6 +71,7 @@ export async function loadPinnedSpec(input: {
56
71
  let sha = input.sha;
57
72
  let pullNumber = input.pull;
58
73
  let pullUrl: string | undefined;
74
+ let pullRev: Rev | undefined;
59
75
  if (sha === undefined) {
60
76
  if (pullNumber === undefined) {
61
77
  return { status: "error", message: "Need a pull number or commit SHA." };
@@ -69,6 +85,10 @@ export async function loadPinnedSpec(input: {
69
85
  if (resolved.status !== "ok") return resolved;
70
86
  sha = resolved.sha;
71
87
  pullUrl = resolved.htmlUrl;
88
+ pullRev =
89
+ resolved.baseSha === undefined
90
+ ? undefined
91
+ : `${resolved.baseSha}..${sha}`;
72
92
  }
73
93
 
74
94
  const blob = await fetchPinnedBlob(
@@ -82,24 +102,6 @@ export async function loadPinnedSpec(input: {
82
102
  return { status: "error", message: blob.message };
83
103
  }
84
104
 
85
- let pullDiffs: CodeViewDiffItem[] = [];
86
- if (pullNumber !== undefined) {
87
- const files = await fetchPullFiles(
88
- input.owner,
89
- input.repo,
90
- pullNumber,
91
- input.signal,
92
- );
93
- if (files instanceof Error) {
94
- if (files.message === "forbidden") return { status: "forbidden" };
95
- if (files.message !== "not-found") {
96
- return { status: "error", message: files.message };
97
- }
98
- } else {
99
- pullDiffs = pullFilesToSourceDiffs(files);
100
- }
101
- }
102
-
103
105
  return {
104
106
  status: "ok",
105
107
  spec: {
@@ -111,7 +113,7 @@ export async function loadPinnedSpec(input: {
111
113
  htmlUrl: `https://github.com/${input.owner}/${input.repo}/blob/${sha}/${input.path}`,
112
114
  pullNumber,
113
115
  pullUrl,
114
- pullDiffs,
116
+ pullRev,
115
117
  },
116
118
  };
117
119
  }
@@ -161,7 +163,7 @@ async function resolvePullHead(
161
163
  pull: number,
162
164
  signal?: AbortSignal,
163
165
  ): Promise<
164
- | { status: "ok"; sha: string; htmlUrl: string }
166
+ | { status: "ok"; sha: string; baseSha: string | undefined; htmlUrl: string }
165
167
  | { status: "not-found" }
166
168
  | { status: "forbidden" }
167
169
  | { status: "error"; message: string }
@@ -181,9 +183,11 @@ async function resolvePullHead(
181
183
  message: "GitHub did not return a pull head SHA.",
182
184
  };
183
185
  }
186
+ const baseSha = payload?.base?.sha;
184
187
  return {
185
188
  status: "ok",
186
189
  sha,
190
+ baseSha: typeof baseSha === "string" ? baseSha : undefined,
187
191
  htmlUrl:
188
192
  typeof payload?.html_url === "string"
189
193
  ? payload.html_url
@@ -191,31 +195,83 @@ async function resolvePullHead(
191
195
  };
192
196
  }
193
197
 
194
- async function fetchPullFiles(
195
- owner: string,
196
- repo: string,
197
- pull: number,
198
- signal?: AbortSignal,
199
- ): Promise<GitHubPullFile[] | Error> {
200
- const files: GitHubPullFile[] = [];
201
- for (let page = 1; page <= 10; page++) {
202
- const response = await githubFetch(
203
- `/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/pulls/${String(pull)}/files?per_page=100&page=${String(page)}`,
204
- signal,
198
+ const changesCache = new Map<string, Promise<Changes | Error>>();
199
+
200
+ /** Commit or compare changes from the GitHub API. Patches only, no full contents. */
201
+ export function fetchGitHubChanges(
202
+ repo: { owner: string; repo: string },
203
+ rev: Rev,
204
+ ): Promise<Changes | Error> {
205
+ if (rev === WORKING)
206
+ return Promise.resolve(
207
+ new Error("Uncommitted changes are only available in a local viewer."),
205
208
  );
206
- if (response.status === "not-found") return new Error("not-found");
207
- if (response.status === "forbidden") return new Error("forbidden");
209
+ const key = `${repo.owner}/${repo.repo}@${rev}`;
210
+ const pending = changesCache.get(key);
211
+ if (pending !== undefined) return pending;
212
+ const request = readGitHubChanges(repo, rev);
213
+ changesCache.set(key, request);
214
+ return request;
215
+ }
216
+
217
+ /** GitHub pages a commit's files up to 3000; a compare lists only its first 300. */
218
+ const COMMIT_FILES_MAX = 3000;
219
+ const COMPARE_FILES_MAX = 300;
220
+
221
+ async function readGitHubChanges(
222
+ repo: { owner: string; repo: string },
223
+ rev: Rev,
224
+ ): Promise<Changes | Error> {
225
+ const base = `/repos/${encodeURIComponent(repo.owner)}/${encodeURIComponent(repo.repo)}`;
226
+ const [from, to] = rev.split("..");
227
+ const files: GitHubFile[] = [];
228
+ let first: (CommitApi & CompareApi) | undefined;
229
+ let next: string | undefined =
230
+ to === undefined
231
+ ? `${base}/commits/${from!}`
232
+ : `${base}/compare/${from!}...${to}`;
233
+ while (next !== undefined) {
234
+ const response = await githubFetch(next);
235
+ if (response.status === "not-found")
236
+ return new Error(
237
+ `GitHub has no commit ${rev} in ${repo.owner}/${repo.repo}.`,
238
+ );
239
+ if (response.status === "forbidden")
240
+ return new Error("GitHub rate-limited this request. Wait and refresh.");
208
241
  if (response.status === "error") return new Error(response.message);
209
242
  const payload = (await response.body.json().catch(() => undefined)) as
210
- | GitHubPullFile[]
243
+ | (CommitApi & CompareApi)
211
244
  | undefined;
212
- if (!Array.isArray(payload)) {
213
- return new Error("GitHub returned invalid pull files.");
214
- }
215
- files.push(...payload);
216
- if (payload.length < 100) break;
245
+ if (payload?.files === undefined)
246
+ return new Error("GitHub returned invalid changes.");
247
+ first ??= payload;
248
+ files.push(...payload.files);
249
+ // Compare pages list commits, not more files.
250
+ next = to === undefined ? nextPage(response.body) : undefined;
217
251
  }
218
- return files;
252
+ const subject = first?.commit?.message?.split("\n", 1)[0] ?? "";
253
+ const truncated =
254
+ files.length >= (to === undefined ? COMMIT_FILES_MAX : COMPARE_FILES_MAX);
255
+ return {
256
+ rev,
257
+ title:
258
+ to === undefined
259
+ ? subject
260
+ : `${from!.slice(0, 7)}..${to.slice(0, 7)} · ${String(first?.total_commits ?? 0)} commits`,
261
+ ...(first?.html_url === undefined ? {} : { url: first.html_url }),
262
+ ...(truncated ? { truncated } : {}),
263
+ files: githubChangedFiles(files),
264
+ };
265
+ }
266
+
267
+ /** The API path of the `rel="next"` page in a GitHub `Link` header. */
268
+ function nextPage(response: Response) {
269
+ const link = /<([^>]+)>;\s*rel="next"/.exec(
270
+ response.headers.get("link") ?? "",
271
+ );
272
+ if (link === null) return undefined;
273
+ const url = new URL(link[1]!);
274
+ return `${url.pathname}${url.search}`;
219
275
  }
220
276
 
221
277
  async function readContents(
@@ -1,10 +1,11 @@
1
1
  import assert from "node:assert/strict";
2
2
  import test from "node:test";
3
3
  import { parsePatchFiles } from "@pierre/diffs";
4
- import { pullFilesToSourceDiffs, wrapGitHubPullPatch } from "./patch.ts";
4
+ import { changedFileItem } from "../changes.ts";
5
+ import { githubChangedFiles, wrapGitHubPatch } from "./patch.ts";
5
6
 
6
- test("wrapGitHubPullPatch builds a unified diff Pierre can parse", () => {
7
- const wrapped = wrapGitHubPullPatch({
7
+ test("wrapGitHubPatch builds a unified diff Pierre can parse", () => {
8
+ const wrapped = wrapGitHubPatch({
8
9
  filename: "src/cli.ts",
9
10
  status: "modified",
10
11
  patch: ["@@ -1,3 +1,4 @@", " line", "+added", " line", " line"].join("\n"),
@@ -15,16 +16,24 @@ test("wrapGitHubPullPatch builds a unified diff Pierre can parse", () => {
15
16
  assert.ok((patches[0]?.files[0]?.hunks.length ?? 0) > 0);
16
17
  });
17
18
 
18
- test("pullFilesToSourceDiffs skips files without a patch", () => {
19
- const diffs = pullFilesToSourceDiffs([
20
- { filename: "icon.png", status: "added" },
19
+ test("githubChangedFiles keeps files without a patch as binary, with no diff item", () => {
20
+ const files = githubChangedFiles([
21
+ { filename: "icon.png", status: "added", additions: 0, deletions: 0 },
21
22
  {
22
23
  filename: "src/cli.ts",
23
24
  status: "modified",
25
+ additions: 1,
26
+ deletions: 0,
24
27
  patch: ["@@ -1,2 +1,3 @@", " a", "+b", " c"].join("\n"),
25
28
  },
26
29
  ]);
27
- assert.equal(diffs.length, 1);
28
- assert.equal(diffs[0]?.id, "pr:src/cli.ts");
29
- assert.equal(diffs[0]?.fileDiff.name, "src/cli.ts");
30
+ assert.deepEqual(
31
+ files.map((file) => [file.path, file.binary, file.additions]),
32
+ [
33
+ ["icon.png", true, 0],
34
+ ["src/cli.ts", false, 1],
35
+ ],
36
+ );
37
+ assert.equal(changedFileItem(files[0]!), undefined);
38
+ assert.equal(changedFileItem(files[1]!)?.fileDiff.name, "src/cli.ts");
30
39
  });
@@ -1,14 +1,17 @@
1
- import { parsePatchFiles, type CodeViewDiffItem } from "@pierre/diffs";
1
+ import type { ChangedFile } from "../changes.ts";
2
2
 
3
- export type GitHubPullFile = {
3
+ /** A file from GitHub's commit, compare, or pull-files APIs. */
4
+ export type GitHubFile = {
4
5
  filename: string;
5
6
  previous_filename?: string;
6
7
  status: string;
8
+ additions?: number;
9
+ deletions?: number;
7
10
  patch?: string;
8
11
  };
9
12
 
10
- /** Wrap a GitHub pull-files `patch` (hunks only) as a unified diff Pierre can parse. */
11
- export function wrapGitHubPullPatch(file: GitHubPullFile) {
13
+ /** Wrap a GitHub file `patch` (hunks only) as a unified diff Pierre can parse. */
14
+ export function wrapGitHubPatch(file: GitHubFile) {
12
15
  if (file.patch === undefined || file.patch.length === 0) return undefined;
13
16
  const oldPath = file.previous_filename ?? file.filename;
14
17
  const newPath = file.filename;
@@ -22,19 +25,27 @@ export function wrapGitHubPullPatch(file: GitHubPullFile) {
22
25
  ].join("\n");
23
26
  }
24
27
 
25
- export function pullFilesToSourceDiffs(files: GitHubPullFile[]) {
26
- const diffs: CodeViewDiffItem[] = [];
27
- for (const file of files) {
28
- const unified = wrapGitHubPullPatch(file);
29
- if (unified === undefined) continue;
30
- const patches = parsePatchFiles(unified, undefined, false);
31
- const fileDiff = patches[0]?.files[0];
32
- if (fileDiff === undefined || fileDiff.hunks.length === 0) continue;
33
- diffs.push({
34
- id: `pr:${file.filename}`,
35
- type: "diff",
36
- fileDiff,
37
- });
38
- }
39
- return diffs;
28
+ export function githubChangedFiles(files: GitHubFile[]): ChangedFile[] {
29
+ return files.map((file) => {
30
+ const patch = wrapGitHubPatch(file);
31
+ return {
32
+ path: file.filename,
33
+ ...(file.previous_filename === undefined
34
+ ? {}
35
+ : { prevPath: file.previous_filename }),
36
+ status:
37
+ file.status === "added"
38
+ ? "added"
39
+ : file.status === "removed"
40
+ ? "deleted"
41
+ : file.status === "renamed"
42
+ ? "renamed"
43
+ : "modified",
44
+ additions: file.additions ?? 0,
45
+ deletions: file.deletions ?? 0,
46
+ // GitHub omits the patch for binary and very large files.
47
+ binary: patch === undefined,
48
+ ...(patch === undefined ? {} : { patch }),
49
+ };
50
+ });
40
51
  }
@@ -5,6 +5,11 @@ import { parseViewerDocument } from "../parseViewer.ts";
5
5
  import type { ViewerDocument } from "../parseViewer.ts";
6
6
  import { ViewerApp } from "../viewer/ViewerApp.tsx";
7
7
  import { GistLoading, GistStatus } from "../gist/GistStatus.tsx";
8
+ import { fetchGitHubChanges } from "../github/fetchRepo.ts";
9
+ import {
10
+ ChangesLoaderContext,
11
+ type ChangesLoader,
12
+ } from "../viewer/changesSource.ts";
8
13
  import { HostFilesContext } from "./files.ts";
9
14
  import homeSource from "./home.md?raw";
10
15
  import cliSource from "../cli.ts?raw";
@@ -26,6 +31,10 @@ const hostFiles = {
26
31
  "src/github/route.ts": githubRouteSource,
27
32
  };
28
33
 
34
+ /** The homepage walks through diffmap's own commits, read from GitHub. */
35
+ const loadHostChanges: ChangesLoader = (rev) =>
36
+ fetchGitHubChanges({ owner: "tanishqkancharla", repo: "diffmap" }, rev);
37
+
29
38
  export function HostHome() {
30
39
  const [view, setView] = useState<
31
40
  | { kind: "loading" }
@@ -61,13 +70,17 @@ export function HostHome() {
61
70
 
62
71
  return (
63
72
  <HostFilesContext.Provider value={hostFiles}>
64
- <ViewerApp
65
- document={view.document}
66
- mode="host"
67
- headerActions={
68
- <Link href="https://github.com/tanishqkancharla/diffmap">GitHub</Link>
69
- }
70
- />
73
+ <ChangesLoaderContext.Provider value={loadHostChanges}>
74
+ <ViewerApp
75
+ document={view.document}
76
+ mode="host"
77
+ headerActions={
78
+ <Link href="https://github.com/tanishqkancharla/diffmap">
79
+ GitHub
80
+ </Link>
81
+ }
82
+ />
83
+ </ChangesLoaderContext.Provider>
71
84
  </HostFilesContext.Provider>
72
85
  );
73
86
  }
package/src/host/files.ts CHANGED
@@ -1,6 +1,10 @@
1
1
  import { createContext, useContext } from "react";
2
2
 
3
- /** Bundled files for the hosted homepage. `[[path]]` reads these, not GitHub. */
3
+ /**
4
+ * Files a hosted viewer has without a server: the homepage's bundled sources,
5
+ * or the files `diffmap share` saved in a gist. `[[path]]` and file excerpts
6
+ * read these, not GitHub.
7
+ */
4
8
  export type HostFiles = Record<string, string>;
5
9
 
6
10
  export const HostFilesContext = createContext<HostFiles | undefined>(undefined);
package/src/host/home.md CHANGED
@@ -7,7 +7,7 @@ TOC on the left, article here, Diff on the right. Click a stack row or a mermaid
7
7
  ## Install the skill
8
8
 
9
9
  ```sh
10
- npx skills add tanishqkancharla/diffmap --skill generate-spec
10
+ npx skills add tanishqkancharla/diffmap --skill generate-diffmap
11
11
  ```
12
12
 
13
13
  That keeps a living spec in `specs/<name>.md`. Same file for the life of the work: what’s already in the tree, and what’s still planned. Don’t start a second walkthrough later.
@@ -42,13 +42,13 @@ Pinned commit:
42
42
 
43
43
  `https://diffmap.dev/<owner>/<repo>/commit/<sha>/specs/<name>.md`
44
44
 
45
- The pin is one SHA. The markdown and every `[[path]]`, mermaid `%% ref`, excerpt, and hunk load from that commit, not `main`. A PR number only chooses the SHA (head at load, then pin). Refresh stays on the commit URL.
45
+ The pin is one SHA. The markdown and every `[[path]]`, mermaid `%% ref`, and excerpt load from that commit, not `main`. Commit references load their diffs from the GitHub API. A PR number only chooses the SHA (head at load, then pin). Refresh stays on the commit URL.
46
46
 
47
47
  ## What the fences are
48
48
 
49
49
  ### System flow
50
50
 
51
- Click a node. `%% ref` is a mermaid comment; `[[path]]` and `[[path#symbol]]` are the same references as on a stack row.
51
+ Click a node. `%% ref` is a mermaid comment; `[[path]]`, `[[path#symbol]]`, and `[[<sha>:path#L12-L30]]` are the same references as on a stack row.
52
52
 
53
53
  ```mermaid
54
54
  flowchart LR
@@ -59,7 +59,7 @@ flowchart LR
59
59
  %% ref node:Serve [[src/cli.ts]]
60
60
  %% ref node:Share [[src/share.ts#shareMarkdownFile]]
61
61
  %% ref node:Pin [[src/github/route.ts]]
62
- %% ref edge:0 [[cli:new:50]]
62
+ %% ref edge:0 [[1257fe8:src/cli.ts#L49-L50]]
63
63
  ```
64
64
 
65
65
  ````md
@@ -72,68 +72,30 @@ flowchart LR
72
72
 
73
73
  ### Call stacks
74
74
 
75
- `-` is current, `+` is proposed. After a phase lands, retarget `[[path]]` to a hunk: `[[id:old:…]]` / `[[id:new:…]]`.
75
+ `-` is current, `+` is proposed. After a phase lands, commit it and retarget `[[path]]` to the commit: `[[<sha>:path#L12-L30]]`. Line numbers are the file after the commit.
76
76
 
77
77
  ```callstack
78
78
  ViewerApp
79
- -└── CloseServerButton # every hosted page tried to stop a server [[close:old:217]]
80
- +└── CloseServerButton # local mode only [[close:new:217-224]]
79
+ -└── DoneButton # a loud Done control
80
+ +└── CloseServerButton # quiet, local mode only [[dae6b98:src/viewer/ViewerApp.tsx#L219-L227]] [[dae6b98:src/viewer/CloseServerButton.tsx#L8-L16]]
81
81
  └── DiffButton
82
82
  ```
83
83
 
84
84
  ```callstack
85
85
  shareMarkdownFile [[src/share.ts#shareMarkdownFile]]
86
- -└── print gist URL [[share:old:62]]
87
- +└── gistViewerUrl # https://diffmap.dev/g/<id> [[share:new:62]]
86
+ -└── print the gist URL
87
+ +└── gistViewerUrl # https://diffmap.dev/g/<id> [[d14ae1a:src/share.ts#L55-L59]]
88
88
  ```
89
89
 
90
- ### Source patches
90
+ ### Changes
91
91
 
92
- Define each id once. Copy the real `git diff` (`diff --git`, `---`, `+++`, `@@`). Ranges have to sit inside an included hunk.
92
+ A `changes` fence names a commit. It renders a card with every file that commit changed. **Review** opens the Diff panel on all of them, with a sticky header per file; a row jumps to its file. An empty body means uncommitted changes.
93
93
 
94
- ```source-diff:cli:src/cli.ts
95
- diff --git a/src/cli.ts b/src/cli.ts
96
- --- a/src/cli.ts
97
- +++ b/src/cli.ts
98
- @@ -49,4 +49,4 @@
99
- const cli = Cli.create("diffmap", {
100
- - description: "Serve a walkthrough",
101
- + description: "Serve a spec locally, or share it as a gist",
102
- version: "0.1.3",
103
- args: serveArgs,
94
+ ```changes
95
+ dae6b98
104
96
  ```
105
97
 
106
- ```source-diff:share:src/share.ts
107
- diff --git a/src/share.ts b/src/share.ts
108
- --- a/src/share.ts
109
- +++ b/src/share.ts
110
- @@ -59,6 +59,6 @@
111
- return {
112
- gistId,
113
- gistUrl,
114
- - viewerUrl: gistUrl,
115
- + viewerUrl: gistViewerUrl(gistId, undefined, process.env.DIFFMAP_ORIGIN),
116
- };
117
- }
118
- ```
119
-
120
- ```source-diff:close:src/viewer/ViewerApp.tsx
121
- diff --git a/src/viewer/ViewerApp.tsx b/src/viewer/ViewerApp.tsx
122
- --- a/src/viewer/ViewerApp.tsx
123
- +++ b/src/viewer/ViewerApp.tsx
124
- @@ -216,3 +216,10 @@
125
- {props.headerActions}
126
- - <CloseServerButton onClick={() => setShutDown(true)} />
127
- + {props.mode === "local" && (
128
- + <CloseServerButton
129
- + onClick={() => {
130
- + setShutDown(true);
131
- + void closeViewer();
132
- + }}
133
- + />
134
- + )}
135
- </div>
136
- ```
98
+ Commit references must name a file the commit changed, and the range must fit the file after the commit. `serve` and `share` check this at startup.
137
99
 
138
100
  ### File excerpts
139
101
 
@@ -167,12 +129,12 @@ Local `serve` trusts `html` in the file you pointed at. This host does not. Gist
167
129
 
168
130
  ## How a spec stays alive
169
131
 
170
- Write `specs/<name>.md`. Serve it while you plan. When the plan changes, edit this file. When a phase lands, paste the patch and retarget the links so the Diff panel opens the change, not just the file.
132
+ Write `specs/<name>.md`. Serve it while you plan. When the plan changes, edit this file. When a phase lands, commit it, add a `changes` fence, and retarget the links to the commit so the Diff panel opens the change, not just the file.
171
133
 
172
134
  ### Phase 1: Spec and serve
173
135
 
174
136
  ```callstack
175
- generate-spec
137
+ generate-diffmap
176
138
  └── specs/name.md
177
139
  └── diffmap serve [[src/cli.ts]]
178
140
  ```
@@ -186,7 +148,7 @@ Write `specs/<name>.md`. Serve it while you plan. When the plan changes, edit th
186
148
  ```callstack
187
149
  specs/name.md
188
150
  -└── localhost only
189
- +├── diffmap share [[share:new:62]]
151
+ +├── diffmap share [[d14ae1a:src/share.ts#L55-L59]]
190
152
  +└── GitHub PR URL [[src/github/route.ts]]
191
153
  ```
192
154
 
@@ -30,7 +30,7 @@ test("homepage markdown is a real viewer document", async () => {
30
30
  const kinds = new Set(fences.map((fence) => fence.kind));
31
31
  assert.equal(kinds.has("mermaid"), true);
32
32
  assert.equal(kinds.has("callstack"), true);
33
- assert.equal(kinds.has("source-diff"), true);
33
+ assert.equal(kinds.has("changes"), true);
34
34
  assert.equal(kinds.has("file"), true);
35
35
  assert.equal(kinds.has("html"), true);
36
36
 
@@ -63,8 +63,9 @@ test("homepage markdown is a real viewer document", async () => {
63
63
  assert.equal(levels.has(1), true);
64
64
  assert.equal(levels.has(2), true);
65
65
  assert.equal(levels.has(3), true);
66
- assert.ok(document.sourceDiffs.length >= 3);
67
- assert.equal(document.hasReferences, true);
66
+ assert.ok(
67
+ document.references.filter((ref) => ref.rev !== undefined).length >= 3,
68
+ );
68
69
 
69
70
  const excerpt = fences.find(
70
71
  (fence) => fence.kind === "file" && fence.path === "src/cli.ts",
package/src/index.ts CHANGED
@@ -13,6 +13,7 @@ export type {
13
13
  ViewerHeading,
14
14
  ViewerHtml,
15
15
  ViewerNode,
16
+ ViewerReference,
16
17
  ViewerText,
17
18
  ViewerView,
18
19
  } from "./parseViewer.js";
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
  }