@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.
Files changed (50) hide show
  1. package/README.md +25 -14
  2. package/package.json +3 -3
  3. package/src/cli.test.ts +50 -0
  4. package/src/cli.ts +126 -51
  5. package/src/contentPlugin.ts +5 -3
  6. package/src/errors.ts +6 -1
  7. package/src/gist/GistApp.tsx +34 -21
  8. package/src/gist/GistStatus.tsx +8 -43
  9. package/src/gist/main.tsx +2 -2
  10. package/src/gist/pin.ts +14 -0
  11. package/src/github/GitHubApp.tsx +235 -0
  12. package/src/github/fetchRepo.test.ts +17 -0
  13. package/src/github/fetchRepo.ts +300 -0
  14. package/src/github/patch.test.ts +30 -0
  15. package/src/github/patch.ts +40 -0
  16. package/src/github/pin.ts +14 -0
  17. package/src/github/route.test.ts +68 -0
  18. package/src/github/route.ts +175 -0
  19. package/src/host/HostApp.tsx +22 -0
  20. package/src/host/HostHome.tsx +73 -0
  21. package/src/host/files.ts +10 -0
  22. package/src/host/home.md +194 -0
  23. package/src/host/home.test.ts +82 -0
  24. package/src/index.ts +1 -0
  25. package/src/mermaid.ts +29 -1
  26. package/src/packageVersion.ts +7 -0
  27. package/src/parseViewer.test.ts +185 -0
  28. package/src/parseViewer.ts +27 -3
  29. package/src/serve.test.ts +306 -0
  30. package/src/serve.ts +23 -5
  31. package/src/share.ts +5 -1
  32. package/src/update.test.ts +19 -0
  33. package/src/update.ts +285 -0
  34. package/src/viewer/CallStackDiff.tsx +26 -6
  35. package/src/viewer/CloseServerButton.tsx +16 -0
  36. package/src/viewer/FileExcerpt.tsx +52 -3
  37. package/src/viewer/HtmlPlaceholder.tsx +6 -1
  38. package/src/viewer/SourceDiffPanel.tsx +155 -59
  39. package/src/viewer/TableOfContents.tsx +230 -39
  40. package/src/viewer/TocButton.tsx +27 -0
  41. package/src/viewer/ViewerApp.tsx +182 -47
  42. package/src/viewer/main.tsx +10 -3
  43. package/src/viewer/sourceHeader.test.ts +131 -0
  44. package/src/viewer/sourceHeader.ts +103 -0
  45. package/src/viewer/sourceNavigation.ts +81 -3
  46. package/src/viewer/viewerMode.ts +1 -1
  47. package/src/viewer/vite-env.d.ts +2 -1
  48. package/vite.config.ts +1 -1
  49. package/src/viewer/Check.tsx +0 -24
  50. 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
+ }
@@ -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) => new DiffmapMermaidError({ 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
  }
@@ -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;