@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.
Files changed (50) hide show
  1. package/README.md +25 -14
  2. package/package.json +3 -3
  3. package/src/cli.test.ts +50 -0
  4. package/src/cli.ts +126 -51
  5. package/src/contentPlugin.ts +5 -3
  6. package/src/errors.ts +6 -1
  7. package/src/gist/GistApp.tsx +34 -21
  8. package/src/gist/GistStatus.tsx +8 -43
  9. package/src/gist/main.tsx +2 -2
  10. package/src/gist/pin.ts +14 -0
  11. package/src/github/GitHubApp.tsx +235 -0
  12. package/src/github/fetchRepo.test.ts +17 -0
  13. package/src/github/fetchRepo.ts +300 -0
  14. package/src/github/patch.test.ts +30 -0
  15. package/src/github/patch.ts +40 -0
  16. package/src/github/pin.ts +14 -0
  17. package/src/github/route.test.ts +68 -0
  18. package/src/github/route.ts +175 -0
  19. package/src/host/HostApp.tsx +22 -0
  20. package/src/host/HostHome.tsx +73 -0
  21. package/src/host/files.ts +10 -0
  22. package/src/host/home.md +194 -0
  23. package/src/host/home.test.ts +82 -0
  24. package/src/index.ts +1 -0
  25. package/src/mermaid.ts +29 -1
  26. package/src/packageVersion.ts +7 -0
  27. package/src/parseViewer.test.ts +185 -0
  28. package/src/parseViewer.ts +27 -3
  29. package/src/serve.test.ts +306 -0
  30. package/src/serve.ts +23 -5
  31. package/src/share.ts +5 -1
  32. package/src/update.test.ts +19 -0
  33. package/src/update.ts +285 -0
  34. package/src/viewer/CallStackDiff.tsx +26 -6
  35. package/src/viewer/CloseServerButton.tsx +16 -0
  36. package/src/viewer/FileExcerpt.tsx +52 -3
  37. package/src/viewer/HtmlPlaceholder.tsx +6 -1
  38. package/src/viewer/SourceDiffPanel.tsx +155 -59
  39. package/src/viewer/TableOfContents.tsx +230 -39
  40. package/src/viewer/TocButton.tsx +27 -0
  41. package/src/viewer/ViewerApp.tsx +182 -47
  42. package/src/viewer/main.tsx +10 -3
  43. package/src/viewer/sourceHeader.test.ts +131 -0
  44. package/src/viewer/sourceHeader.ts +103 -0
  45. package/src/viewer/sourceNavigation.ts +81 -3
  46. package/src/viewer/viewerMode.ts +1 -1
  47. package/src/viewer/vite-env.d.ts +2 -1
  48. package/vite.config.ts +1 -1
  49. package/src/viewer/Check.tsx +0 -24
  50. 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
+ });
@@ -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
- for (const fence of fences) {
110
- if (fence.kind !== "mermaid") continue;
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: number;
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: input.port,
86
+ port,
82
87
  host: "127.0.0.1",
83
- strictPort: input.port !== 0,
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(input.port);
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: input.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
- }): Promise<ShareResult | DiffmapFileError | DiffmapShareError> {
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
+ });