@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
@@ -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
  }
@@ -0,0 +1,86 @@
1
+ import { useEffect, useState } from "react";
2
+ import { Link, P } from "maui";
3
+ import { init } from "md4x/standalone";
4
+ import { parseViewerDocument } from "../parseViewer.ts";
5
+ import type { ViewerDocument } from "../parseViewer.ts";
6
+ import { ViewerApp } from "../viewer/ViewerApp.tsx";
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";
13
+ import { HostFilesContext } from "./files.ts";
14
+ import homeSource from "./home.md?raw";
15
+ import cliSource from "../cli.ts?raw";
16
+ import shareSource from "../share.ts?raw";
17
+ import parseViewerSource from "../parseViewer.ts?raw";
18
+ import githubRouteSource from "../github/route.ts?raw";
19
+
20
+ let parserReady: Promise<void> | undefined;
21
+
22
+ function readyHostParser() {
23
+ parserReady ??= init();
24
+ return parserReady;
25
+ }
26
+
27
+ const hostFiles = {
28
+ "src/cli.ts": cliSource,
29
+ "src/share.ts": shareSource,
30
+ "src/parseViewer.ts": parseViewerSource,
31
+ "src/github/route.ts": githubRouteSource,
32
+ };
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
+
38
+ export function HostHome() {
39
+ const [view, setView] = useState<
40
+ | { kind: "loading" }
41
+ | { kind: "error"; message: string }
42
+ | { kind: "ready"; document: ViewerDocument }
43
+ >({ kind: "loading" });
44
+
45
+ useEffect(() => {
46
+ let cancelled = false;
47
+ // oxlint-disable-next-line typescript/no-floating-promises -- The effect owns this parse; ignore after unmount.
48
+ void readyHostParser().then(() => {
49
+ if (cancelled) return;
50
+ const parsed = parseViewerDocument(homeSource, "home.md");
51
+ if (parsed instanceof Error) {
52
+ setView({ kind: "error", message: parsed.message });
53
+ return;
54
+ }
55
+ setView({ kind: "ready", document: parsed });
56
+ });
57
+ return () => {
58
+ cancelled = true;
59
+ };
60
+ }, []);
61
+
62
+ if (view.kind === "loading") return <GistLoading title="Loading…" />;
63
+ if (view.kind === "error") {
64
+ return (
65
+ <GistStatus title="Could not parse this walkthrough">
66
+ <P>{view.message}</P>
67
+ </GistStatus>
68
+ );
69
+ }
70
+
71
+ return (
72
+ <HostFilesContext.Provider value={hostFiles}>
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>
84
+ </HostFilesContext.Provider>
85
+ );
86
+ }
@@ -0,0 +1,10 @@
1
+ import { createContext, useContext } from "react";
2
+
3
+ /** Bundled files for the hosted homepage. `[[path]]` reads these, not GitHub. */
4
+ export type HostFiles = Record<string, string>;
5
+
6
+ export const HostFilesContext = createContext<HostFiles | undefined>(undefined);
7
+
8
+ export function useHostFiles() {
9
+ return useContext(HostFilesContext);
10
+ }
@@ -0,0 +1,156 @@
1
+ # diffmap
2
+
3
+ A viewer for specs and walkthroughs. Call stacks, mermaid that links into source, and a Diff panel. This page is one — `parseViewerDocument` plus `ViewerApp`, same path as `diffmap serve`.
4
+
5
+ TOC on the left, article here, Diff on the right. Click a stack row or a mermaid node.
6
+
7
+ ## Install the skill
8
+
9
+ ```sh
10
+ npx skills add tanishqkancharla/diffmap --skill generate-diffmap
11
+ ```
12
+
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.
14
+
15
+ ## Serve locally
16
+
17
+ ```sh
18
+ npx @tanishqkancharla/diffmap serve specs/<name>.md
19
+ ```
20
+
21
+ Bare `npx @tanishqkancharla/diffmap specs/<name>.md` is the same as `serve`. `list` shows running viewers — reuse the URL instead of starting a second server.
22
+
23
+ **Close server** is only on that local page. This host has no process to stop.
24
+
25
+ ## Share
26
+
27
+ ### Gist
28
+
29
+ ```sh
30
+ npx @tanishqkancharla/diffmap share specs/<name>.md
31
+ ```
32
+
33
+ Prints `https://diffmap.dev/g/<gistId>` (optional `/<file.md>`). Secret gist: unlisted, not private. Anyone with the id can read it. Does not start a local server.
34
+
35
+ ### GitHub PR vs commit
36
+
37
+ A spec on a public PR:
38
+
39
+ `https://diffmap.dev/<owner>/<repo>/pull/<n>/specs/<name>.md`
40
+
41
+ Pinned commit:
42
+
43
+ `https://diffmap.dev/<owner>/<repo>/commit/<sha>/specs/<name>.md`
44
+
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
+
47
+ ## What the fences are
48
+
49
+ ### System flow
50
+
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
+
53
+ ```mermaid
54
+ flowchart LR
55
+ Spec[Living spec] --> Serve[serve]
56
+ Spec --> Share[share]
57
+ Spec --> Pin[GitHub pin]
58
+ %% ref node:Spec [[src/parseViewer.ts#parseViewerDocument]]
59
+ %% ref node:Serve [[src/cli.ts]]
60
+ %% ref node:Share [[src/share.ts#shareMarkdownFile]]
61
+ %% ref node:Pin [[src/github/route.ts]]
62
+ %% ref edge:0 [[1257fe8:src/cli.ts#L49-L50]]
63
+ ```
64
+
65
+ ````md
66
+ ```mermaid
67
+ flowchart LR
68
+ Spec[Living spec] --> Serve[serve]
69
+ %% ref node:Serve [[src/cli.ts]]
70
+ ```
71
+ ````
72
+
73
+ ### Call stacks
74
+
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
+
77
+ ```callstack
78
+ ViewerApp
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
+ └── DiffButton
82
+ ```
83
+
84
+ ```callstack
85
+ shareMarkdownFile [[src/share.ts#shareMarkdownFile]]
86
+ -└── print the gist URL
87
+ +└── gistViewerUrl # https://diffmap.dev/g/<id> [[d14ae1a:src/share.ts#L55-L59]]
88
+ ```
89
+
90
+ ### Changes
91
+
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
+
94
+ ```changes
95
+ dae6b98
96
+ ```
97
+
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.
99
+
100
+ ### File excerpts
101
+
102
+ `start:end:path`. Locally the server reads the workspace file. Here the bundled file is sliced the same way.
103
+
104
+ ```58:72:src/cli.ts
105
+ const cli = Cli.create("diffmap", {
106
+ description: "Serve a spec locally, or share it as a gist",
107
+ version: packageVersion,
108
+ args: serveArgs,
109
+ options: serveOptions,
110
+ output: serveOutput,
111
+ hint: "`serve <spec.md>` runs the local viewer. `share <spec.md>` publishes a secret gist. `update` (or `--update`) installs the latest diffmap.",
112
+ examples: [
113
+ {
114
+ args: { file: "spec.md" },
115
+ description: "Same as `diffmap serve spec.md`",
116
+ },
117
+ ],
118
+ run: runServe,
119
+ })
120
+ ```
121
+
122
+ ### HTML
123
+
124
+ Local `serve` trusts `html` in the file you pointed at. This host does not. Gist and GitHub pages are the same — no `dangerouslySetInnerHTML` from untrusted markdown.
125
+
126
+ ```html
127
+ <aside>If this renders, the host leaked HTML.</aside>
128
+ ```
129
+
130
+ ## How a spec stays alive
131
+
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.
133
+
134
+ ### Phase 1: Spec and serve
135
+
136
+ ```callstack
137
+ generate-diffmap
138
+ └── specs/name.md
139
+ └── diffmap serve [[src/cli.ts]]
140
+ ```
141
+
142
+ - [x] Install the skill.
143
+ - [x] Keep one living spec.
144
+ - [x] `diffmap serve` / `list`.
145
+
146
+ ### Phase 2: Share it
147
+
148
+ ```callstack
149
+ specs/name.md
150
+ -└── localhost only
151
+ +├── diffmap share [[d14ae1a:src/share.ts#L55-L59]]
152
+ +└── GitHub PR URL [[src/github/route.ts]]
153
+ ```
154
+
155
+ - [x] Gist URL for a snapshot.
156
+ - [x] PR URL when the spec lives in the repo (pin to one SHA).
@@ -0,0 +1,83 @@
1
+ import assert from "node:assert/strict";
2
+ import fs from "node:fs/promises";
3
+ import path from "node:path";
4
+ import { fileURLToPath } from "node:url";
5
+ import test from "node:test";
6
+ import { parseViewerDocument } from "../parseViewer.ts";
7
+ import type { ViewerNode } from "../parseViewer.ts";
8
+ import type { Fence } from "../parseFence.ts";
9
+
10
+ const homePath = path.join(
11
+ path.dirname(fileURLToPath(import.meta.url)),
12
+ "home.md",
13
+ );
14
+
15
+ function collectFences(nodes: ViewerNode[]): Fence[] {
16
+ return nodes.flatMap((node) => {
17
+ if (node.type === "view") return [node.fence];
18
+ if (node.type === "element") return collectFences(node.children);
19
+ return [];
20
+ });
21
+ }
22
+
23
+ test("homepage markdown is a real viewer document", async () => {
24
+ const source = await fs.readFile(homePath, "utf8");
25
+ const document = parseViewerDocument(source, "home.md");
26
+ assert.equal(document instanceof Error, false);
27
+ if (document instanceof Error) return;
28
+
29
+ const fences = collectFences(document.nodes);
30
+ const kinds = new Set(fences.map((fence) => fence.kind));
31
+ assert.equal(kinds.has("mermaid"), true);
32
+ assert.equal(kinds.has("callstack"), true);
33
+ assert.equal(kinds.has("changes"), true);
34
+ assert.equal(kinds.has("file"), true);
35
+ assert.equal(kinds.has("html"), true);
36
+
37
+ const mermaid = fences.find((fence) => fence.kind === "mermaid");
38
+ assert.notEqual(mermaid, undefined);
39
+ if (mermaid?.kind !== "mermaid") return;
40
+ assert.equal(
41
+ mermaid.annotations.some((link) =>
42
+ link.annotation.references.some((ref) => ref.kind === "file"),
43
+ ),
44
+ true,
45
+ );
46
+ assert.equal(
47
+ mermaid.annotations.some((link) => link.target === "edge"),
48
+ true,
49
+ );
50
+
51
+ const stacks = fences.filter((fence) => fence.kind === "callstack");
52
+ assert.equal(
53
+ stacks.some((fence) =>
54
+ fence.kind === "callstack"
55
+ ? fence.lines.some((line) => line.text.startsWith("-")) &&
56
+ fence.lines.some((line) => line.text.startsWith("+"))
57
+ : false,
58
+ ),
59
+ true,
60
+ );
61
+
62
+ const levels = new Set(document.headings.map((heading) => heading.level));
63
+ assert.equal(levels.has(1), true);
64
+ assert.equal(levels.has(2), true);
65
+ assert.equal(levels.has(3), true);
66
+ assert.ok(
67
+ document.references.filter((ref) => ref.rev !== undefined).length >= 3,
68
+ );
69
+
70
+ const excerpt = fences.find(
71
+ (fence) => fence.kind === "file" && fence.path === "src/cli.ts",
72
+ );
73
+ assert.equal(excerpt?.kind, "file");
74
+ if (excerpt?.kind !== "file") return;
75
+ const cliPath = path.join(path.dirname(homePath), "..", "cli.ts");
76
+ const cli = await fs.readFile(cliPath, "utf8");
77
+ const lines = cli.split(/\n/);
78
+ const slice = lines.slice(excerpt.start - 1, excerpt.end).join("\n");
79
+ assert.equal(lines[excerpt.start - 1]?.trim().length !== 0, true);
80
+ assert.match(slice, /packageVersion/);
81
+ assert.match(slice, /\}\)\s*$/);
82
+ assert.equal(excerpt.source, slice);
83
+ });
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";
@@ -0,0 +1,7 @@
1
+ import { readFileSync } from "node:fs";
2
+
3
+ const packageJson = JSON.parse(
4
+ readFileSync(new URL("../package.json", import.meta.url), "utf8"),
5
+ ) as { version: string };
6
+
7
+ export const packageVersion = packageJson.version;