@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.
- package/README.md +69 -61
- package/index.html +18 -0
- package/package.json +7 -5
- package/src/annotations.ts +36 -29
- package/src/changes.ts +229 -0
- package/src/cli.test.ts +50 -0
- package/src/cli.ts +41 -5
- package/src/errors.ts +10 -0
- package/src/gist/GistApp.tsx +39 -20
- package/src/gist/GistStatus.tsx +2 -51
- package/src/gist/fetchGist.ts +23 -0
- package/src/git.test.ts +402 -0
- package/src/git.ts +498 -0
- package/src/github/GitHubApp.tsx +39 -22
- package/src/github/fetchRepo.test.ts +59 -1
- package/src/github/fetchRepo.ts +99 -43
- package/src/github/patch.test.ts +18 -9
- package/src/github/patch.ts +30 -19
- package/src/host/HostHome.tsx +86 -0
- package/src/host/files.ts +10 -0
- package/src/host/home.md +156 -0
- package/src/host/home.test.ts +83 -0
- package/src/index.ts +1 -0
- package/src/packageVersion.ts +7 -0
- package/src/parseFence.ts +6 -6
- package/src/parseViewer.test.ts +87 -0
- package/src/parseViewer.ts +103 -77
- package/src/serve.test.ts +306 -0
- package/src/serve.ts +48 -5
- package/src/share.ts +18 -0
- package/src/update.test.ts +19 -0
- package/src/update.ts +285 -0
- package/src/viewer/CallStackDiff.tsx +15 -3
- package/src/viewer/ChangesCard.tsx +267 -0
- package/src/viewer/CloseServerButton.tsx +16 -0
- package/src/viewer/ComarkView.tsx +83 -15
- package/src/viewer/Fence.tsx +9 -1
- package/src/viewer/FileExcerpt.tsx +25 -3
- package/src/viewer/HtmlPlaceholder.tsx +6 -1
- package/src/viewer/MermaidBlock.tsx +99 -11
- package/src/viewer/RangePicker.tsx +526 -0
- package/src/viewer/SourceDiffPanel.tsx +277 -121
- package/src/viewer/TableOfContents.tsx +313 -39
- package/src/viewer/ViewerApp.tsx +120 -68
- package/src/viewer/changesSource.ts +91 -0
- package/src/viewer/sourceHeader.test.ts +2 -87
- package/src/viewer/sourceHeader.ts +4 -63
- package/src/viewer/sourceNavigation.ts +33 -42
- package/src/viewer/viewerMode.ts +1 -1
- package/vite.config.ts +2 -1
- package/vite.gist.config.ts +57 -0
- package/src/viewer/Check.tsx +0 -24
- package/src/viewer/DoneButton.tsx +0 -32
package/src/github/fetchRepo.ts
CHANGED
|
@@ -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
|
|
8
|
-
import {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
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
|
-
|
|
207
|
-
|
|
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
|
-
|
|
|
243
|
+
| (CommitApi & CompareApi)
|
|
211
244
|
| undefined;
|
|
212
|
-
if (
|
|
213
|
-
return new Error("GitHub returned invalid
|
|
214
|
-
|
|
215
|
-
files.push(...payload);
|
|
216
|
-
|
|
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
|
-
|
|
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(
|
package/src/github/patch.test.ts
CHANGED
|
@@ -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 {
|
|
4
|
+
import { changedFileItem } from "../changes.ts";
|
|
5
|
+
import { githubChangedFiles, wrapGitHubPatch } from "./patch.ts";
|
|
5
6
|
|
|
6
|
-
test("
|
|
7
|
-
const wrapped =
|
|
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("
|
|
19
|
-
const
|
|
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.
|
|
28
|
-
|
|
29
|
-
|
|
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
|
});
|
package/src/github/patch.ts
CHANGED
|
@@ -1,14 +1,17 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import type { ChangedFile } from "../changes.ts";
|
|
2
2
|
|
|
3
|
-
|
|
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
|
|
11
|
-
export function
|
|
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
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
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
|
+
}
|
package/src/host/home.md
ADDED
|
@@ -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