@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 +12 -4
- package/package.json +3 -3
- package/src/cli.test.ts +50 -0
- package/src/cli.ts +40 -5
- package/src/errors.ts +5 -0
- package/src/gist/GistApp.tsx +3 -2
- package/src/gist/GistStatus.tsx +2 -51
- 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/packageVersion.ts +7 -0
- package/src/serve.test.ts +306 -0
- package/src/serve.ts +14 -4
- package/src/update.test.ts +19 -0
- package/src/update.ts +285 -0
- package/src/viewer/CloseServerButton.tsx +16 -0
- package/src/viewer/FileExcerpt.tsx +25 -3
- package/src/viewer/HtmlPlaceholder.tsx +6 -1
- package/src/viewer/SourceDiffPanel.tsx +26 -7
- package/src/viewer/TableOfContents.tsx +230 -40
- package/src/viewer/TocButton.tsx +27 -0
- package/src/viewer/ViewerApp.tsx +182 -50
- package/src/viewer/sourceNavigation.ts +37 -3
- package/src/viewer/viewerMode.ts +1 -1
- package/vite.config.ts +1 -1
- package/src/viewer/Check.tsx +0 -24
- package/src/viewer/DoneButton.tsx +0 -32
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
|
|
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
|
-
**
|
|
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
|
|
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.
|
|
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.
|
|
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",
|
package/src/cli.test.ts
ADDED
|
@@ -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
|
|
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
|
|
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:
|
|
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
|
+
}) {}
|
package/src/gist/GistApp.tsx
CHANGED
|
@@ -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 {
|
|
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 <
|
|
87
|
+
if (route.kind === "home") return <HostHome />;
|
|
87
88
|
if (route.kind === "invalid") {
|
|
88
89
|
return (
|
|
89
90
|
<GistStatus title="Not a gist id">
|
package/src/gist/GistStatus.tsx
CHANGED
|
@@ -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/<gistId></code>.
|
|
76
|
-
Optional file:{" "}
|
|
77
|
-
<code className={code}>/g/<gistId>/<file.md></code>. GitHub
|
|
78
|
-
spec:{" "}
|
|
79
|
-
<code className={code}>
|
|
80
|
-
https://diffmap.dev/<owner>/<repo>/pull/<n>/<path.md>
|
|
81
|
-
</code>{" "}
|
|
82
|
-
or{" "}
|
|
83
|
-
<code className={code}>
|
|
84
|
-
/<owner>/<repo>/commit/<sha>/<path.md>
|
|
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",
|
|
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",
|
|
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
|
+
}
|
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
|
+
});
|