@tanishqkancharla/diffmap 0.1.3 → 0.1.5
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 +25 -14
- package/package.json +3 -3
- package/src/cli.test.ts +50 -0
- package/src/cli.ts +126 -51
- package/src/contentPlugin.ts +5 -3
- package/src/errors.ts +6 -1
- package/src/gist/GistApp.tsx +34 -21
- package/src/gist/GistStatus.tsx +8 -43
- package/src/gist/main.tsx +2 -2
- package/src/gist/pin.ts +14 -0
- package/src/github/GitHubApp.tsx +235 -0
- package/src/github/fetchRepo.test.ts +17 -0
- package/src/github/fetchRepo.ts +300 -0
- package/src/github/patch.test.ts +30 -0
- package/src/github/patch.ts +40 -0
- package/src/github/pin.ts +14 -0
- package/src/github/route.test.ts +68 -0
- package/src/github/route.ts +175 -0
- package/src/host/HostApp.tsx +22 -0
- package/src/host/HostHome.tsx +73 -0
- package/src/host/files.ts +10 -0
- package/src/host/home.md +194 -0
- package/src/host/home.test.ts +82 -0
- package/src/index.ts +1 -0
- package/src/mermaid.ts +29 -1
- package/src/packageVersion.ts +7 -0
- package/src/parseViewer.test.ts +185 -0
- package/src/parseViewer.ts +27 -3
- package/src/serve.test.ts +306 -0
- package/src/serve.ts +23 -5
- package/src/share.ts +5 -1
- package/src/update.test.ts +19 -0
- package/src/update.ts +285 -0
- package/src/viewer/CallStackDiff.tsx +26 -6
- package/src/viewer/CloseServerButton.tsx +16 -0
- package/src/viewer/FileExcerpt.tsx +52 -3
- package/src/viewer/HtmlPlaceholder.tsx +6 -1
- package/src/viewer/SourceDiffPanel.tsx +155 -59
- package/src/viewer/TableOfContents.tsx +230 -39
- package/src/viewer/TocButton.tsx +27 -0
- package/src/viewer/ViewerApp.tsx +182 -47
- package/src/viewer/main.tsx +10 -3
- package/src/viewer/sourceHeader.test.ts +131 -0
- package/src/viewer/sourceHeader.ts +103 -0
- package/src/viewer/sourceNavigation.ts +81 -3
- package/src/viewer/viewerMode.ts +1 -1
- package/src/viewer/vite-env.d.ts +2 -1
- package/vite.config.ts +1 -1
- package/src/viewer/Check.tsx +0 -24
- package/src/viewer/DoneButton.tsx +0 -32
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import test from "node:test";
|
|
3
|
+
import {
|
|
4
|
+
githubCommitPath,
|
|
5
|
+
githubPullPath,
|
|
6
|
+
githubViewerUrl,
|
|
7
|
+
parseGitHubPath,
|
|
8
|
+
} from "./route.ts";
|
|
9
|
+
|
|
10
|
+
test("parseGitHubPath reads pull and commit spec URLs", () => {
|
|
11
|
+
assert.deepEqual(
|
|
12
|
+
parseGitHubPath(
|
|
13
|
+
"/tanishqkancharla/diffmap/pull/5/specs/merge-generate-spec-walkthrough.md",
|
|
14
|
+
),
|
|
15
|
+
{
|
|
16
|
+
kind: "github-pull",
|
|
17
|
+
owner: "tanishqkancharla",
|
|
18
|
+
repo: "diffmap",
|
|
19
|
+
pull: 5,
|
|
20
|
+
path: "specs/merge-generate-spec-walkthrough.md",
|
|
21
|
+
},
|
|
22
|
+
);
|
|
23
|
+
assert.deepEqual(
|
|
24
|
+
parseGitHubPath(
|
|
25
|
+
"/tanishqkancharla/diffmap/commit/0123456789abcdef0123456789abcdef01234567/specs/a.md",
|
|
26
|
+
),
|
|
27
|
+
{
|
|
28
|
+
kind: "github-commit",
|
|
29
|
+
owner: "tanishqkancharla",
|
|
30
|
+
repo: "diffmap",
|
|
31
|
+
sha: "0123456789abcdef0123456789abcdef01234567",
|
|
32
|
+
path: "specs/a.md",
|
|
33
|
+
},
|
|
34
|
+
);
|
|
35
|
+
assert.equal(parseGitHubPath("/").kind, "home");
|
|
36
|
+
assert.equal(
|
|
37
|
+
parseGitHubPath("/g/0123456789abcdef0123456789abcdef").kind,
|
|
38
|
+
"home",
|
|
39
|
+
);
|
|
40
|
+
assert.equal(
|
|
41
|
+
parseGitHubPath("/tanishqkancharla/diffmap/pull/5").kind,
|
|
42
|
+
"github-invalid",
|
|
43
|
+
);
|
|
44
|
+
assert.equal(
|
|
45
|
+
parseGitHubPath("/tanishqkancharla/diffmap/pull/5/src/cli.ts").kind,
|
|
46
|
+
"github-invalid",
|
|
47
|
+
);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test("printed GitHub viewer URLs pin pull or commit", () => {
|
|
51
|
+
assert.equal(
|
|
52
|
+
githubPullPath(
|
|
53
|
+
"tanishqkancharla",
|
|
54
|
+
"diffmap",
|
|
55
|
+
5,
|
|
56
|
+
"specs/merge-generate-spec-walkthrough.md",
|
|
57
|
+
),
|
|
58
|
+
"/tanishqkancharla/diffmap/pull/5/specs/merge-generate-spec-walkthrough.md",
|
|
59
|
+
);
|
|
60
|
+
assert.equal(
|
|
61
|
+
githubCommitPath("o", "r", "deadbeef", "docs/a b.md"),
|
|
62
|
+
"/o/r/commit/deadbeef/docs/a%20b.md",
|
|
63
|
+
);
|
|
64
|
+
assert.equal(
|
|
65
|
+
githubViewerUrl("tanishqkancharla", "diffmap", { pull: 5 }, "specs/x.md"),
|
|
66
|
+
"https://diffmap.dev/tanishqkancharla/diffmap/pull/5/specs/x.md",
|
|
67
|
+
);
|
|
68
|
+
});
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GitHub-hosted spec URLs.
|
|
3
|
+
*
|
|
4
|
+
* Pin model (locked): a hosted spec is always one commit SHA. The spec markdown
|
|
5
|
+
* is the blob of `path` at that SHA. Every [[path]], mermaid %% ref, excerpt,
|
|
6
|
+
* and hunk is loaded from that same SHA — never main / latest. A PR number only
|
|
7
|
+
* chooses the SHA (head at load time, then pin). Prefer putting the SHA in the
|
|
8
|
+
* URL; otherwise resolve head once per page load and use that SHA for all fetches.
|
|
9
|
+
*
|
|
10
|
+
* v1: /<owner>/<repo>/pull/<n>/<path-to-spec.md>
|
|
11
|
+
* /<owner>/<repo>/commit/<sha>/<path-to-spec.md>
|
|
12
|
+
*/
|
|
13
|
+
import { DIFFMAP_ORIGIN } from "../gist/route.ts";
|
|
14
|
+
|
|
15
|
+
const NAME_PATTERN = /^[A-Za-z0-9._-]+$/;
|
|
16
|
+
const SHA_PATTERN = /^[0-9a-f]{7,40}$/i;
|
|
17
|
+
const PULL_PATTERN = /^[1-9][0-9]{0,9}$/;
|
|
18
|
+
const MARKDOWN_EXT = /\.(?:md|mdx)$/i;
|
|
19
|
+
|
|
20
|
+
export type GitHubRoute =
|
|
21
|
+
| {
|
|
22
|
+
kind: "github-pull";
|
|
23
|
+
owner: string;
|
|
24
|
+
repo: string;
|
|
25
|
+
pull: number;
|
|
26
|
+
path: string;
|
|
27
|
+
}
|
|
28
|
+
| {
|
|
29
|
+
kind: "github-commit";
|
|
30
|
+
owner: string;
|
|
31
|
+
repo: string;
|
|
32
|
+
sha: string;
|
|
33
|
+
path: string;
|
|
34
|
+
}
|
|
35
|
+
| { kind: "github-invalid"; message: string };
|
|
36
|
+
|
|
37
|
+
export function parseGitHubPath(
|
|
38
|
+
pathname: string,
|
|
39
|
+
): GitHubRoute | { kind: "home" } {
|
|
40
|
+
const parts = normalizePath(pathname)
|
|
41
|
+
.split("/")
|
|
42
|
+
.filter((part) => part.length > 0);
|
|
43
|
+
if (parts.length < 4) return { kind: "home" };
|
|
44
|
+
const owner = decodePathSegment(parts[0] ?? "");
|
|
45
|
+
const repo = decodePathSegment(parts[1] ?? "");
|
|
46
|
+
const kind = parts[2];
|
|
47
|
+
const id = decodePathSegment(parts[3] ?? "");
|
|
48
|
+
if (!isRepoName(owner) || !isRepoName(repo)) return { kind: "home" };
|
|
49
|
+
if (kind !== "pull" && kind !== "commit") return { kind: "home" };
|
|
50
|
+
|
|
51
|
+
const specPath = parts.slice(4).map(decodePathSegment).join("/");
|
|
52
|
+
if (kind === "pull") {
|
|
53
|
+
if (!PULL_PATTERN.test(id)) {
|
|
54
|
+
return { kind: "github-invalid", message: "Not a pull request number." };
|
|
55
|
+
}
|
|
56
|
+
const pathError = specPathError(specPath);
|
|
57
|
+
if (pathError !== undefined)
|
|
58
|
+
return { kind: "github-invalid", message: pathError };
|
|
59
|
+
return {
|
|
60
|
+
kind: "github-pull",
|
|
61
|
+
owner,
|
|
62
|
+
repo,
|
|
63
|
+
pull: Number(id),
|
|
64
|
+
path: specPath,
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
if (!SHA_PATTERN.test(id)) {
|
|
68
|
+
return { kind: "github-invalid", message: "Not a git commit SHA." };
|
|
69
|
+
}
|
|
70
|
+
const pathError = specPathError(specPath);
|
|
71
|
+
if (pathError !== undefined)
|
|
72
|
+
return { kind: "github-invalid", message: pathError };
|
|
73
|
+
return {
|
|
74
|
+
kind: "github-commit",
|
|
75
|
+
owner,
|
|
76
|
+
repo,
|
|
77
|
+
sha: id.toLowerCase(),
|
|
78
|
+
path: specPath,
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function githubPullPath(
|
|
83
|
+
owner: string,
|
|
84
|
+
repo: string,
|
|
85
|
+
pull: number,
|
|
86
|
+
filePath: string,
|
|
87
|
+
) {
|
|
88
|
+
return `/${encodeName(owner)}/${encodeName(repo)}/pull/${String(pull)}/${encodeSpecPath(filePath)}`;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function githubCommitPath(
|
|
92
|
+
owner: string,
|
|
93
|
+
repo: string,
|
|
94
|
+
sha: string,
|
|
95
|
+
filePath: string,
|
|
96
|
+
) {
|
|
97
|
+
return `/${encodeName(owner)}/${encodeName(repo)}/commit/${sha}/${encodeSpecPath(filePath)}`;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export function githubViewerUrl(
|
|
101
|
+
owner: string,
|
|
102
|
+
repo: string,
|
|
103
|
+
pin: { pull: number } | { sha: string },
|
|
104
|
+
filePath: string,
|
|
105
|
+
origin = DIFFMAP_ORIGIN,
|
|
106
|
+
) {
|
|
107
|
+
const path =
|
|
108
|
+
"pull" in pin
|
|
109
|
+
? githubPullPath(owner, repo, pin.pull, filePath)
|
|
110
|
+
: githubCommitPath(owner, repo, pin.sha, filePath);
|
|
111
|
+
return `${origin}${path}`;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export function githubBlobUrl(
|
|
115
|
+
owner: string,
|
|
116
|
+
repo: string,
|
|
117
|
+
sha: string,
|
|
118
|
+
filePath: string,
|
|
119
|
+
) {
|
|
120
|
+
return `https://github.com/${owner}/${repo}/blob/${sha}/${filePath
|
|
121
|
+
.split("/")
|
|
122
|
+
.map(encodeURIComponent)
|
|
123
|
+
.join("/")}`;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export function githubPullUrl(owner: string, repo: string, pull: number) {
|
|
127
|
+
return `https://github.com/${owner}/${repo}/pull/${String(pull)}`;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function isRepoName(value: string) {
|
|
131
|
+
return (
|
|
132
|
+
NAME_PATTERN.test(value) &&
|
|
133
|
+
value !== "." &&
|
|
134
|
+
value !== ".." &&
|
|
135
|
+
!value.startsWith(".")
|
|
136
|
+
);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function specPathError(specPath: string) {
|
|
140
|
+
if (specPath.length === 0) {
|
|
141
|
+
return "A hosted spec URL needs a markdown path after the pull or commit.";
|
|
142
|
+
}
|
|
143
|
+
if (specPath.split("/").some((part) => part === "." || part === "..")) {
|
|
144
|
+
return "Spec path is not a repository file.";
|
|
145
|
+
}
|
|
146
|
+
if (!MARKDOWN_EXT.test(specPath)) {
|
|
147
|
+
return "Spec path must be a .md or .mdx file.";
|
|
148
|
+
}
|
|
149
|
+
return undefined;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function encodeName(value: string) {
|
|
153
|
+
return encodeURIComponent(value);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function encodeSpecPath(filePath: string) {
|
|
157
|
+
return filePath.split("/").map(encodeURIComponent).join("/");
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function normalizePath(pathname: string) {
|
|
161
|
+
if (pathname.length === 0) return "/";
|
|
162
|
+
try {
|
|
163
|
+
return decodeURI(pathname);
|
|
164
|
+
} catch {
|
|
165
|
+
return pathname;
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function decodePathSegment(value: string) {
|
|
170
|
+
try {
|
|
171
|
+
return decodeURIComponent(value);
|
|
172
|
+
} catch {
|
|
173
|
+
return value;
|
|
174
|
+
}
|
|
175
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { useEffect, useState } from "react";
|
|
2
|
+
import { GistApp } from "../gist/GistApp.tsx";
|
|
3
|
+
import { GitHubApp } from "../github/GitHubApp.tsx";
|
|
4
|
+
import { parseGitHubPath } from "../github/route.ts";
|
|
5
|
+
|
|
6
|
+
function isGitHubPath(pathname: string) {
|
|
7
|
+
return parseGitHubPath(pathname).kind !== "home";
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function HostApp() {
|
|
11
|
+
const [github, setGithub] = useState(() =>
|
|
12
|
+
isGitHubPath(window.location.pathname),
|
|
13
|
+
);
|
|
14
|
+
|
|
15
|
+
useEffect(() => {
|
|
16
|
+
const onPop = () => setGithub(isGitHubPath(window.location.pathname));
|
|
17
|
+
window.addEventListener("popstate", onPop);
|
|
18
|
+
return () => window.removeEventListener("popstate", onPop);
|
|
19
|
+
}, []);
|
|
20
|
+
|
|
21
|
+
return github ? <GitHubApp /> : <GistApp />;
|
|
22
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
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 { HostFilesContext } from "./files.ts";
|
|
9
|
+
import homeSource from "./home.md?raw";
|
|
10
|
+
import cliSource from "../cli.ts?raw";
|
|
11
|
+
import shareSource from "../share.ts?raw";
|
|
12
|
+
import parseViewerSource from "../parseViewer.ts?raw";
|
|
13
|
+
import githubRouteSource from "../github/route.ts?raw";
|
|
14
|
+
|
|
15
|
+
let parserReady: Promise<void> | undefined;
|
|
16
|
+
|
|
17
|
+
function readyHostParser() {
|
|
18
|
+
parserReady ??= init();
|
|
19
|
+
return parserReady;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const hostFiles = {
|
|
23
|
+
"src/cli.ts": cliSource,
|
|
24
|
+
"src/share.ts": shareSource,
|
|
25
|
+
"src/parseViewer.ts": parseViewerSource,
|
|
26
|
+
"src/github/route.ts": githubRouteSource,
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
export function HostHome() {
|
|
30
|
+
const [view, setView] = useState<
|
|
31
|
+
| { kind: "loading" }
|
|
32
|
+
| { kind: "error"; message: string }
|
|
33
|
+
| { kind: "ready"; document: ViewerDocument }
|
|
34
|
+
>({ kind: "loading" });
|
|
35
|
+
|
|
36
|
+
useEffect(() => {
|
|
37
|
+
let cancelled = false;
|
|
38
|
+
// oxlint-disable-next-line typescript/no-floating-promises -- The effect owns this parse; ignore after unmount.
|
|
39
|
+
void readyHostParser().then(() => {
|
|
40
|
+
if (cancelled) return;
|
|
41
|
+
const parsed = parseViewerDocument(homeSource, "home.md");
|
|
42
|
+
if (parsed instanceof Error) {
|
|
43
|
+
setView({ kind: "error", message: parsed.message });
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
setView({ kind: "ready", document: parsed });
|
|
47
|
+
});
|
|
48
|
+
return () => {
|
|
49
|
+
cancelled = true;
|
|
50
|
+
};
|
|
51
|
+
}, []);
|
|
52
|
+
|
|
53
|
+
if (view.kind === "loading") return <GistLoading title="Loading…" />;
|
|
54
|
+
if (view.kind === "error") {
|
|
55
|
+
return (
|
|
56
|
+
<GistStatus title="Could not parse this walkthrough">
|
|
57
|
+
<P>{view.message}</P>
|
|
58
|
+
</GistStatus>
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
return (
|
|
63
|
+
<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
|
+
/>
|
|
71
|
+
</HostFilesContext.Provider>
|
|
72
|
+
);
|
|
73
|
+
}
|
|
@@ -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,194 @@
|
|
|
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-spec
|
|
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`, 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.
|
|
46
|
+
|
|
47
|
+
## What the fences are
|
|
48
|
+
|
|
49
|
+
### System flow
|
|
50
|
+
|
|
51
|
+
Click a node. `%% ref` is a mermaid comment; `[[path]]` and `[[path#symbol]]` 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 [[cli:new:50]]
|
|
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, retarget `[[path]]` to a hunk: `[[id:old:…]]` / `[[id:new:…]]`.
|
|
76
|
+
|
|
77
|
+
```callstack
|
|
78
|
+
ViewerApp
|
|
79
|
+
-└── CloseServerButton # every hosted page tried to stop a server [[close:old:217]]
|
|
80
|
+
+└── CloseServerButton # local mode only [[close:new:217-224]]
|
|
81
|
+
└── DiffButton
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
```callstack
|
|
85
|
+
shareMarkdownFile [[src/share.ts#shareMarkdownFile]]
|
|
86
|
+
-└── print gist URL [[share:old:62]]
|
|
87
|
+
+└── gistViewerUrl # https://diffmap.dev/g/<id> [[share:new:62]]
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
### Source patches
|
|
91
|
+
|
|
92
|
+
Define each id once. Copy the real `git diff` (`diff --git`, `---`, `+++`, `@@`). Ranges have to sit inside an included hunk.
|
|
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,
|
|
104
|
+
```
|
|
105
|
+
|
|
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
|
+
```
|
|
137
|
+
|
|
138
|
+
### File excerpts
|
|
139
|
+
|
|
140
|
+
`start:end:path`. Locally the server reads the workspace file. Here the bundled file is sliced the same way.
|
|
141
|
+
|
|
142
|
+
```58:72:src/cli.ts
|
|
143
|
+
const cli = Cli.create("diffmap", {
|
|
144
|
+
description: "Serve a spec locally, or share it as a gist",
|
|
145
|
+
version: packageVersion,
|
|
146
|
+
args: serveArgs,
|
|
147
|
+
options: serveOptions,
|
|
148
|
+
output: serveOutput,
|
|
149
|
+
hint: "`serve <spec.md>` runs the local viewer. `share <spec.md>` publishes a secret gist. `update` (or `--update`) installs the latest diffmap.",
|
|
150
|
+
examples: [
|
|
151
|
+
{
|
|
152
|
+
args: { file: "spec.md" },
|
|
153
|
+
description: "Same as `diffmap serve spec.md`",
|
|
154
|
+
},
|
|
155
|
+
],
|
|
156
|
+
run: runServe,
|
|
157
|
+
})
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
### HTML
|
|
161
|
+
|
|
162
|
+
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.
|
|
163
|
+
|
|
164
|
+
```html
|
|
165
|
+
<aside>If this renders, the host leaked HTML.</aside>
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
## How a spec stays alive
|
|
169
|
+
|
|
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.
|
|
171
|
+
|
|
172
|
+
### Phase 1: Spec and serve
|
|
173
|
+
|
|
174
|
+
```callstack
|
|
175
|
+
generate-spec
|
|
176
|
+
└── specs/name.md
|
|
177
|
+
└── diffmap serve [[src/cli.ts]]
|
|
178
|
+
```
|
|
179
|
+
|
|
180
|
+
- [x] Install the skill.
|
|
181
|
+
- [x] Keep one living spec.
|
|
182
|
+
- [x] `diffmap serve` / `list`.
|
|
183
|
+
|
|
184
|
+
### Phase 2: Share it
|
|
185
|
+
|
|
186
|
+
```callstack
|
|
187
|
+
specs/name.md
|
|
188
|
+
-└── localhost only
|
|
189
|
+
+├── diffmap share [[share:new:62]]
|
|
190
|
+
+└── GitHub PR URL [[src/github/route.ts]]
|
|
191
|
+
```
|
|
192
|
+
|
|
193
|
+
- [x] Gist URL for a snapshot.
|
|
194
|
+
- [x] PR URL when the spec lives in the repo (pin to one SHA).
|
|
@@ -0,0 +1,82 @@
|
|
|
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("source-diff"), 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(document.sourceDiffs.length >= 3);
|
|
67
|
+
assert.equal(document.hasReferences, true);
|
|
68
|
+
|
|
69
|
+
const excerpt = fences.find(
|
|
70
|
+
(fence) => fence.kind === "file" && fence.path === "src/cli.ts",
|
|
71
|
+
);
|
|
72
|
+
assert.equal(excerpt?.kind, "file");
|
|
73
|
+
if (excerpt?.kind !== "file") return;
|
|
74
|
+
const cliPath = path.join(path.dirname(homePath), "..", "cli.ts");
|
|
75
|
+
const cli = await fs.readFile(cliPath, "utf8");
|
|
76
|
+
const lines = cli.split(/\n/);
|
|
77
|
+
const slice = lines.slice(excerpt.start - 1, excerpt.end).join("\n");
|
|
78
|
+
assert.equal(lines[excerpt.start - 1]?.trim().length !== 0, true);
|
|
79
|
+
assert.match(slice, /packageVersion/);
|
|
80
|
+
assert.match(slice, /\}\)\s*$/);
|
|
81
|
+
assert.equal(excerpt.source, slice);
|
|
82
|
+
});
|
package/src/index.ts
CHANGED
|
@@ -17,6 +17,7 @@ export type {
|
|
|
17
17
|
ViewerView,
|
|
18
18
|
} from "./parseViewer.js";
|
|
19
19
|
export { gistViewerUrl } from "./gist/route.js";
|
|
20
|
+
export { githubViewerUrl } from "./github/route.js";
|
|
20
21
|
export { shareMarkdownFile } from "./share.js";
|
|
21
22
|
export { startServer } from "./serve.js";
|
|
22
23
|
export type { FileExcerpt, StartServerInput, DiffmapServer } from "./serve.js";
|
package/src/mermaid.ts
CHANGED
|
@@ -2,6 +2,28 @@ import { renderMermaidSVG } from "beautiful-mermaid";
|
|
|
2
2
|
import * as errore from "errore";
|
|
3
3
|
import { DiffmapMermaidError } from "./errors.js";
|
|
4
4
|
|
|
5
|
+
function mermaidReason(cause: unknown) {
|
|
6
|
+
if (cause instanceof Error && cause.message !== "") return cause.message;
|
|
7
|
+
return "unknown error";
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function parseMermaidDiagram(input: {
|
|
11
|
+
source: string;
|
|
12
|
+
path: string;
|
|
13
|
+
where: string;
|
|
14
|
+
}) {
|
|
15
|
+
return errore.try({
|
|
16
|
+
try: () => renderMermaidSVG(input.source),
|
|
17
|
+
catch: (cause) =>
|
|
18
|
+
new DiffmapMermaidError({
|
|
19
|
+
path: input.path,
|
|
20
|
+
where: input.where,
|
|
21
|
+
reason: mermaidReason(cause),
|
|
22
|
+
cause,
|
|
23
|
+
}),
|
|
24
|
+
});
|
|
25
|
+
}
|
|
26
|
+
|
|
5
27
|
export function mermaidSvg(input: {
|
|
6
28
|
source: string;
|
|
7
29
|
bg: string;
|
|
@@ -24,6 +46,12 @@ export function mermaidSvg(input: {
|
|
|
24
46
|
font: input.font,
|
|
25
47
|
transparent: true,
|
|
26
48
|
}),
|
|
27
|
-
catch: (cause) =>
|
|
49
|
+
catch: (cause) =>
|
|
50
|
+
new DiffmapMermaidError({
|
|
51
|
+
path: "spec",
|
|
52
|
+
where: "diagram 1",
|
|
53
|
+
reason: mermaidReason(cause),
|
|
54
|
+
cause,
|
|
55
|
+
}),
|
|
28
56
|
});
|
|
29
57
|
}
|