@tanishqkancharla/diffmap 0.1.4 → 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 CHANGED
@@ -28,10 +28,10 @@ Keeps a spec in `specs/` that stays true to what's planned versus already in the
28
28
 
29
29
  Options:
30
30
 
31
- - `--port <n>` — listen port (default `4177`)
31
+ - `--port <n>` — pin a listen port (default: a free port)
32
32
  - `--root <dir>` — workspace root for file excerpts (default the directory you ran the command from)
33
33
 
34
- **Done** in the top right stops the server.
34
+ **Close server** in the top right stops the server. Hosted gist and GitHub viewers have no local server, so they omit this control.
35
35
 
36
36
  The server also stops after 24 hours without a page or source request.
37
37
  Loading or refreshing the page resets the timer; background health checks and
@@ -43,11 +43,19 @@ List every running viewer, including viewers using custom ports:
43
43
  npx @tanishqkancharla/diffmap list
44
44
  ```
45
45
 
46
+ Update a global install to the latest `@tanishqkancharla/diffmap` on npm. `diffmap --update` does the same thing:
47
+
48
+ ```sh
49
+ diffmap update
50
+ ```
51
+
52
+ If you are already on the latest version, it says so and exits. Otherwise it runs the global install for the package manager that installed diffmap (`npm install --global`, `pnpm add --global`, or `bun add --global`) and prints the old and new versions. Under `npx`, or when it cannot tell how diffmap was installed, it prints the command to run instead of installing anything.
53
+
46
54
  Request the page with `Accept: text/markdown` to read the current source file
47
- instead of the rendered HTML:
55
+ instead of the rendered HTML. Use the URL printed by `serve`:
48
56
 
49
57
  ```sh
50
- curl -H 'Accept: text/markdown' http://127.0.0.1:4177/
58
+ curl -H 'Accept: text/markdown' http://127.0.0.1:<port>/
51
59
  ```
52
60
 
53
61
  ## Library
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tanishqkancharla/diffmap",
3
- "version": "0.1.4",
3
+ "version": "0.1.5",
4
4
  "description": "Serve a spec or code walkthrough as a local page",
5
5
  "license": "MIT",
6
6
  "author": "Tanishq Kancharla",
@@ -33,7 +33,7 @@
33
33
  "lint:fix": "oxlint --fix src vite.config.ts vite.gist.config.ts bin.js",
34
34
  "prepublishOnly": "npm run typecheck && npm run lint && npm run format:check",
35
35
  "release": "npm publish",
36
- "test": "tsx --test src/parseViewer.test.ts src/gist/route.test.ts src/github/route.test.ts src/github/patch.test.ts src/github/fetchRepo.test.ts src/viewer/sourceHeader.test.ts",
36
+ "test": "tsx --test src/cli.test.ts src/update.test.ts src/parseViewer.test.ts src/serve.test.ts src/gist/route.test.ts src/github/route.test.ts src/github/patch.test.ts src/github/fetchRepo.test.ts src/viewer/sourceHeader.test.ts src/host/home.test.ts",
37
37
  "typecheck": "tsc --noEmit",
38
38
  "build": "vite build",
39
39
  "build:gist": "vite build",
@@ -45,7 +45,7 @@
45
45
  "beautiful-mermaid": "^1.1.3",
46
46
  "errore": "^0.14.1",
47
47
  "incur": "^0.4.20",
48
- "maui": "npm:@tanishqkancharla/maui@0.0.16",
48
+ "maui": "npm:@tanishqkancharla/maui@0.0.30",
49
49
  "md4x": "^0.0.29",
50
50
  "purse-styles": "^0.2.3",
51
51
  "react": "^19.2.8",
@@ -0,0 +1,50 @@
1
+ import assert from "node:assert/strict";
2
+ import { execFile } from "node:child_process";
3
+ import fs from "node:fs/promises";
4
+ import path from "node:path";
5
+ import { fileURLToPath } from "node:url";
6
+ import { promisify } from "node:util";
7
+ import test from "node:test";
8
+
9
+ const execFileAsync = promisify(execFile);
10
+ const srcDir = fileURLToPath(new URL(".", import.meta.url));
11
+ const packageRoot = path.resolve(srcDir, "..");
12
+ const cliPath = path.join(srcDir, "cli.ts");
13
+
14
+ async function runCli(args: string[]) {
15
+ try {
16
+ const result = await execFileAsync(
17
+ process.execPath,
18
+ ["--import", "tsx", cliPath, ...args],
19
+ {
20
+ cwd: packageRoot,
21
+ timeout: 20_000,
22
+ maxBuffer: 1_000_000,
23
+ },
24
+ );
25
+ return { exitCode: 0, stdout: result.stdout, stderr: result.stderr };
26
+ } catch (cause) {
27
+ if (typeof cause !== "object" || !cause) throw cause;
28
+ const exitCode =
29
+ "code" in cause && typeof cause.code === "number"
30
+ ? cause.code
31
+ : undefined;
32
+ const stdout =
33
+ "stdout" in cause && typeof cause.stdout === "string" ? cause.stdout : "";
34
+ const stderr =
35
+ "stderr" in cause && typeof cause.stderr === "string" ? cause.stderr : "";
36
+ return { exitCode, stdout, stderr };
37
+ }
38
+ }
39
+
40
+ test("diffmap --version matches package.json", async () => {
41
+ const pkg = JSON.parse(
42
+ await fs.readFile(path.join(packageRoot, "package.json"), "utf8"),
43
+ ) as { version: string };
44
+ const source = await fs.readFile(cliPath, "utf8");
45
+ assert.doesNotMatch(source, /version:\s*["'`]\d+\.\d+\.\d+/);
46
+ const result = await runCli(["--version"]);
47
+ assert.equal(result.exitCode, 0, result.stderr);
48
+ const printed = `${result.stdout}${result.stderr}`.trim();
49
+ assert.equal(printed, pkg.version);
50
+ });
package/src/cli.ts CHANGED
@@ -2,16 +2,25 @@
2
2
 
3
3
  import { Cli, z } from "incur";
4
4
  import { resolveFromInvokeCwd } from "./invokeCwd.js";
5
+ import { packageVersion } from "./packageVersion.js";
5
6
  import { listRunningDiffmaps } from "./registry.js";
6
7
  import { startServer } from "./serve.js";
7
8
  import { shareMarkdownFile } from "./share.js";
9
+ import {
10
+ detectInstallMethod,
11
+ fetchLatestVersion,
12
+ packageName,
13
+ resolveUpdateArgv,
14
+ runCommand,
15
+ runUpdate,
16
+ } from "./update.js";
8
17
 
9
18
  const serveArgs = z.object({
10
19
  file: z.string().describe("Path to the markdown file"),
11
20
  });
12
21
 
13
22
  const serveOptions = z.object({
14
- port: z.coerce.number().optional().describe("Port (default 4177)"),
23
+ port: z.coerce.number().optional().describe("Port (default: a free port)"),
15
24
  root: z.string().optional().describe("Workspace root for file excerpts"),
16
25
  });
17
26
 
@@ -33,7 +42,7 @@ async function* runServe(c: ServeContext) {
33
42
  c.options.root === undefined
34
43
  ? resolveFromInvokeCwd(".")
35
44
  : resolveFromInvokeCwd(c.options.root),
36
- port: c.options.port === undefined ? 4177 : c.options.port,
45
+ port: c.options.port,
37
46
  });
38
47
  if (started instanceof Error) {
39
48
  return c.error({
@@ -48,11 +57,11 @@ async function* runServe(c: ServeContext) {
48
57
 
49
58
  const cli = Cli.create("diffmap", {
50
59
  description: "Serve a spec locally, or share it as a gist",
51
- version: "0.1.3",
60
+ version: packageVersion,
52
61
  args: serveArgs,
53
62
  options: serveOptions,
54
63
  output: serveOutput,
55
- hint: "`serve <spec.md>` runs the local viewer. `share <spec.md>` publishes a secret gist.",
64
+ hint: "`serve <spec.md>` runs the local viewer. `share <spec.md>` publishes a secret gist. `update` (or `--update`) installs the latest diffmap.",
56
65
  examples: [
57
66
  {
58
67
  args: { file: "spec.md" },
@@ -129,6 +138,32 @@ const cli = Cli.create("diffmap", {
129
138
  }
130
139
  return c.ok({ instances });
131
140
  },
141
+ })
142
+ .command("update", {
143
+ description: `Update diffmap to the latest ${packageName} on npm (same as --update)`,
144
+ output: z.object({
145
+ status: z.enum(["up-to-date", "updated", "manual"]),
146
+ message: z.string(),
147
+ current: z.string(),
148
+ previous: z.string().optional(),
149
+ latest: z.string().optional(),
150
+ command: z.string().optional(),
151
+ }),
152
+ async run(c) {
153
+ const result = await runUpdate({
154
+ currentVersion: packageVersion,
155
+ fetchLatestVersion,
156
+ detectInstallMethod,
157
+ runCommand,
158
+ });
159
+ if (result instanceof Error) {
160
+ return c.error({
161
+ code: "UPDATE_FAILED",
162
+ message: result.message,
163
+ });
164
+ }
165
+ return c.ok(result);
166
+ },
132
167
  });
133
168
 
134
- await cli.serve();
169
+ await cli.serve(resolveUpdateArgv(process.argv.slice(2)));
package/src/errors.ts CHANGED
@@ -39,3 +39,8 @@ export class DiffmapShareError extends errore.createTaggedError({
39
39
  name: "DiffmapShareError",
40
40
  message: "diffmap share failed: $reason",
41
41
  }) {}
42
+
43
+ export class DiffmapUpdateError extends errore.createTaggedError({
44
+ name: "DiffmapUpdateError",
45
+ message: "diffmap update failed: $reason",
46
+ }) {}
@@ -7,7 +7,8 @@ import type { ViewerDocument } from "../parseViewer.ts";
7
7
  import { ViewerApp } from "../viewer/ViewerApp.tsx";
8
8
  import { loadGistMarkdown, type GistDocument } from "./fetchGist.ts";
9
9
  import { GistPinContext } from "./pin.ts";
10
- import { GistLanding, GistLoading, GistStatus } from "./GistStatus.tsx";
10
+ import { HostHome } from "../host/HostHome.tsx";
11
+ import { GistLoading, GistStatus } from "./GistStatus.tsx";
11
12
  import {
12
13
  gistPath,
13
14
  parseGistPath,
@@ -83,7 +84,7 @@ export function GistApp() {
83
84
  return () => abort.abort();
84
85
  }, [route]);
85
86
 
86
- if (route.kind === "home") return <GistLanding />;
87
+ if (route.kind === "home") return <HostHome />;
87
88
  if (route.kind === "invalid") {
88
89
  return (
89
90
  <GistStatus title="Not a gist id">
@@ -61,52 +61,6 @@ export function GistLoading(props: { title?: string }) {
61
61
  );
62
62
  }
63
63
 
64
- export function GistLanding() {
65
- const code = useStyles(styles.code);
66
- return (
67
- <GistStatus title="diffmap">
68
- <P>
69
- A hosted viewer for diffmap walkthroughs. This page fetches a GitHub
70
- gist or a public repo file in your browser. There is no server holding
71
- the markdown.
72
- </P>
73
- <P>
74
- Canonical gist URL:{" "}
75
- <code className={code}>https://diffmap.dev/g/&lt;gistId&gt;</code>.
76
- Optional file:{" "}
77
- <code className={code}>/g/&lt;gistId&gt;/&lt;file.md&gt;</code>. GitHub
78
- spec:{" "}
79
- <code className={code}>
80
- https://diffmap.dev/&lt;owner&gt;/&lt;repo&gt;/pull/&lt;n&gt;/&lt;path.md&gt;
81
- </code>{" "}
82
- or{" "}
83
- <code className={code}>
84
- /&lt;owner&gt;/&lt;repo&gt;/commit/&lt;sha&gt;/&lt;path.md&gt;
85
- </code>
86
- . Heading hashes stay in the fragment, same as the local viewer.
87
- </P>
88
- <P>
89
- Share a secret gist (requires <code className={code}>gh</code>
90
- ):
91
- </P>
92
- <P>
93
- <code className={code}>
94
- npx @tanishqkancharla/diffmap share path/to.md
95
- </code>
96
- </P>
97
- <P>
98
- Secret gists are unlisted, not private. Anyone with the id can read
99
- them. Unauthenticated GitHub API is 60 requests per hour per IP.
100
- </P>
101
- <P>
102
- <Link href="https://github.com/tanishqkancharla/diffmap">
103
- tanishqkancharla/diffmap
104
- </Link>
105
- </P>
106
- </GistStatus>
107
- );
108
- }
109
-
110
64
  const styles = {
111
65
  shell: style(flex({ direction: "column" }), {
112
66
  width: "100%",
@@ -117,7 +71,7 @@ const styles = {
117
71
  backgroundColor: colors.gray[4],
118
72
  }),
119
73
  header: style(
120
- flex({ direction: "row", align: "center", justify: "between" }),
74
+ flex({ direction: "row", alignItems: "center", justifyContent: "between" }),
121
75
  spacing.padding({ x: 6, y: 3 }),
122
76
  flexItem({ size: "hug" }),
123
77
  border(["bottom"], "border"),
@@ -142,10 +96,7 @@ const styles = {
142
96
  title: style(text({ size: "xl", fontWeight: 600, color: "highContrast" }), {
143
97
  marginBottom: spacing.value(4),
144
98
  }),
145
- loading: style(flex({ direction: "row", align: "center" }), {
99
+ loading: style(flex({ direction: "row", alignItems: "center" }), {
146
100
  minHeight: spacing.value(12),
147
101
  }),
148
- code: style(text({ size: "sm", color: "highContrast" }), {
149
- fontFamily: "ui-monospace, SFMono-Regular, Menlo, monospace",
150
- }),
151
102
  };
@@ -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
+ });
@@ -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;