@tanishqkancharla/diffmap 0.1.3 → 0.1.4
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 +13 -10
- package/package.json +2 -2
- package/src/cli.ts +94 -54
- package/src/contentPlugin.ts +5 -3
- package/src/errors.ts +1 -1
- package/src/gist/GistApp.tsx +31 -19
- package/src/gist/GistStatus.tsx +22 -8
- 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/index.ts +1 -0
- package/src/mermaid.ts +29 -1
- package/src/parseViewer.test.ts +185 -0
- package/src/parseViewer.ts +27 -3
- package/src/serve.ts +9 -1
- package/src/share.ts +5 -1
- package/src/viewer/CallStackDiff.tsx +26 -6
- package/src/viewer/FileExcerpt.tsx +28 -1
- package/src/viewer/SourceDiffPanel.tsx +131 -54
- package/src/viewer/TableOfContents.tsx +1 -0
- package/src/viewer/ViewerApp.tsx +4 -1
- 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 +46 -2
- package/src/viewer/viewerMode.ts +1 -1
- package/src/viewer/vite-env.d.ts +2 -1
|
@@ -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
|
+
}
|
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
|
}
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { execFile } from "node:child_process";
|
|
3
|
+
import fs from "node:fs/promises";
|
|
4
|
+
import os from "node:os";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
import { fileURLToPath } from "node:url";
|
|
7
|
+
import { promisify } from "node:util";
|
|
8
|
+
import test from "node:test";
|
|
9
|
+
import { DiffmapMermaidError } from "./errors.js";
|
|
10
|
+
import { parseViewerDocument } from "./parseViewer.js";
|
|
11
|
+
import { shareMarkdownFile } from "./share.js";
|
|
12
|
+
import { startServer } from "./serve.js";
|
|
13
|
+
|
|
14
|
+
const execFileAsync = promisify(execFile);
|
|
15
|
+
const srcDir = fileURLToPath(new URL(".", import.meta.url));
|
|
16
|
+
const packageRoot = path.resolve(srcDir, "..");
|
|
17
|
+
const cliPath = path.join(srcDir, "cli.ts");
|
|
18
|
+
const sampleSpec = path.join(packageRoot, "fixtures", "sample-spec.md");
|
|
19
|
+
|
|
20
|
+
function brokenMermaidSpec(body = "flwochart TD\n A --> B") {
|
|
21
|
+
return `# Broken mermaid\n\n\`\`\`mermaid\n${body}\n\`\`\`\n`;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
async function withTempSpec(
|
|
25
|
+
source: string,
|
|
26
|
+
run: (filePath: string) => Promise<void>,
|
|
27
|
+
) {
|
|
28
|
+
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "diffmap-mermaid-"));
|
|
29
|
+
const filePath = path.join(dir, "broken.md");
|
|
30
|
+
await fs.writeFile(filePath, source);
|
|
31
|
+
try {
|
|
32
|
+
await run(filePath);
|
|
33
|
+
} finally {
|
|
34
|
+
await fs.rm(dir, { recursive: true, force: true });
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
async function runCli(args: string[]) {
|
|
39
|
+
try {
|
|
40
|
+
const result = await execFileAsync(
|
|
41
|
+
process.execPath,
|
|
42
|
+
["--import", "tsx", cliPath, ...args],
|
|
43
|
+
{
|
|
44
|
+
cwd: packageRoot,
|
|
45
|
+
timeout: 20_000,
|
|
46
|
+
maxBuffer: 1_000_000,
|
|
47
|
+
},
|
|
48
|
+
);
|
|
49
|
+
return { exitCode: 0, stdout: result.stdout, stderr: result.stderr };
|
|
50
|
+
} catch (cause) {
|
|
51
|
+
if (typeof cause !== "object" || !cause) throw cause;
|
|
52
|
+
const exitCode =
|
|
53
|
+
"code" in cause && typeof cause.code === "number"
|
|
54
|
+
? cause.code
|
|
55
|
+
: undefined;
|
|
56
|
+
const stdout =
|
|
57
|
+
"stdout" in cause && typeof cause.stdout === "string" ? cause.stdout : "";
|
|
58
|
+
const stderr =
|
|
59
|
+
"stderr" in cause && typeof cause.stderr === "string" ? cause.stderr : "";
|
|
60
|
+
return { exitCode, stdout, stderr };
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
test("parseViewerDocument accepts a spec with valid mermaid", async () => {
|
|
65
|
+
const source = await fs.readFile(sampleSpec, "utf8");
|
|
66
|
+
const document = parseViewerDocument(source, sampleSpec);
|
|
67
|
+
assert.equal(document instanceof Error, false);
|
|
68
|
+
if (document instanceof Error) return;
|
|
69
|
+
assert.equal(
|
|
70
|
+
document.nodes.some(
|
|
71
|
+
(node) => node.type === "view" && node.fence.kind === "mermaid",
|
|
72
|
+
),
|
|
73
|
+
true,
|
|
74
|
+
);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
test("parseViewerDocument fails on broken mermaid with file and diagram", () => {
|
|
78
|
+
const source = brokenMermaidSpec();
|
|
79
|
+
const parsed = parseViewerDocument(source, "broken.md");
|
|
80
|
+
assert.equal(parsed instanceof DiffmapMermaidError, true);
|
|
81
|
+
if (!(parsed instanceof DiffmapMermaidError)) return;
|
|
82
|
+
assert.equal(parsed.path, "broken.md");
|
|
83
|
+
assert.equal(parsed.where, "diagram 1, line 3");
|
|
84
|
+
assert.match(String(parsed.reason), /Invalid mermaid header: "flwochart TD"/);
|
|
85
|
+
assert.equal(
|
|
86
|
+
parsed.message,
|
|
87
|
+
'diffmap could not parse mermaid in broken.md (diagram 1, line 3): Invalid mermaid header: "flwochart TD". Expected "graph TD", "flowchart LR", "stateDiagram-v2", etc.',
|
|
88
|
+
);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
test("parseViewerDocument names the second mermaid diagram when it is invalid", () => {
|
|
92
|
+
const source = `# Two diagrams
|
|
93
|
+
|
|
94
|
+
\`\`\`mermaid
|
|
95
|
+
flowchart TD
|
|
96
|
+
A --> B
|
|
97
|
+
\`\`\`
|
|
98
|
+
|
|
99
|
+
\`\`\`mermaid
|
|
100
|
+
not a diagram
|
|
101
|
+
\`\`\`
|
|
102
|
+
`;
|
|
103
|
+
const parsed = parseViewerDocument(source, "two.md");
|
|
104
|
+
assert.equal(parsed instanceof DiffmapMermaidError, true);
|
|
105
|
+
if (!(parsed instanceof DiffmapMermaidError)) return;
|
|
106
|
+
assert.equal(parsed.where, "diagram 2, line 8");
|
|
107
|
+
assert.match(parsed.message, /in two\.md \(diagram 2, line 8\)/);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
test("parseViewerDocument fails on invalid MDC mermaid", () => {
|
|
111
|
+
const source = `# MDC
|
|
112
|
+
|
|
113
|
+
::mermaid
|
|
114
|
+
flwochart TD
|
|
115
|
+
A --> B
|
|
116
|
+
::
|
|
117
|
+
`;
|
|
118
|
+
const parsed = parseViewerDocument(source, "mdc.md");
|
|
119
|
+
assert.equal(parsed instanceof DiffmapMermaidError, true);
|
|
120
|
+
if (!(parsed instanceof DiffmapMermaidError)) return;
|
|
121
|
+
assert.equal(parsed.where, "diagram 1, line 3");
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
test("startServer does not listen when mermaid is invalid", async () => {
|
|
125
|
+
await withTempSpec(brokenMermaidSpec(), async (filePath) => {
|
|
126
|
+
const started = await startServer({
|
|
127
|
+
filePath,
|
|
128
|
+
workspaceRoot: path.dirname(filePath),
|
|
129
|
+
port: 0,
|
|
130
|
+
});
|
|
131
|
+
assert.equal(started instanceof DiffmapMermaidError, true);
|
|
132
|
+
if (!(started instanceof DiffmapMermaidError)) return;
|
|
133
|
+
assert.equal(started.path, filePath);
|
|
134
|
+
assert.equal(started.where, "diagram 1, line 3");
|
|
135
|
+
});
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
test("shareMarkdownFile does not call gh when mermaid is invalid", async () => {
|
|
139
|
+
await withTempSpec(brokenMermaidSpec(), async (filePath) => {
|
|
140
|
+
const shared = await shareMarkdownFile({
|
|
141
|
+
filePath,
|
|
142
|
+
isPublic: false,
|
|
143
|
+
});
|
|
144
|
+
assert.equal(shared instanceof DiffmapMermaidError, true);
|
|
145
|
+
if (!(shared instanceof DiffmapMermaidError)) return;
|
|
146
|
+
assert.equal(shared.path, filePath);
|
|
147
|
+
});
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
function mermaidCliMessage(filePath: string) {
|
|
151
|
+
return `diffmap could not parse mermaid in ${filePath} (diagram 1, line 3): Invalid mermaid header: "flwochart TD". Expected "graph TD", "flowchart LR", "stateDiagram-v2", etc.`;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
test("diffmap serve exits non-zero on broken mermaid", async () => {
|
|
155
|
+
await withTempSpec(brokenMermaidSpec(), async (filePath) => {
|
|
156
|
+
const result = await runCli(["serve", filePath]);
|
|
157
|
+
assert.equal(result.exitCode, 1);
|
|
158
|
+
assert.equal(
|
|
159
|
+
`${result.stdout}${result.stderr}`,
|
|
160
|
+
`Error (DIFFMAP): ${mermaidCliMessage(filePath)}\n`,
|
|
161
|
+
);
|
|
162
|
+
});
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
test("bare diffmap <file.md> exits non-zero on broken mermaid", async () => {
|
|
166
|
+
await withTempSpec(brokenMermaidSpec(), async (filePath) => {
|
|
167
|
+
const result = await runCli([filePath]);
|
|
168
|
+
assert.equal(result.exitCode, 1);
|
|
169
|
+
assert.equal(
|
|
170
|
+
`${result.stdout}${result.stderr}`,
|
|
171
|
+
`Error (DIFFMAP): ${mermaidCliMessage(filePath)}\n`,
|
|
172
|
+
);
|
|
173
|
+
});
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
test("diffmap share exits non-zero on broken mermaid", async () => {
|
|
177
|
+
await withTempSpec(brokenMermaidSpec(), async (filePath) => {
|
|
178
|
+
const result = await runCli(["share", filePath]);
|
|
179
|
+
assert.equal(result.exitCode, 1);
|
|
180
|
+
assert.equal(
|
|
181
|
+
`${result.stdout}${result.stderr}`.trimEnd(),
|
|
182
|
+
`code: DIFFMAP\nmessage: ${JSON.stringify(mermaidCliMessage(filePath))}`,
|
|
183
|
+
);
|
|
184
|
+
});
|
|
185
|
+
});
|
package/src/parseViewer.ts
CHANGED
|
@@ -3,6 +3,7 @@ import { parsePatchFiles, type CodeViewDiffItem } from "@pierre/diffs";
|
|
|
3
3
|
import { parseAST } from "md4x/napi";
|
|
4
4
|
import type { ComarkElement, ComarkNode } from "md4x/napi";
|
|
5
5
|
import { DiffmapAnnotationError, DiffmapParseError } from "./errors.js";
|
|
6
|
+
import { parseMermaidDiagram } from "./mermaid.js";
|
|
6
7
|
import { parseFence, type Fence } from "./parseFence.js";
|
|
7
8
|
|
|
8
9
|
export type ViewerDocument = {
|
|
@@ -57,7 +58,7 @@ export type ViewerElementAttrs = {
|
|
|
57
58
|
align: string | undefined;
|
|
58
59
|
};
|
|
59
60
|
|
|
60
|
-
export function parseViewerDocument(source: string) {
|
|
61
|
+
export function parseViewerDocument(source: string, path = "spec") {
|
|
61
62
|
const tree = errore.try({
|
|
62
63
|
try: () => parseAST(source),
|
|
63
64
|
catch: (cause) => new DiffmapParseError({ cause }),
|
|
@@ -106,8 +107,17 @@ export function parseViewerDocument(source: string) {
|
|
|
106
107
|
return fence.annotations.map((link) => link.annotation);
|
|
107
108
|
return [];
|
|
108
109
|
});
|
|
109
|
-
|
|
110
|
-
|
|
110
|
+
const mermaidFences = fences.flatMap((fence) =>
|
|
111
|
+
fence.kind === "mermaid" ? [fence] : [],
|
|
112
|
+
);
|
|
113
|
+
for (const [offset, fence] of mermaidFences.entries()) {
|
|
114
|
+
const mermaidIndex = offset + 1;
|
|
115
|
+
const parsedMermaid = parseMermaidDiagram({
|
|
116
|
+
source: fence.source,
|
|
117
|
+
path,
|
|
118
|
+
where: mermaidWhere(source, mermaidIndex),
|
|
119
|
+
});
|
|
120
|
+
if (parsedMermaid instanceof Error) return parsedMermaid;
|
|
111
121
|
const targets = new Set<string>();
|
|
112
122
|
for (const link of fence.annotations) {
|
|
113
123
|
const target = `${link.target}:${link.id}`;
|
|
@@ -186,6 +196,20 @@ function collectFences(nodes: ViewerNode[]): Fence[] {
|
|
|
186
196
|
});
|
|
187
197
|
}
|
|
188
198
|
|
|
199
|
+
function mermaidWhere(source: string, index: number) {
|
|
200
|
+
const line = mermaidFenceLine(source, index);
|
|
201
|
+
if (line === undefined) return `diagram ${index}`;
|
|
202
|
+
return `diagram ${index}, line ${line}`;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function mermaidFenceLine(source: string, index: number) {
|
|
206
|
+
const match = [...source.matchAll(/^[ \t]*(`{3,}mermaid\b|::mermaid\b)/gm)][
|
|
207
|
+
index - 1
|
|
208
|
+
];
|
|
209
|
+
if (match === undefined || match.index === undefined) return undefined;
|
|
210
|
+
return source.slice(0, match.index).split("\n").length;
|
|
211
|
+
}
|
|
212
|
+
|
|
189
213
|
const headingTags = new Set(["h1", "h2", "h3", "h4", "h5", "h6"]);
|
|
190
214
|
|
|
191
215
|
function collectHeadings(nodes: ViewerNode[]): ViewerHeading[] {
|
package/src/serve.ts
CHANGED
|
@@ -5,6 +5,7 @@ import { createServer, type ViteDevServer } from "vite";
|
|
|
5
5
|
import { diffmapContentPlugin } from "./contentPlugin.js";
|
|
6
6
|
import { DiffmapFileError, DiffmapServeError } from "./errors.js";
|
|
7
7
|
import { extractTitle } from "./extractDocument.js";
|
|
8
|
+
import { parseViewerDocument } from "./parseViewer.js";
|
|
8
9
|
import { findDefinition, readSourceReference } from "./definitions.js";
|
|
9
10
|
import {
|
|
10
11
|
registerRunningDiffmap,
|
|
@@ -54,6 +55,9 @@ export async function startServer(input: StartServerInput) {
|
|
|
54
55
|
);
|
|
55
56
|
if (source instanceof Error) return source;
|
|
56
57
|
|
|
58
|
+
const parsed = parseViewerDocument(source, filePath);
|
|
59
|
+
if (parsed instanceof Error) return parsed;
|
|
60
|
+
|
|
57
61
|
const title = extractTitle(source);
|
|
58
62
|
|
|
59
63
|
let vite: ViteDevServer | undefined;
|
|
@@ -258,11 +262,15 @@ async function handleDiffmapRequest(input: {
|
|
|
258
262
|
return;
|
|
259
263
|
}
|
|
260
264
|
if (parsed.pathname === "/__diffmap/meta" && input.method === "GET") {
|
|
265
|
+
const source = await fs
|
|
266
|
+
.readFile(input.filePath, "utf8")
|
|
267
|
+
.catch(() => undefined);
|
|
268
|
+
const title = source === undefined ? input.title : extractTitle(source);
|
|
261
269
|
input.res.statusCode = 200;
|
|
262
270
|
input.res.setHeader("content-type", "application/json; charset=utf-8");
|
|
263
271
|
input.res.end(
|
|
264
272
|
JSON.stringify({
|
|
265
|
-
title
|
|
273
|
+
title,
|
|
266
274
|
file: input.filePath,
|
|
267
275
|
pid: process.pid,
|
|
268
276
|
}),
|
package/src/share.ts
CHANGED
|
@@ -5,6 +5,7 @@ import { promisify } from "node:util";
|
|
|
5
5
|
import { DiffmapFileError, DiffmapShareError } from "./errors.js";
|
|
6
6
|
import { extractTitle } from "./extractDocument.js";
|
|
7
7
|
import { extractGistId, gistViewerUrl } from "./gist/route.ts";
|
|
8
|
+
import { parseViewerDocument } from "./parseViewer.js";
|
|
8
9
|
|
|
9
10
|
const execFileAsync = promisify(execFile);
|
|
10
11
|
|
|
@@ -17,7 +18,7 @@ export type ShareResult = {
|
|
|
17
18
|
export async function shareMarkdownFile(input: {
|
|
18
19
|
filePath: string;
|
|
19
20
|
isPublic: boolean;
|
|
20
|
-
})
|
|
21
|
+
}) {
|
|
21
22
|
const filePath = path.resolve(input.filePath);
|
|
22
23
|
const source = await fs.readFile(filePath, "utf8").catch(
|
|
23
24
|
(cause) =>
|
|
@@ -29,6 +30,9 @@ export async function shareMarkdownFile(input: {
|
|
|
29
30
|
);
|
|
30
31
|
if (source instanceof Error) return source;
|
|
31
32
|
|
|
33
|
+
const parsed = parseViewerDocument(source, filePath);
|
|
34
|
+
if (parsed instanceof Error) return parsed;
|
|
35
|
+
|
|
32
36
|
const auth = await runGh(["auth", "status"]);
|
|
33
37
|
if (auth instanceof DiffmapShareError) return auth;
|
|
34
38
|
|