@tanishqkancharla/diffmap 0.1.3
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/LICENSE +21 -0
- package/README.md +212 -0
- package/bin.js +5 -0
- package/index.html +38 -0
- package/package.json +71 -0
- package/src/annotations.ts +82 -0
- package/src/cli.ts +94 -0
- package/src/codeFont.ts +2 -0
- package/src/contentPlugin.ts +47 -0
- package/src/definitions.ts +320 -0
- package/src/diagramAnnotations.ts +34 -0
- package/src/errors.ts +41 -0
- package/src/extractDocument.ts +20 -0
- package/src/gist/GistApp.tsx +292 -0
- package/src/gist/GistStatus.tsx +137 -0
- package/src/gist/fetchGist.ts +128 -0
- package/src/gist/main.tsx +25 -0
- package/src/gist/route.test.ts +90 -0
- package/src/gist/route.ts +134 -0
- package/src/index.ts +22 -0
- package/src/invokeCwd.ts +10 -0
- package/src/mermaid.ts +29 -0
- package/src/normalizeDiff.ts +39 -0
- package/src/parseFence.ts +91 -0
- package/src/parseViewer.ts +399 -0
- package/src/registry.ts +121 -0
- package/src/serve.ts +350 -0
- package/src/share.ts +94 -0
- package/src/viewer/CallStackDiff.tsx +156 -0
- package/src/viewer/Check.tsx +24 -0
- package/src/viewer/CodeDiff.tsx +26 -0
- package/src/viewer/ComarkView.tsx +209 -0
- package/src/viewer/DiffButton.tsx +24 -0
- package/src/viewer/DoneButton.tsx +32 -0
- package/src/viewer/Fence.tsx +51 -0
- package/src/viewer/FileExcerpt.tsx +65 -0
- package/src/viewer/HtmlBlock.tsx +19 -0
- package/src/viewer/HtmlPlaceholder.tsx +29 -0
- package/src/viewer/MermaidBlock.tsx +126 -0
- package/src/viewer/SourceDiffPanel.tsx +305 -0
- package/src/viewer/TableOfContents.tsx +211 -0
- package/src/viewer/ViewerApp.tsx +257 -0
- package/src/viewer/diagramLinks.ts +74 -0
- package/src/viewer/diffsTheme.ts +4 -0
- package/src/viewer/main.tsx +17 -0
- package/src/viewer/pierre.ts +47 -0
- package/src/viewer/sourceNavigation.ts +104 -0
- package/src/viewer/styles.css +9 -0
- package/src/viewer/viewerMode.ts +9 -0
- package/src/viewer/vite-env.d.ts +4 -0
- package/vite.config.ts +41 -0
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import type { ReactNode } from "react";
|
|
2
|
+
import {
|
|
3
|
+
backgroundColor,
|
|
4
|
+
border,
|
|
5
|
+
colors,
|
|
6
|
+
flex,
|
|
7
|
+
flexItem,
|
|
8
|
+
Link,
|
|
9
|
+
P,
|
|
10
|
+
spacing,
|
|
11
|
+
text,
|
|
12
|
+
Thinking,
|
|
13
|
+
} from "maui";
|
|
14
|
+
import { style, useStyles } from "purse-styles";
|
|
15
|
+
import { githubGistUrl } from "./route.ts";
|
|
16
|
+
|
|
17
|
+
export function GistStatus(props: {
|
|
18
|
+
title: string;
|
|
19
|
+
children: ReactNode;
|
|
20
|
+
gistId?: string;
|
|
21
|
+
gistUrl?: string;
|
|
22
|
+
}) {
|
|
23
|
+
const shell = useStyles(styles.shell);
|
|
24
|
+
const header = useStyles(styles.header);
|
|
25
|
+
const heading = useStyles(styles.heading);
|
|
26
|
+
const body = useStyles(styles.body);
|
|
27
|
+
const title = useStyles(styles.title);
|
|
28
|
+
const gistUrl =
|
|
29
|
+
props.gistUrl ??
|
|
30
|
+
(props.gistId === undefined ? undefined : githubGistUrl(props.gistId));
|
|
31
|
+
|
|
32
|
+
return (
|
|
33
|
+
<div className={shell}>
|
|
34
|
+
<header className={header}>
|
|
35
|
+
<div className={heading}>diffmap</div>
|
|
36
|
+
</header>
|
|
37
|
+
<main className={body}>
|
|
38
|
+
<h1 className={title}>{props.title}</h1>
|
|
39
|
+
{props.children}
|
|
40
|
+
{gistUrl !== undefined && (
|
|
41
|
+
<P>
|
|
42
|
+
<Link href={gistUrl}>Open gist on GitHub</Link>
|
|
43
|
+
</P>
|
|
44
|
+
)}
|
|
45
|
+
</main>
|
|
46
|
+
</div>
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function GistLoading() {
|
|
51
|
+
const row = useStyles(styles.loading);
|
|
52
|
+
return (
|
|
53
|
+
<GistStatus title="Loading gist…">
|
|
54
|
+
<div className={row}>
|
|
55
|
+
<Thinking />
|
|
56
|
+
</div>
|
|
57
|
+
</GistStatus>
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function GistLanding() {
|
|
62
|
+
const code = useStyles(styles.code);
|
|
63
|
+
return (
|
|
64
|
+
<GistStatus title="diffmap">
|
|
65
|
+
<P>
|
|
66
|
+
A hosted viewer for diffmap walkthroughs. This page fetches a GitHub
|
|
67
|
+
gist in your browser. There is no server holding the markdown.
|
|
68
|
+
</P>
|
|
69
|
+
<P>
|
|
70
|
+
Canonical URL:{" "}
|
|
71
|
+
<code className={code}>https://diffmap.dev/g/<gistId></code>.
|
|
72
|
+
Optional file:{" "}
|
|
73
|
+
<code className={code}>/g/<gistId>/<file.md></code>. Heading
|
|
74
|
+
hashes stay in the fragment, same as the local viewer.
|
|
75
|
+
</P>
|
|
76
|
+
<P>
|
|
77
|
+
Share a secret gist (requires <code className={code}>gh</code>
|
|
78
|
+
):
|
|
79
|
+
</P>
|
|
80
|
+
<P>
|
|
81
|
+
<code className={code}>npx diffmap share path/to.md</code>
|
|
82
|
+
</P>
|
|
83
|
+
<P>
|
|
84
|
+
Secret gists are unlisted, not private. Anyone with the id can read
|
|
85
|
+
them. Unauthenticated GitHub API is 60 requests per hour per IP.
|
|
86
|
+
</P>
|
|
87
|
+
<P>
|
|
88
|
+
<Link href="https://github.com/tanishqkancharla/diffmap">
|
|
89
|
+
tanishqkancharla/diffmap
|
|
90
|
+
</Link>
|
|
91
|
+
</P>
|
|
92
|
+
</GistStatus>
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const styles = {
|
|
97
|
+
shell: style(flex({ direction: "column" }), {
|
|
98
|
+
width: "100%",
|
|
99
|
+
height: "100vh",
|
|
100
|
+
minWidth: 0,
|
|
101
|
+
minHeight: 0,
|
|
102
|
+
overflow: "hidden",
|
|
103
|
+
backgroundColor: colors.gray[4],
|
|
104
|
+
}),
|
|
105
|
+
header: style(
|
|
106
|
+
flex({ direction: "row", align: "center", justify: "between" }),
|
|
107
|
+
spacing.padding({ x: 6, y: 3 }),
|
|
108
|
+
flexItem({ size: "hug" }),
|
|
109
|
+
border(["bottom"], "border"),
|
|
110
|
+
{
|
|
111
|
+
minWidth: 0,
|
|
112
|
+
backgroundColor: backgroundColor.app,
|
|
113
|
+
},
|
|
114
|
+
),
|
|
115
|
+
heading: style(text({ size: "md", fontWeight: 600, color: "highContrast" }), {
|
|
116
|
+
minWidth: 0,
|
|
117
|
+
}),
|
|
118
|
+
body: style(spacing.padding({ x: 12, y: 12 }), {
|
|
119
|
+
flex: "1 1 auto",
|
|
120
|
+
minWidth: 0,
|
|
121
|
+
minHeight: 0,
|
|
122
|
+
overflowY: "auto",
|
|
123
|
+
backgroundColor: backgroundColor.app,
|
|
124
|
+
maxWidth: "72ch",
|
|
125
|
+
width: "100%",
|
|
126
|
+
marginInline: "auto",
|
|
127
|
+
}),
|
|
128
|
+
title: style(text({ size: "xl", fontWeight: 600, color: "highContrast" }), {
|
|
129
|
+
marginBottom: spacing.value(4),
|
|
130
|
+
}),
|
|
131
|
+
loading: style(flex({ direction: "row", align: "center" }), {
|
|
132
|
+
minHeight: spacing.value(12),
|
|
133
|
+
}),
|
|
134
|
+
code: style(text({ size: "sm", color: "highContrast" }), {
|
|
135
|
+
fontFamily: "ui-monospace, SFMono-Regular, Menlo, monospace",
|
|
136
|
+
}),
|
|
137
|
+
};
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import { githubGistUrl, markdownFileNames, pickMarkdownFile } from "./route.ts";
|
|
2
|
+
|
|
3
|
+
export type GistMarkdownFile = {
|
|
4
|
+
name: string;
|
|
5
|
+
content: string;
|
|
6
|
+
};
|
|
7
|
+
|
|
8
|
+
export type GistDocument = {
|
|
9
|
+
id: string;
|
|
10
|
+
htmlUrl: string;
|
|
11
|
+
files: GistMarkdownFile[];
|
|
12
|
+
chosen: GistMarkdownFile;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
export type GistLoadResult =
|
|
16
|
+
| { status: "ok"; gist: GistDocument }
|
|
17
|
+
| { status: "not-found" }
|
|
18
|
+
| { status: "forbidden" }
|
|
19
|
+
| { status: "no-markdown"; htmlUrl: string }
|
|
20
|
+
| { status: "missing-file"; htmlUrl: string; files: GistMarkdownFile[] }
|
|
21
|
+
| { status: "error"; message: string };
|
|
22
|
+
|
|
23
|
+
type GistApiFile = {
|
|
24
|
+
filename?: string;
|
|
25
|
+
truncated?: boolean;
|
|
26
|
+
content?: string;
|
|
27
|
+
raw_url?: string;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
type GistApiResponse = {
|
|
31
|
+
id?: string;
|
|
32
|
+
html_url?: string;
|
|
33
|
+
files?: Record<string, GistApiFile>;
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
export async function loadGistMarkdown(
|
|
37
|
+
gistId: string,
|
|
38
|
+
fileName: string | undefined,
|
|
39
|
+
signal?: AbortSignal,
|
|
40
|
+
): Promise<GistLoadResult> {
|
|
41
|
+
const response = await fetch(`https://api.github.com/gists/${gistId}`, {
|
|
42
|
+
headers: { Accept: "application/vnd.github+json" },
|
|
43
|
+
signal,
|
|
44
|
+
}).catch((cause: unknown) => cause);
|
|
45
|
+
|
|
46
|
+
if (response instanceof Error) {
|
|
47
|
+
if (response.name === "AbortError") throw response;
|
|
48
|
+
return { status: "error", message: "Failed to load this gist." };
|
|
49
|
+
}
|
|
50
|
+
if (!(response instanceof Response)) {
|
|
51
|
+
return { status: "error", message: "Failed to load this gist." };
|
|
52
|
+
}
|
|
53
|
+
if (response.status === 404) return { status: "not-found" };
|
|
54
|
+
if (response.status === 403) return { status: "forbidden" };
|
|
55
|
+
if (!response.ok) {
|
|
56
|
+
return {
|
|
57
|
+
status: "error",
|
|
58
|
+
message: `GitHub returned ${String(response.status)}.`,
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const payload = (await response.json().catch(() => undefined)) as
|
|
63
|
+
| GistApiResponse
|
|
64
|
+
| undefined;
|
|
65
|
+
if (payload === undefined) {
|
|
66
|
+
return { status: "error", message: "GitHub returned an invalid gist." };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const htmlUrl =
|
|
70
|
+
typeof payload.html_url === "string" && payload.html_url.length > 0
|
|
71
|
+
? payload.html_url
|
|
72
|
+
: githubGistUrl(gistId);
|
|
73
|
+
const apiFiles = payload.files ?? {};
|
|
74
|
+
const names = markdownFileNames(Object.keys(apiFiles));
|
|
75
|
+
if (names.length === 0) return { status: "no-markdown", htmlUrl };
|
|
76
|
+
|
|
77
|
+
const files: GistMarkdownFile[] = [];
|
|
78
|
+
for (const name of names) {
|
|
79
|
+
const file = apiFiles[name];
|
|
80
|
+
if (file === undefined) continue;
|
|
81
|
+
const content = await readGistFile(file, signal);
|
|
82
|
+
if (content instanceof Error) {
|
|
83
|
+
return { status: "error", message: content.message };
|
|
84
|
+
}
|
|
85
|
+
files.push({ name, content });
|
|
86
|
+
}
|
|
87
|
+
if (files.length === 0) return { status: "no-markdown", htmlUrl };
|
|
88
|
+
|
|
89
|
+
const chosenName = pickMarkdownFile(
|
|
90
|
+
files.map((file) => file.name),
|
|
91
|
+
fileName,
|
|
92
|
+
);
|
|
93
|
+
const chosen = files.find((file) => file.name === chosenName);
|
|
94
|
+
if (chosen === undefined) {
|
|
95
|
+
return { status: "missing-file", htmlUrl, files };
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
return {
|
|
99
|
+
status: "ok",
|
|
100
|
+
gist: {
|
|
101
|
+
id: typeof payload.id === "string" ? payload.id : gistId,
|
|
102
|
+
htmlUrl,
|
|
103
|
+
files,
|
|
104
|
+
chosen,
|
|
105
|
+
},
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
async function readGistFile(file: GistApiFile, signal?: AbortSignal) {
|
|
110
|
+
const name = file.filename ?? "file";
|
|
111
|
+
if (file.truncated !== true && typeof file.content === "string") {
|
|
112
|
+
return file.content;
|
|
113
|
+
}
|
|
114
|
+
if (file.raw_url === undefined || file.raw_url.length === 0) {
|
|
115
|
+
return new Error(`Gist file "${name}" is truncated and has no raw URL.`);
|
|
116
|
+
}
|
|
117
|
+
const raw = await fetch(file.raw_url, { signal }).catch(
|
|
118
|
+
(cause: unknown) => cause,
|
|
119
|
+
);
|
|
120
|
+
if (raw instanceof Error) {
|
|
121
|
+
if (raw.name === "AbortError") throw raw;
|
|
122
|
+
return new Error(`Could not fetch gist file "${name}".`);
|
|
123
|
+
}
|
|
124
|
+
if (!(raw instanceof Response) || !raw.ok) {
|
|
125
|
+
return new Error(`Could not fetch gist file "${name}".`);
|
|
126
|
+
}
|
|
127
|
+
return raw.text();
|
|
128
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { StrictMode } from "react";
|
|
2
|
+
import { createRoot } from "react-dom/client";
|
|
3
|
+
import { MauiProvider } from "maui";
|
|
4
|
+
import { rewritePiGistUrl } from "./route.ts";
|
|
5
|
+
import { GistApp } from "./GistApp.tsx";
|
|
6
|
+
import "../viewer/styles.css";
|
|
7
|
+
|
|
8
|
+
const rewritten = rewritePiGistUrl(
|
|
9
|
+
window.location.pathname,
|
|
10
|
+
window.location.hash,
|
|
11
|
+
);
|
|
12
|
+
if (rewritten !== undefined) {
|
|
13
|
+
window.history.replaceState({}, "", rewritten);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const root = document.getElementById("root");
|
|
17
|
+
if (root === null) throw new Error("diffmap root element is missing");
|
|
18
|
+
|
|
19
|
+
createRoot(root).render(
|
|
20
|
+
<StrictMode>
|
|
21
|
+
<MauiProvider>
|
|
22
|
+
<GistApp />
|
|
23
|
+
</MauiProvider>
|
|
24
|
+
</StrictMode>,
|
|
25
|
+
);
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import test from "node:test";
|
|
3
|
+
import {
|
|
4
|
+
extractGistId,
|
|
5
|
+
gistPath,
|
|
6
|
+
gistViewerUrl,
|
|
7
|
+
parseGistPath,
|
|
8
|
+
parsePiGistHash,
|
|
9
|
+
pickMarkdownFile,
|
|
10
|
+
rewritePiGistUrl,
|
|
11
|
+
} from "./route.ts";
|
|
12
|
+
|
|
13
|
+
const id = "0123456789abcdef0123456789abcdef";
|
|
14
|
+
|
|
15
|
+
test("extractGistId accepts hex and gist URLs", () => {
|
|
16
|
+
assert.equal(extractGistId(id), id);
|
|
17
|
+
assert.equal(extractGistId(`https://gist.github.com/tanishq/${id}`), id);
|
|
18
|
+
assert.equal(extractGistId(`gist.github.com/${id}`), id);
|
|
19
|
+
assert.equal(extractGistId("not-a-gist"), undefined);
|
|
20
|
+
assert.equal(extractGistId("abcd"), undefined);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
test("parseGistPath reads /g/:id/:file and heading-safe home", () => {
|
|
24
|
+
assert.deepEqual(parseGistPath("/"), { kind: "home" });
|
|
25
|
+
assert.deepEqual(parseGistPath("/g"), { kind: "empty" });
|
|
26
|
+
assert.deepEqual(parseGistPath("/g/"), { kind: "empty" });
|
|
27
|
+
assert.deepEqual(parseGistPath(`/g/${id}`), {
|
|
28
|
+
kind: "gist",
|
|
29
|
+
gistId: id,
|
|
30
|
+
file: undefined,
|
|
31
|
+
});
|
|
32
|
+
assert.deepEqual(parseGistPath(`/g/${id}/walkthrough.md`), {
|
|
33
|
+
kind: "gist",
|
|
34
|
+
gistId: id,
|
|
35
|
+
file: "walkthrough.md",
|
|
36
|
+
});
|
|
37
|
+
assert.deepEqual(parseGistPath(`/g/${id}/nested/spec.md`), {
|
|
38
|
+
kind: "gist",
|
|
39
|
+
gistId: id,
|
|
40
|
+
file: "nested/spec.md",
|
|
41
|
+
});
|
|
42
|
+
assert.equal(parseGistPath("/g/not-an-id").kind, "invalid");
|
|
43
|
+
assert.deepEqual(parseGistPath(`/g/https://gist.github.com/tanishq/${id}`), {
|
|
44
|
+
kind: "gist",
|
|
45
|
+
gistId: id,
|
|
46
|
+
file: undefined,
|
|
47
|
+
});
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test("Pi-shaped hash rewrites once to the canonical path", () => {
|
|
51
|
+
assert.deepEqual(parsePiGistHash(`#${id}`), { gistId: id, file: undefined });
|
|
52
|
+
assert.deepEqual(parsePiGistHash(`#${id}/README.md`), {
|
|
53
|
+
gistId: id,
|
|
54
|
+
file: "README.md",
|
|
55
|
+
});
|
|
56
|
+
assert.equal(parsePiGistHash("#implementation"), undefined);
|
|
57
|
+
assert.equal(rewritePiGistUrl(`/g/${id}`, `#${id}`), undefined);
|
|
58
|
+
assert.equal(rewritePiGistUrl("/g", `#${id}`), `/g/${id}`);
|
|
59
|
+
assert.equal(
|
|
60
|
+
rewritePiGistUrl("/g", `#${id}/spec.md&leafId=1`),
|
|
61
|
+
`/g/${id}/spec.md`,
|
|
62
|
+
);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test("pickMarkdownFile prefers README, then only md, then walkthrough/spec", () => {
|
|
66
|
+
assert.equal(
|
|
67
|
+
pickMarkdownFile(["notes.txt", "README.md", "a.md"], undefined),
|
|
68
|
+
"README.md",
|
|
69
|
+
);
|
|
70
|
+
assert.equal(pickMarkdownFile(["only.md"], undefined), "only.md");
|
|
71
|
+
assert.equal(
|
|
72
|
+
pickMarkdownFile(["z.md", "walkthrough.md"], undefined),
|
|
73
|
+
"walkthrough.md",
|
|
74
|
+
);
|
|
75
|
+
assert.equal(pickMarkdownFile(["b.md", "a.md"], undefined), "a.md");
|
|
76
|
+
assert.equal(
|
|
77
|
+
pickMarkdownFile(["README.md", "spec.md"], "spec.md"),
|
|
78
|
+
"spec.md",
|
|
79
|
+
);
|
|
80
|
+
assert.equal(pickMarkdownFile(["README.md"], "missing.md"), undefined);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test("printed viewer URL keeps gist id in the path", () => {
|
|
84
|
+
assert.equal(gistPath(id), `/g/${id}`);
|
|
85
|
+
assert.equal(gistViewerUrl(id), `https://diffmap.dev/g/${id}`);
|
|
86
|
+
assert.equal(
|
|
87
|
+
gistViewerUrl(id, "Phase 1.md"),
|
|
88
|
+
`https://diffmap.dev/g/${id}/Phase%201.md`,
|
|
89
|
+
);
|
|
90
|
+
});
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
export const DIFFMAP_ORIGIN = "https://diffmap.dev";
|
|
2
|
+
export const GIST_ID_PATTERN = /^[0-9a-f]{32}$/i;
|
|
3
|
+
const GIST_URL_PATTERN =
|
|
4
|
+
/(?:https?:\/\/)?gist\.github\.com\/(?:[^/]+\/)?([0-9a-f]{32})/i;
|
|
5
|
+
const MARKDOWN_EXT = /\.(?:md|mdx)$/i;
|
|
6
|
+
|
|
7
|
+
export type GistRoute =
|
|
8
|
+
| { kind: "home" }
|
|
9
|
+
| { kind: "empty" }
|
|
10
|
+
| { kind: "invalid"; value: string }
|
|
11
|
+
| { kind: "gist"; gistId: string; file: string | undefined };
|
|
12
|
+
|
|
13
|
+
export function extractGistId(value: string) {
|
|
14
|
+
const trimmed = value.trim();
|
|
15
|
+
if (trimmed.length === 0) return undefined;
|
|
16
|
+
if (GIST_ID_PATTERN.test(trimmed)) return trimmed.toLowerCase();
|
|
17
|
+
const fromUrl = GIST_URL_PATTERN.exec(trimmed);
|
|
18
|
+
if (fromUrl?.[1] !== undefined) return fromUrl[1].toLowerCase();
|
|
19
|
+
try {
|
|
20
|
+
const decoded = decodeURIComponent(trimmed);
|
|
21
|
+
if (decoded !== trimmed) return extractGistId(decoded);
|
|
22
|
+
} catch {
|
|
23
|
+
return undefined;
|
|
24
|
+
}
|
|
25
|
+
return undefined;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function parsePiGistHash(hash: string) {
|
|
29
|
+
const raw = hash.replace(/^#/, "").split("&", 1)[0] ?? "";
|
|
30
|
+
if (raw.length === 0) return undefined;
|
|
31
|
+
const [idPart, ...fileParts] = raw.split("/");
|
|
32
|
+
const gistId = extractGistId(idPart ?? "");
|
|
33
|
+
if (gistId === undefined) return undefined;
|
|
34
|
+
const file =
|
|
35
|
+
fileParts.length === 0
|
|
36
|
+
? undefined
|
|
37
|
+
: fileParts.map(decodePathSegment).join("/");
|
|
38
|
+
return { gistId, file };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function parseGistPath(pathname: string): GistRoute {
|
|
42
|
+
const path = normalizePath(pathname);
|
|
43
|
+
const parts = path.split("/").filter((part) => part.length > 0);
|
|
44
|
+
if (parts.length === 0) return { kind: "home" };
|
|
45
|
+
if (parts[0] !== "g") return { kind: "home" };
|
|
46
|
+
if (parts.length === 1) return { kind: "empty" };
|
|
47
|
+
|
|
48
|
+
const rest = parts.slice(1).join("/");
|
|
49
|
+
const nestedId = extractGistId(rest) ?? extractGistId(path);
|
|
50
|
+
const firstId = extractGistId(decodePathSegment(parts[1] ?? ""));
|
|
51
|
+
const gistId = firstId ?? nestedId;
|
|
52
|
+
if (gistId === undefined) {
|
|
53
|
+
return { kind: "invalid", value: decodePathSegment(parts[1] ?? "") };
|
|
54
|
+
}
|
|
55
|
+
const file =
|
|
56
|
+
firstId === undefined || parts.length === 2
|
|
57
|
+
? undefined
|
|
58
|
+
: parts.slice(2).map(decodePathSegment).join("/");
|
|
59
|
+
return {
|
|
60
|
+
kind: "gist",
|
|
61
|
+
gistId,
|
|
62
|
+
file: file === undefined || file.length === 0 ? undefined : file,
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function rewritePiGistUrl(pathname: string, hash: string) {
|
|
67
|
+
const parsed = parseGistPath(pathname);
|
|
68
|
+
if (parsed.kind !== "empty") return undefined;
|
|
69
|
+
const pi = parsePiGistHash(hash);
|
|
70
|
+
if (pi === undefined) return undefined;
|
|
71
|
+
return gistPath(pi.gistId, pi.file);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function gistPath(gistId: string, file?: string) {
|
|
75
|
+
if (file === undefined || file.length === 0) return `/g/${gistId}`;
|
|
76
|
+
return `/g/${gistId}/${file.split("/").map(encodeURIComponent).join("/")}`;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function gistViewerUrl(
|
|
80
|
+
gistId: string,
|
|
81
|
+
file?: string,
|
|
82
|
+
origin = DIFFMAP_ORIGIN,
|
|
83
|
+
) {
|
|
84
|
+
return `${origin}${gistPath(gistId, file)}`;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function githubGistUrl(gistId: string) {
|
|
88
|
+
return `https://gist.github.com/${gistId}`;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function markdownFileNames(files: string[]) {
|
|
92
|
+
return files
|
|
93
|
+
.filter((name) => MARKDOWN_EXT.test(name))
|
|
94
|
+
.toSorted((a, b) => a.localeCompare(b));
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export function pickMarkdownFile(
|
|
98
|
+
files: string[],
|
|
99
|
+
requested: string | undefined,
|
|
100
|
+
) {
|
|
101
|
+
const markdown = markdownFileNames(files);
|
|
102
|
+
if (requested !== undefined && requested.length > 0) {
|
|
103
|
+
const exact = files.find((name) => name === requested);
|
|
104
|
+
if (exact !== undefined && MARKDOWN_EXT.test(exact)) return exact;
|
|
105
|
+
return markdown.find(
|
|
106
|
+
(name) => name.toLowerCase() === requested.toLowerCase(),
|
|
107
|
+
);
|
|
108
|
+
}
|
|
109
|
+
const readme = markdown.find((name) => name.toLowerCase() === "readme.md");
|
|
110
|
+
if (readme !== undefined) return readme;
|
|
111
|
+
if (markdown.length === 1) return markdown[0];
|
|
112
|
+
for (const preferred of ["walkthrough.md", "spec.md"]) {
|
|
113
|
+
const found = markdown.find((name) => name.toLowerCase() === preferred);
|
|
114
|
+
if (found !== undefined) return found;
|
|
115
|
+
}
|
|
116
|
+
return markdown[0];
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function normalizePath(pathname: string) {
|
|
120
|
+
if (pathname.length === 0) return "/";
|
|
121
|
+
try {
|
|
122
|
+
return decodeURI(pathname);
|
|
123
|
+
} catch {
|
|
124
|
+
return pathname;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function decodePathSegment(value: string) {
|
|
129
|
+
try {
|
|
130
|
+
return decodeURIComponent(value);
|
|
131
|
+
} catch {
|
|
132
|
+
return value;
|
|
133
|
+
}
|
|
134
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
export { extractFences, extractTitle } from "./extractDocument.js";
|
|
2
|
+
export {
|
|
3
|
+
isCallStackSource,
|
|
4
|
+
parseFence,
|
|
5
|
+
pathFromDiffSource,
|
|
6
|
+
} from "./parseFence.js";
|
|
7
|
+
export type { Fence } from "./parseFence.js";
|
|
8
|
+
export { parseViewerDocument } from "./parseViewer.js";
|
|
9
|
+
export type {
|
|
10
|
+
ViewerDocument,
|
|
11
|
+
ViewerElement,
|
|
12
|
+
ViewerElementAttrs,
|
|
13
|
+
ViewerHeading,
|
|
14
|
+
ViewerHtml,
|
|
15
|
+
ViewerNode,
|
|
16
|
+
ViewerText,
|
|
17
|
+
ViewerView,
|
|
18
|
+
} from "./parseViewer.js";
|
|
19
|
+
export { gistViewerUrl } from "./gist/route.js";
|
|
20
|
+
export { shareMarkdownFile } from "./share.js";
|
|
21
|
+
export { startServer } from "./serve.js";
|
|
22
|
+
export type { FileExcerpt, StartServerInput, DiffmapServer } from "./serve.js";
|
package/src/invokeCwd.ts
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
|
|
3
|
+
function invokeWorkingDirectory() {
|
|
4
|
+
if (process.env.INIT_CWD === undefined) return process.cwd();
|
|
5
|
+
return process.env.INIT_CWD;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export function resolveFromInvokeCwd(filePath: string) {
|
|
9
|
+
return path.resolve(invokeWorkingDirectory(), filePath);
|
|
10
|
+
}
|
package/src/mermaid.ts
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { renderMermaidSVG } from "beautiful-mermaid";
|
|
2
|
+
import * as errore from "errore";
|
|
3
|
+
import { DiffmapMermaidError } from "./errors.js";
|
|
4
|
+
|
|
5
|
+
export function mermaidSvg(input: {
|
|
6
|
+
source: string;
|
|
7
|
+
bg: string;
|
|
8
|
+
fg: string;
|
|
9
|
+
accent: string;
|
|
10
|
+
muted: string;
|
|
11
|
+
surface: string;
|
|
12
|
+
border: string;
|
|
13
|
+
font: string;
|
|
14
|
+
}) {
|
|
15
|
+
return errore.try({
|
|
16
|
+
try: () =>
|
|
17
|
+
renderMermaidSVG(input.source, {
|
|
18
|
+
bg: input.bg,
|
|
19
|
+
fg: input.fg,
|
|
20
|
+
accent: input.accent,
|
|
21
|
+
muted: input.muted,
|
|
22
|
+
surface: input.surface,
|
|
23
|
+
border: input.border,
|
|
24
|
+
font: input.font,
|
|
25
|
+
transparent: true,
|
|
26
|
+
}),
|
|
27
|
+
catch: (cause) => new DiffmapMermaidError({ cause }),
|
|
28
|
+
});
|
|
29
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
export function toUnifiedDiff(source: string, path: string) {
|
|
2
|
+
if (/^--- /m.test(source) && /^\+\+\+ /m.test(source)) return source;
|
|
3
|
+
|
|
4
|
+
const lines = source.split("\n");
|
|
5
|
+
const first = lines[0];
|
|
6
|
+
const body =
|
|
7
|
+
first !== undefined && first.startsWith("// ") ? lines.slice(1) : lines;
|
|
8
|
+
const normalized = body.map((line) => {
|
|
9
|
+
if (
|
|
10
|
+
line.startsWith("+") ||
|
|
11
|
+
line.startsWith("-") ||
|
|
12
|
+
line.startsWith(" ") ||
|
|
13
|
+
line.startsWith("\\")
|
|
14
|
+
) {
|
|
15
|
+
return line;
|
|
16
|
+
}
|
|
17
|
+
return ` ${line}`;
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
let oldCount = 0;
|
|
21
|
+
let newCount = 0;
|
|
22
|
+
for (const line of normalized) {
|
|
23
|
+
if (line.startsWith("+")) {
|
|
24
|
+
newCount += 1;
|
|
25
|
+
continue;
|
|
26
|
+
}
|
|
27
|
+
if (line.startsWith("-")) {
|
|
28
|
+
oldCount += 1;
|
|
29
|
+
continue;
|
|
30
|
+
}
|
|
31
|
+
if (line.startsWith("\\")) continue;
|
|
32
|
+
oldCount += 1;
|
|
33
|
+
newCount += 1;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const oldRange = oldCount === 0 ? "-0,0" : `-1,${oldCount}`;
|
|
37
|
+
const newRange = newCount === 0 ? "+0,0" : `+1,${newCount}`;
|
|
38
|
+
return `--- a/${path}\n+++ b/${path}\n@@ ${oldRange} ${newRange} @@\n${normalized.join("\n")}\n`;
|
|
39
|
+
}
|