@tanishqkancharla/diffmap 0.1.3 → 0.1.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +25 -14
- package/package.json +3 -3
- package/src/cli.test.ts +50 -0
- package/src/cli.ts +126 -51
- package/src/contentPlugin.ts +5 -3
- package/src/errors.ts +6 -1
- package/src/gist/GistApp.tsx +34 -21
- package/src/gist/GistStatus.tsx +8 -43
- 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/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/index.ts +1 -0
- package/src/mermaid.ts +29 -1
- package/src/packageVersion.ts +7 -0
- package/src/parseViewer.test.ts +185 -0
- package/src/parseViewer.ts +27 -3
- package/src/serve.test.ts +306 -0
- package/src/serve.ts +23 -5
- package/src/share.ts +5 -1
- package/src/update.test.ts +19 -0
- package/src/update.ts +285 -0
- package/src/viewer/CallStackDiff.tsx +26 -6
- package/src/viewer/CloseServerButton.tsx +16 -0
- package/src/viewer/FileExcerpt.tsx +52 -3
- package/src/viewer/HtmlPlaceholder.tsx +6 -1
- package/src/viewer/SourceDiffPanel.tsx +155 -59
- package/src/viewer/TableOfContents.tsx +230 -39
- package/src/viewer/TocButton.tsx +27 -0
- package/src/viewer/ViewerApp.tsx +182 -47
- 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 +81 -3
- package/src/viewer/viewerMode.ts +1 -1
- package/src/viewer/vite-env.d.ts +2 -1
- package/vite.config.ts +1 -1
- package/src/viewer/Check.tsx +0 -24
- package/src/viewer/DoneButton.tsx +0 -32
|
@@ -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[] {
|
|
@@ -0,0 +1,306 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { execFile, spawn, type ChildProcess } from "node:child_process";
|
|
3
|
+
import fs from "node:fs/promises";
|
|
4
|
+
import net from "node:net";
|
|
5
|
+
import os from "node:os";
|
|
6
|
+
import path from "node:path";
|
|
7
|
+
import { fileURLToPath } from "node:url";
|
|
8
|
+
import { promisify } from "node:util";
|
|
9
|
+
import { setTimeout as delay } from "node:timers/promises";
|
|
10
|
+
import test from "node:test";
|
|
11
|
+
import { DiffmapServeError } from "./errors.js";
|
|
12
|
+
import { startServer, type DiffmapServer } 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
|
+
const localUrlPattern = /http:\/\/127\.0\.0\.1:\d+/g;
|
|
20
|
+
|
|
21
|
+
function httpUrlPort(url: string) {
|
|
22
|
+
const parsed = new URL(url);
|
|
23
|
+
assert.equal(parsed.hostname, "127.0.0.1");
|
|
24
|
+
const port = Number(parsed.port);
|
|
25
|
+
assert.ok(Number.isInteger(port) && port > 0);
|
|
26
|
+
return port;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function assertStarted(
|
|
30
|
+
started: Awaited<ReturnType<typeof startServer>>,
|
|
31
|
+
): DiffmapServer {
|
|
32
|
+
assert.equal(
|
|
33
|
+
started instanceof Error,
|
|
34
|
+
false,
|
|
35
|
+
started instanceof Error ? started.message : "",
|
|
36
|
+
);
|
|
37
|
+
if (started instanceof Error) throw started;
|
|
38
|
+
return started;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
async function occupyPort(port: number) {
|
|
42
|
+
const server = net.createServer();
|
|
43
|
+
try {
|
|
44
|
+
await new Promise<void>((resolve, reject) => {
|
|
45
|
+
server.once("error", reject);
|
|
46
|
+
server.listen(port, "127.0.0.1", () => resolve());
|
|
47
|
+
});
|
|
48
|
+
return server;
|
|
49
|
+
} catch {
|
|
50
|
+
server.close();
|
|
51
|
+
return undefined;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
async function listenEphemeral() {
|
|
56
|
+
const server = net.createServer();
|
|
57
|
+
await new Promise<void>((resolve, reject) => {
|
|
58
|
+
server.once("error", reject);
|
|
59
|
+
server.listen(0, "127.0.0.1", () => resolve());
|
|
60
|
+
});
|
|
61
|
+
return server;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function boundPort(server: net.Server) {
|
|
65
|
+
const address = server.address();
|
|
66
|
+
assert.ok(address !== null && typeof address !== "string");
|
|
67
|
+
return address.port;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
async function closeTcp(server: net.Server | undefined) {
|
|
71
|
+
if (server === undefined) return;
|
|
72
|
+
await new Promise<void>((resolve) => {
|
|
73
|
+
server.close(() => resolve());
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
async function fetchOk(url: string) {
|
|
78
|
+
const response = await fetch(url, {
|
|
79
|
+
headers: { accept: "text/markdown" },
|
|
80
|
+
});
|
|
81
|
+
assert.equal(response.ok, true);
|
|
82
|
+
const body = await response.text();
|
|
83
|
+
assert.match(body, /Spec fixture/);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
async function waitForClosed(server: DiffmapServer, timeoutMs = 10_000) {
|
|
87
|
+
await Promise.race([
|
|
88
|
+
server.closed,
|
|
89
|
+
delay(timeoutMs).then(() => {
|
|
90
|
+
throw new Error(`server did not close: ${server.url}`);
|
|
91
|
+
}),
|
|
92
|
+
]);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
test(
|
|
96
|
+
"startServer binds distinct free ports when 4177 is taken",
|
|
97
|
+
{ timeout: 90_000 },
|
|
98
|
+
async () => {
|
|
99
|
+
const occupied = await occupyPort(4177);
|
|
100
|
+
try {
|
|
101
|
+
const first = assertStarted(
|
|
102
|
+
await startServer({
|
|
103
|
+
filePath: sampleSpec,
|
|
104
|
+
workspaceRoot: packageRoot,
|
|
105
|
+
}),
|
|
106
|
+
);
|
|
107
|
+
const second = assertStarted(
|
|
108
|
+
await startServer({
|
|
109
|
+
filePath: sampleSpec,
|
|
110
|
+
workspaceRoot: packageRoot,
|
|
111
|
+
}),
|
|
112
|
+
);
|
|
113
|
+
try {
|
|
114
|
+
const firstPort = httpUrlPort(first.url);
|
|
115
|
+
const secondPort = httpUrlPort(second.url);
|
|
116
|
+
assert.notEqual(firstPort, secondPort);
|
|
117
|
+
assert.notEqual(firstPort, 4177);
|
|
118
|
+
assert.notEqual(secondPort, 4177);
|
|
119
|
+
await fetchOk(first.url);
|
|
120
|
+
await fetchOk(second.url);
|
|
121
|
+
} finally {
|
|
122
|
+
await first.shutdown();
|
|
123
|
+
await second.shutdown();
|
|
124
|
+
await waitForClosed(first);
|
|
125
|
+
await waitForClosed(second);
|
|
126
|
+
}
|
|
127
|
+
} finally {
|
|
128
|
+
await closeTcp(occupied);
|
|
129
|
+
}
|
|
130
|
+
},
|
|
131
|
+
);
|
|
132
|
+
|
|
133
|
+
test(
|
|
134
|
+
"startServer --port pins and fails when that port is taken",
|
|
135
|
+
{ timeout: 90_000 },
|
|
136
|
+
async () => {
|
|
137
|
+
const holder = await listenEphemeral();
|
|
138
|
+
const port = boundPort(holder);
|
|
139
|
+
|
|
140
|
+
const blocked = await startServer({
|
|
141
|
+
filePath: sampleSpec,
|
|
142
|
+
workspaceRoot: packageRoot,
|
|
143
|
+
port,
|
|
144
|
+
});
|
|
145
|
+
assert.equal(blocked instanceof DiffmapServeError, true);
|
|
146
|
+
if (!(blocked instanceof DiffmapServeError)) return;
|
|
147
|
+
assert.match(blocked.message, /already in use/i);
|
|
148
|
+
|
|
149
|
+
await closeTcp(holder);
|
|
150
|
+
|
|
151
|
+
const pinned = assertStarted(
|
|
152
|
+
await startServer({
|
|
153
|
+
filePath: sampleSpec,
|
|
154
|
+
workspaceRoot: packageRoot,
|
|
155
|
+
port,
|
|
156
|
+
}),
|
|
157
|
+
);
|
|
158
|
+
try {
|
|
159
|
+
assert.equal(httpUrlPort(pinned.url), port);
|
|
160
|
+
await fetchOk(pinned.url);
|
|
161
|
+
} finally {
|
|
162
|
+
await pinned.shutdown();
|
|
163
|
+
await waitForClosed(pinned);
|
|
164
|
+
}
|
|
165
|
+
},
|
|
166
|
+
);
|
|
167
|
+
|
|
168
|
+
test(
|
|
169
|
+
"startServer Close server still stops an ephemeral listener",
|
|
170
|
+
{ timeout: 90_000 },
|
|
171
|
+
async () => {
|
|
172
|
+
const started = assertStarted(
|
|
173
|
+
await startServer({
|
|
174
|
+
filePath: sampleSpec,
|
|
175
|
+
workspaceRoot: packageRoot,
|
|
176
|
+
}),
|
|
177
|
+
);
|
|
178
|
+
const response = await fetch(`${started.url}/__diffmap/shutdown`, {
|
|
179
|
+
method: "POST",
|
|
180
|
+
});
|
|
181
|
+
assert.equal(response.ok, true);
|
|
182
|
+
await waitForClosed(started);
|
|
183
|
+
await assert.rejects(() => fetch(started.url));
|
|
184
|
+
},
|
|
185
|
+
);
|
|
186
|
+
|
|
187
|
+
type SpawnedCli = {
|
|
188
|
+
child: ChildProcess;
|
|
189
|
+
output: () => string;
|
|
190
|
+
};
|
|
191
|
+
|
|
192
|
+
function spawnCli(args: string[]) {
|
|
193
|
+
let stdout = "";
|
|
194
|
+
let stderr = "";
|
|
195
|
+
const child = spawn(process.execPath, ["--import", "tsx", cliPath, ...args], {
|
|
196
|
+
cwd: packageRoot,
|
|
197
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
198
|
+
});
|
|
199
|
+
child.stdout?.on("data", (chunk: Buffer) => {
|
|
200
|
+
stdout += chunk.toString();
|
|
201
|
+
});
|
|
202
|
+
child.stderr?.on("data", (chunk: Buffer) => {
|
|
203
|
+
stderr += chunk.toString();
|
|
204
|
+
});
|
|
205
|
+
return {
|
|
206
|
+
child,
|
|
207
|
+
output: () => `${stdout}${stderr}`,
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function firstLocalUrl(text: string) {
|
|
212
|
+
return text.match(localUrlPattern)?.[0];
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
async function waitForLocalUrl(spawned: SpawnedCli, timeoutMs = 60_000) {
|
|
216
|
+
const start = Date.now();
|
|
217
|
+
while (Date.now() - start < timeoutMs) {
|
|
218
|
+
if (spawned.child.exitCode !== null) {
|
|
219
|
+
throw new Error(
|
|
220
|
+
`cli exited ${spawned.child.exitCode} before listen:\n${spawned.output()}`,
|
|
221
|
+
);
|
|
222
|
+
}
|
|
223
|
+
const url = firstLocalUrl(spawned.output());
|
|
224
|
+
if (url !== undefined) return url;
|
|
225
|
+
await delay(50);
|
|
226
|
+
}
|
|
227
|
+
throw new Error(`cli printed no listen url:\n${spawned.output()}`);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
async function waitForExit(child: ChildProcess, timeoutMs = 15_000) {
|
|
231
|
+
if (child.exitCode !== null) return;
|
|
232
|
+
await Promise.race([
|
|
233
|
+
new Promise<void>((resolve) => {
|
|
234
|
+
child.once("exit", () => resolve());
|
|
235
|
+
}),
|
|
236
|
+
delay(timeoutMs).then(() => {
|
|
237
|
+
child.kill("SIGKILL");
|
|
238
|
+
throw new Error("cli did not exit");
|
|
239
|
+
}),
|
|
240
|
+
]);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
async function stopCli(spawned: SpawnedCli, url: string) {
|
|
244
|
+
const response = await fetch(`${url}/__diffmap/shutdown`, {
|
|
245
|
+
method: "POST",
|
|
246
|
+
}).catch(() => undefined);
|
|
247
|
+
if (response === undefined || !response.ok) {
|
|
248
|
+
spawned.child.kill("SIGTERM");
|
|
249
|
+
}
|
|
250
|
+
await waitForExit(spawned.child);
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
async function withTempSpecs(
|
|
254
|
+
run: (firstFile: string, secondFile: string) => Promise<void>,
|
|
255
|
+
) {
|
|
256
|
+
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "diffmap-serve-"));
|
|
257
|
+
const firstFile = path.join(dir, "one.md");
|
|
258
|
+
const secondFile = path.join(dir, "two.md");
|
|
259
|
+
const source = await fs.readFile(sampleSpec, "utf8");
|
|
260
|
+
await Promise.all([
|
|
261
|
+
fs.writeFile(firstFile, source),
|
|
262
|
+
fs.writeFile(secondFile, source),
|
|
263
|
+
]);
|
|
264
|
+
try {
|
|
265
|
+
await run(firstFile, secondFile);
|
|
266
|
+
} finally {
|
|
267
|
+
await fs.rm(dir, { recursive: true, force: true });
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
test(
|
|
272
|
+
"diffmap serve prints a free port and list tracks both servers",
|
|
273
|
+
{ timeout: 90_000 },
|
|
274
|
+
async () => {
|
|
275
|
+
await withTempSpecs(async (firstFile, secondFile) => {
|
|
276
|
+
const first = spawnCli(["serve", firstFile]);
|
|
277
|
+
const second = spawnCli([secondFile]);
|
|
278
|
+
try {
|
|
279
|
+
const firstUrl = await waitForLocalUrl(first);
|
|
280
|
+
const secondUrl = await waitForLocalUrl(second);
|
|
281
|
+
assert.notEqual(firstUrl, secondUrl);
|
|
282
|
+
await fetchOk(firstUrl);
|
|
283
|
+
await fetchOk(secondUrl);
|
|
284
|
+
|
|
285
|
+
const listed = await execFileAsync(
|
|
286
|
+
process.execPath,
|
|
287
|
+
["--import", "tsx", cliPath, "list"],
|
|
288
|
+
{
|
|
289
|
+
cwd: packageRoot,
|
|
290
|
+
timeout: 20_000,
|
|
291
|
+
maxBuffer: 1_000_000,
|
|
292
|
+
},
|
|
293
|
+
);
|
|
294
|
+
const listOutput = `${listed.stdout}${listed.stderr}`;
|
|
295
|
+
assert.equal(listOutput.includes(firstUrl), true);
|
|
296
|
+
assert.equal(listOutput.includes(secondUrl), true);
|
|
297
|
+
|
|
298
|
+
await stopCli(first, firstUrl);
|
|
299
|
+
await stopCli(second, secondUrl);
|
|
300
|
+
} finally {
|
|
301
|
+
first.child.kill("SIGTERM");
|
|
302
|
+
second.child.kill("SIGTERM");
|
|
303
|
+
}
|
|
304
|
+
});
|
|
305
|
+
},
|
|
306
|
+
);
|
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,
|
|
@@ -21,7 +22,7 @@ export type DiffmapServer = {
|
|
|
21
22
|
export type StartServerInput = {
|
|
22
23
|
filePath: string;
|
|
23
24
|
workspaceRoot: string;
|
|
24
|
-
port
|
|
25
|
+
port?: number;
|
|
25
26
|
};
|
|
26
27
|
|
|
27
28
|
const packageRoot = path.resolve(fileURLToPath(new URL("..", import.meta.url)));
|
|
@@ -44,6 +45,7 @@ function createClosedBarrier() {
|
|
|
44
45
|
export async function startServer(input: StartServerInput) {
|
|
45
46
|
const filePath = path.resolve(input.filePath);
|
|
46
47
|
const workspaceRoot = path.resolve(input.workspaceRoot);
|
|
48
|
+
const port = input.port ?? 0;
|
|
47
49
|
const source = await fs.readFile(filePath, "utf8").catch(
|
|
48
50
|
(cause) =>
|
|
49
51
|
new DiffmapFileError({
|
|
@@ -54,6 +56,9 @@ export async function startServer(input: StartServerInput) {
|
|
|
54
56
|
);
|
|
55
57
|
if (source instanceof Error) return source;
|
|
56
58
|
|
|
59
|
+
const parsed = parseViewerDocument(source, filePath);
|
|
60
|
+
if (parsed instanceof Error) return parsed;
|
|
61
|
+
|
|
57
62
|
const title = extractTitle(source);
|
|
58
63
|
|
|
59
64
|
let vite: ViteDevServer | undefined;
|
|
@@ -78,9 +83,9 @@ export async function startServer(input: StartServerInput) {
|
|
|
78
83
|
configFile: path.join(packageRoot, "vite.config.ts"),
|
|
79
84
|
root: packageRoot,
|
|
80
85
|
server: {
|
|
81
|
-
port
|
|
86
|
+
port,
|
|
82
87
|
host: "127.0.0.1",
|
|
83
|
-
strictPort:
|
|
88
|
+
strictPort: port !== 0,
|
|
84
89
|
fs: {
|
|
85
90
|
allow: [packageRoot, workspaceRoot, path.dirname(filePath)],
|
|
86
91
|
},
|
|
@@ -134,7 +139,16 @@ export async function startServer(input: StartServerInput) {
|
|
|
134
139
|
],
|
|
135
140
|
});
|
|
136
141
|
|
|
137
|
-
await vite.listen(
|
|
142
|
+
const listened = await vite.listen(port).catch(
|
|
143
|
+
(cause) =>
|
|
144
|
+
new DiffmapServeError({
|
|
145
|
+
reason: cause instanceof Error ? cause.message : "listen failed",
|
|
146
|
+
}),
|
|
147
|
+
);
|
|
148
|
+
if (listened instanceof Error) {
|
|
149
|
+
await shutdown();
|
|
150
|
+
return listened;
|
|
151
|
+
}
|
|
138
152
|
const localUrl = vite.resolvedUrls?.local[0];
|
|
139
153
|
if (localUrl === undefined) {
|
|
140
154
|
await shutdown();
|
|
@@ -258,11 +272,15 @@ async function handleDiffmapRequest(input: {
|
|
|
258
272
|
return;
|
|
259
273
|
}
|
|
260
274
|
if (parsed.pathname === "/__diffmap/meta" && input.method === "GET") {
|
|
275
|
+
const source = await fs
|
|
276
|
+
.readFile(input.filePath, "utf8")
|
|
277
|
+
.catch(() => undefined);
|
|
278
|
+
const title = source === undefined ? input.title : extractTitle(source);
|
|
261
279
|
input.res.statusCode = 200;
|
|
262
280
|
input.res.setHeader("content-type", "application/json; charset=utf-8");
|
|
263
281
|
input.res.end(
|
|
264
282
|
JSON.stringify({
|
|
265
|
-
title
|
|
283
|
+
title,
|
|
266
284
|
file: input.filePath,
|
|
267
285
|
pid: process.pid,
|
|
268
286
|
}),
|
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
|
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import test from "node:test";
|
|
3
|
+
import { compareVersions, resolveUpdateArgv } from "./update.js";
|
|
4
|
+
|
|
5
|
+
test("compareVersions orders versions by semver precedence", () => {
|
|
6
|
+
assert.equal(compareVersions("0.1.4", "0.1.4"), 0);
|
|
7
|
+
assert.equal(compareVersions("0.1.4", "0.1.5"), -1);
|
|
8
|
+
assert.equal(compareVersions("0.1.10", "0.1.9"), 1);
|
|
9
|
+
assert.equal(compareVersions("1.0.0-rc.1", "1.0.0"), -1);
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
test("update and --update run the same command", () => {
|
|
13
|
+
assert.deepEqual(resolveUpdateArgv(["update"]), ["update"]);
|
|
14
|
+
assert.deepEqual(resolveUpdateArgv(["--update"]), ["update"]);
|
|
15
|
+
assert.deepEqual(resolveUpdateArgv(["serve", "spec.md"]), [
|
|
16
|
+
"serve",
|
|
17
|
+
"spec.md",
|
|
18
|
+
]);
|
|
19
|
+
});
|