@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,91 @@
|
|
|
1
|
+
import { parseCallStack, type SourceAnnotation } from "./annotations.js";
|
|
2
|
+
import {
|
|
3
|
+
parseDiagramAnnotations,
|
|
4
|
+
type DiagramAnnotation,
|
|
5
|
+
} from "./diagramAnnotations.js";
|
|
6
|
+
|
|
7
|
+
export type Fence =
|
|
8
|
+
| { kind: "mermaid"; source: string; annotations: DiagramAnnotation[] }
|
|
9
|
+
| { kind: "html"; source: string }
|
|
10
|
+
| { kind: "callstack"; source: string; lines: SourceAnnotation[] }
|
|
11
|
+
| { kind: "source-diff"; id: string; path: string; source: string }
|
|
12
|
+
| { kind: "file"; path: string; start: number; end: number; source: string }
|
|
13
|
+
| { kind: "diff"; path: string | undefined; source: string }
|
|
14
|
+
| { kind: "code"; lang: string; source: string };
|
|
15
|
+
|
|
16
|
+
const fileRefPattern = /^(\d+):(\d+):(.+)$/;
|
|
17
|
+
const diffPathPattern = /^diff:(.+)$/;
|
|
18
|
+
|
|
19
|
+
export function isCallStackSource(source: string) {
|
|
20
|
+
return source.includes("└──") || source.includes("├──");
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function pathFromDiffSource(source: string) {
|
|
24
|
+
const plus = /^\+\+\+ [ab]\/(.+)$/m.exec(source);
|
|
25
|
+
if (plus?.[1] !== undefined) return plus[1];
|
|
26
|
+
const minus = /^--- [ab]\/(.+)$/m.exec(source);
|
|
27
|
+
if (minus?.[1] !== undefined) return minus[1];
|
|
28
|
+
const comment = /^\/\/ (.+\S)\s*$/m.exec(source);
|
|
29
|
+
if (comment?.[1] !== undefined) return comment[1];
|
|
30
|
+
return undefined;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function parseFence(lang: string, source: string): Fence {
|
|
34
|
+
const trimmed = source.replace(/\n$/, "");
|
|
35
|
+
if (lang === "mermaid")
|
|
36
|
+
return {
|
|
37
|
+
kind: "mermaid",
|
|
38
|
+
source: trimmed,
|
|
39
|
+
annotations: parseDiagramAnnotations(trimmed),
|
|
40
|
+
};
|
|
41
|
+
if (lang === "html") return { kind: "html", source: trimmed };
|
|
42
|
+
if (lang === "callstack")
|
|
43
|
+
return {
|
|
44
|
+
kind: "callstack",
|
|
45
|
+
source: trimmed,
|
|
46
|
+
lines: parseCallStack(trimmed),
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
const sourceDiff = /^source-diff:([\w-]+):(.+)$/.exec(lang);
|
|
50
|
+
if (sourceDiff !== null) {
|
|
51
|
+
return {
|
|
52
|
+
kind: "source-diff",
|
|
53
|
+
id: sourceDiff[1]!,
|
|
54
|
+
path: sourceDiff[2]!,
|
|
55
|
+
source: trimmed,
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const fileRef = fileRefPattern.exec(lang);
|
|
60
|
+
if (fileRef !== null) {
|
|
61
|
+
return {
|
|
62
|
+
kind: "file",
|
|
63
|
+
start: Number(fileRef[1]),
|
|
64
|
+
end: Number(fileRef[2]),
|
|
65
|
+
path: fileRef[3]!,
|
|
66
|
+
source: trimmed,
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const diffPath = diffPathPattern.exec(lang);
|
|
71
|
+
if (lang === "diff" || diffPath !== null) {
|
|
72
|
+
if (isCallStackSource(trimmed)) {
|
|
73
|
+
return {
|
|
74
|
+
kind: "callstack",
|
|
75
|
+
source: trimmed,
|
|
76
|
+
lines: parseCallStack(trimmed),
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
return {
|
|
80
|
+
kind: "diff",
|
|
81
|
+
path: diffPath === null ? pathFromDiffSource(trimmed) : diffPath[1],
|
|
82
|
+
source: trimmed,
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
return {
|
|
87
|
+
kind: "code",
|
|
88
|
+
lang: lang.length === 0 ? "text" : lang,
|
|
89
|
+
source: trimmed,
|
|
90
|
+
};
|
|
91
|
+
}
|
|
@@ -0,0 +1,399 @@
|
|
|
1
|
+
import * as errore from "errore";
|
|
2
|
+
import { parsePatchFiles, type CodeViewDiffItem } from "@pierre/diffs";
|
|
3
|
+
import { parseAST } from "md4x/napi";
|
|
4
|
+
import type { ComarkElement, ComarkNode } from "md4x/napi";
|
|
5
|
+
import { DiffmapAnnotationError, DiffmapParseError } from "./errors.js";
|
|
6
|
+
import { parseFence, type Fence } from "./parseFence.js";
|
|
7
|
+
|
|
8
|
+
export type ViewerDocument = {
|
|
9
|
+
nodes: ViewerNode[];
|
|
10
|
+
headings: ViewerHeading[];
|
|
11
|
+
sourceDiffs: CodeViewDiffItem[];
|
|
12
|
+
hasReferences: boolean;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
export type ViewerHeading = {
|
|
16
|
+
level: 1 | 2 | 3 | 4 | 5 | 6;
|
|
17
|
+
text: string;
|
|
18
|
+
id: string;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
export type ViewerNode = ViewerText | ViewerElement | ViewerHtml | ViewerView;
|
|
22
|
+
|
|
23
|
+
export type ViewerText = {
|
|
24
|
+
type: "text";
|
|
25
|
+
value: string;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
export type ViewerElement = {
|
|
29
|
+
type: "element";
|
|
30
|
+
tag: string;
|
|
31
|
+
attrs: ViewerElementAttrs;
|
|
32
|
+
children: ViewerNode[];
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
export type ViewerHtml = {
|
|
36
|
+
type: "html";
|
|
37
|
+
block: boolean;
|
|
38
|
+
source: string;
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
export type ViewerView = {
|
|
42
|
+
type: "view";
|
|
43
|
+
fence: Fence;
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
export type ViewerElementAttrs = {
|
|
47
|
+
id: string | undefined;
|
|
48
|
+
href: string | undefined;
|
|
49
|
+
src: string | undefined;
|
|
50
|
+
alt: string | undefined;
|
|
51
|
+
title: string | undefined;
|
|
52
|
+
className: string | undefined;
|
|
53
|
+
start: number | undefined;
|
|
54
|
+
checked: boolean | undefined;
|
|
55
|
+
task: boolean | undefined;
|
|
56
|
+
alertType: string | undefined;
|
|
57
|
+
align: string | undefined;
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
export function parseViewerDocument(source: string) {
|
|
61
|
+
const tree = errore.try({
|
|
62
|
+
try: () => parseAST(source),
|
|
63
|
+
catch: (cause) => new DiffmapParseError({ cause }),
|
|
64
|
+
});
|
|
65
|
+
if (tree instanceof Error) return tree;
|
|
66
|
+
const nodes = tree.nodes.flatMap(fromNode);
|
|
67
|
+
const fences = collectFences(nodes);
|
|
68
|
+
const sourceDiffs: CodeViewDiffItem[] = [];
|
|
69
|
+
for (const fence of fences) {
|
|
70
|
+
if (fence.kind !== "source-diff") continue;
|
|
71
|
+
if (sourceDiffs.some((diff) => diff.id === fence.id)) {
|
|
72
|
+
return new DiffmapAnnotationError({
|
|
73
|
+
reason: `Duplicate source diff ID "${fence.id}"`,
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
const patches = errore.try({
|
|
77
|
+
try: () => parsePatchFiles(fence.source, undefined, true),
|
|
78
|
+
catch: (cause) =>
|
|
79
|
+
new DiffmapAnnotationError({
|
|
80
|
+
reason: `Invalid patch "${fence.id}"`,
|
|
81
|
+
cause,
|
|
82
|
+
}),
|
|
83
|
+
});
|
|
84
|
+
if (patches instanceof Error) return patches;
|
|
85
|
+
const files = patches.flatMap((patch) => patch.files);
|
|
86
|
+
const fileDiff = files[0];
|
|
87
|
+
if (
|
|
88
|
+
files.length !== 1 ||
|
|
89
|
+
fileDiff === undefined ||
|
|
90
|
+
fileDiff.hunks.length === 0
|
|
91
|
+
) {
|
|
92
|
+
return new DiffmapAnnotationError({
|
|
93
|
+
reason: `Source diff "${fence.id}" must contain one file with unified diff hunks`,
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
if (fileDiff.name !== fence.path) {
|
|
97
|
+
return new DiffmapAnnotationError({
|
|
98
|
+
reason: `Source diff "${fence.id}" path does not match its patch (${fileDiff.name})`,
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
sourceDiffs.push({ id: fence.id, type: "diff", fileDiff });
|
|
102
|
+
}
|
|
103
|
+
const annotations = fences.flatMap((fence) => {
|
|
104
|
+
if (fence.kind === "callstack") return fence.lines;
|
|
105
|
+
if (fence.kind === "mermaid")
|
|
106
|
+
return fence.annotations.map((link) => link.annotation);
|
|
107
|
+
return [];
|
|
108
|
+
});
|
|
109
|
+
for (const fence of fences) {
|
|
110
|
+
if (fence.kind !== "mermaid") continue;
|
|
111
|
+
const targets = new Set<string>();
|
|
112
|
+
for (const link of fence.annotations) {
|
|
113
|
+
const target = `${link.target}:${link.id}`;
|
|
114
|
+
if (
|
|
115
|
+
targets.has(target) ||
|
|
116
|
+
link.annotation.references.length === 0 ||
|
|
117
|
+
(link.target === "edge" && !/^(0|[1-9]\d*)$/.test(link.id))
|
|
118
|
+
) {
|
|
119
|
+
return new DiffmapAnnotationError({
|
|
120
|
+
reason: `Invalid or duplicate Mermaid reference for ${target}`,
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
targets.add(target);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
for (const line of annotations) {
|
|
127
|
+
if (line.text.includes("[[")) {
|
|
128
|
+
return new DiffmapAnnotationError({
|
|
129
|
+
reason: `Invalid reference in "${line.text}". Use [[id:old|new:start-end]] or [[path#symbol]]`,
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
for (const ref of line.references) {
|
|
133
|
+
if (ref.kind === "file") {
|
|
134
|
+
if (
|
|
135
|
+
ref.start !== undefined &&
|
|
136
|
+
(!Number.isSafeInteger(ref.start) ||
|
|
137
|
+
!Number.isSafeInteger(ref.end) ||
|
|
138
|
+
ref.end! < ref.start)
|
|
139
|
+
) {
|
|
140
|
+
return new DiffmapAnnotationError({
|
|
141
|
+
reason: `Invalid file range in "${ref.path}"`,
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
continue;
|
|
145
|
+
}
|
|
146
|
+
const diff = sourceDiffs.find((item) => item.id === ref.id);
|
|
147
|
+
if (diff === undefined) {
|
|
148
|
+
return new DiffmapAnnotationError({
|
|
149
|
+
reason: `Unknown source diff "${ref.id}"`,
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
const inHunk = diff.fileDiff.hunks.some((hunk) => {
|
|
153
|
+
const start =
|
|
154
|
+
ref.side === "old" ? hunk.deletionStart : hunk.additionStart;
|
|
155
|
+
const count =
|
|
156
|
+
ref.side === "old" ? hunk.deletionCount : hunk.additionCount;
|
|
157
|
+
return ref.start >= start && ref.end < start + count;
|
|
158
|
+
});
|
|
159
|
+
if (
|
|
160
|
+
!Number.isSafeInteger(ref.start) ||
|
|
161
|
+
!Number.isSafeInteger(ref.end) ||
|
|
162
|
+
ref.end < ref.start ||
|
|
163
|
+
!inHunk
|
|
164
|
+
) {
|
|
165
|
+
return new DiffmapAnnotationError({
|
|
166
|
+
reason: `Range ${ref.side}:${ref.start}-${ref.end} is not in a hunk of "${ref.id}"`,
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
return {
|
|
172
|
+
nodes,
|
|
173
|
+
headings: collectHeadings(nodes),
|
|
174
|
+
sourceDiffs,
|
|
175
|
+
hasReferences: annotations.some(
|
|
176
|
+
(annotation) => annotation.references.length > 0,
|
|
177
|
+
),
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function collectFences(nodes: ViewerNode[]): Fence[] {
|
|
182
|
+
return nodes.flatMap((node) => {
|
|
183
|
+
if (node.type === "view") return [node.fence];
|
|
184
|
+
if (node.type === "element") return collectFences(node.children);
|
|
185
|
+
return [];
|
|
186
|
+
});
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
const headingTags = new Set(["h1", "h2", "h3", "h4", "h5", "h6"]);
|
|
190
|
+
|
|
191
|
+
function collectHeadings(nodes: ViewerNode[]): ViewerHeading[] {
|
|
192
|
+
const headings: ViewerHeading[] = [];
|
|
193
|
+
const usedIds = new Set<string>();
|
|
194
|
+
walkHeadings(nodes, headings, usedIds);
|
|
195
|
+
return headings;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function walkHeadings(
|
|
199
|
+
nodes: ViewerNode[],
|
|
200
|
+
headings: ViewerHeading[],
|
|
201
|
+
usedIds: Set<string>,
|
|
202
|
+
) {
|
|
203
|
+
for (const node of nodes) {
|
|
204
|
+
if (node.type !== "element") continue;
|
|
205
|
+
if (headingTags.has(node.tag)) {
|
|
206
|
+
const text = viewerNodeText(node);
|
|
207
|
+
const id = ensureHeadingId(node, text, usedIds);
|
|
208
|
+
headings.push({
|
|
209
|
+
level: headingLevel(node.tag),
|
|
210
|
+
text,
|
|
211
|
+
id,
|
|
212
|
+
});
|
|
213
|
+
continue;
|
|
214
|
+
}
|
|
215
|
+
walkHeadings(node.children, headings, usedIds);
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function headingLevel(tag: string): ViewerHeading["level"] {
|
|
220
|
+
if (tag === "h1") return 1;
|
|
221
|
+
if (tag === "h2") return 2;
|
|
222
|
+
if (tag === "h3") return 3;
|
|
223
|
+
if (tag === "h4") return 4;
|
|
224
|
+
if (tag === "h5") return 5;
|
|
225
|
+
return 6;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function ensureHeadingId(
|
|
229
|
+
node: ViewerElement,
|
|
230
|
+
text: string,
|
|
231
|
+
usedIds: Set<string>,
|
|
232
|
+
) {
|
|
233
|
+
const existing = node.attrs.id;
|
|
234
|
+
if (existing !== undefined && existing !== "") {
|
|
235
|
+
usedIds.add(existing);
|
|
236
|
+
return existing;
|
|
237
|
+
}
|
|
238
|
+
const id = uniqueHeadingId(text, usedIds);
|
|
239
|
+
node.attrs.id = id;
|
|
240
|
+
return id;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
function uniqueHeadingId(text: string, usedIds: Set<string>) {
|
|
244
|
+
const base =
|
|
245
|
+
text
|
|
246
|
+
.toLowerCase()
|
|
247
|
+
.normalize("NFKD")
|
|
248
|
+
.replace(/[^\p{Letter}\p{Number}]+/gu, "-")
|
|
249
|
+
.replace(/^-+|-+$/g, "") || "heading";
|
|
250
|
+
let id = base;
|
|
251
|
+
let n = 1;
|
|
252
|
+
while (usedIds.has(id)) {
|
|
253
|
+
id = `${base}-${n}`;
|
|
254
|
+
n += 1;
|
|
255
|
+
}
|
|
256
|
+
usedIds.add(id);
|
|
257
|
+
return id;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
function viewerNodeText(node: ViewerNode): string {
|
|
261
|
+
if (node.type === "text") return node.value;
|
|
262
|
+
if (node.type !== "element") return "";
|
|
263
|
+
return node.children.map(viewerNodeText).join("");
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
function fromNode(node: ComarkNode): ViewerNode[] {
|
|
267
|
+
if (!Array.isArray(node)) {
|
|
268
|
+
return [{ type: "text", value: node }];
|
|
269
|
+
}
|
|
270
|
+
const converted = fromElement(node);
|
|
271
|
+
if (converted === undefined) return [];
|
|
272
|
+
if (Array.isArray(converted)) return converted;
|
|
273
|
+
return [converted];
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
function fromElement(
|
|
277
|
+
element: ComarkElement,
|
|
278
|
+
): ViewerNode | ViewerNode[] | undefined {
|
|
279
|
+
const [tag, , ...children] = element;
|
|
280
|
+
if (tag === null) return undefined;
|
|
281
|
+
if (tag === "template") return children.flatMap(fromNode);
|
|
282
|
+
if (tag === "pre") {
|
|
283
|
+
return fenceView(stringAttr(element, "language"), comarkText(children));
|
|
284
|
+
}
|
|
285
|
+
if (tag === "mermaid") return fenceView("mermaid", comarkText(children));
|
|
286
|
+
if (tag === "callstack") return fenceView("callstack", comarkText(children));
|
|
287
|
+
if (tag === "diff") {
|
|
288
|
+
const path = stringAttr(element, "path");
|
|
289
|
+
const lang = path === undefined ? "diff" : `diff:${path}`;
|
|
290
|
+
return fenceView(lang, comarkText(children));
|
|
291
|
+
}
|
|
292
|
+
if (tag === "file") return fileView(element, children);
|
|
293
|
+
if (tag === "html") return htmlView(element, children);
|
|
294
|
+
const convertedChildren = children.flatMap(fromNode);
|
|
295
|
+
return {
|
|
296
|
+
type: "element",
|
|
297
|
+
tag,
|
|
298
|
+
attrs: elementAttrs(element),
|
|
299
|
+
children:
|
|
300
|
+
tag === "li" && element[1].task === true
|
|
301
|
+
? unwrapTaskParagraph(convertedChildren)
|
|
302
|
+
: convertedChildren,
|
|
303
|
+
};
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
function unwrapTaskParagraph(children: ViewerNode[]): ViewerNode[] {
|
|
307
|
+
const first = children[0];
|
|
308
|
+
if (
|
|
309
|
+
children.length === 1 &&
|
|
310
|
+
first !== undefined &&
|
|
311
|
+
first.type === "element" &&
|
|
312
|
+
first.tag === "p"
|
|
313
|
+
) {
|
|
314
|
+
return first.children;
|
|
315
|
+
}
|
|
316
|
+
return children;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
function fenceView(lang: string | undefined, source: string): ViewerView {
|
|
320
|
+
return {
|
|
321
|
+
type: "view",
|
|
322
|
+
fence: parseFence(lang === undefined ? "" : lang, source),
|
|
323
|
+
};
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
function fileView(
|
|
327
|
+
element: ComarkElement,
|
|
328
|
+
children: ComarkNode[],
|
|
329
|
+
): ViewerView | ViewerNode[] {
|
|
330
|
+
const path = stringAttr(element, "path");
|
|
331
|
+
if (path === undefined) return children.flatMap(fromNode);
|
|
332
|
+
const start = lineAttr(element, "start");
|
|
333
|
+
const end = lineAttr(element, "end");
|
|
334
|
+
// MDC ::file with no range loads the whole file.
|
|
335
|
+
return fenceView(
|
|
336
|
+
`${start === undefined ? 1 : start}:${end === undefined ? Number.MAX_SAFE_INTEGER : end}:${path}`,
|
|
337
|
+
comarkText(children),
|
|
338
|
+
);
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
function htmlView(
|
|
342
|
+
element: ComarkElement,
|
|
343
|
+
children: ComarkNode[],
|
|
344
|
+
): ViewerHtml | ViewerView {
|
|
345
|
+
const source = comarkText(children);
|
|
346
|
+
if (children.every((child) => !Array.isArray(child))) {
|
|
347
|
+
return {
|
|
348
|
+
type: "html",
|
|
349
|
+
block: element[1].block === true,
|
|
350
|
+
source,
|
|
351
|
+
};
|
|
352
|
+
}
|
|
353
|
+
return {
|
|
354
|
+
type: "view",
|
|
355
|
+
fence: parseFence("html", source),
|
|
356
|
+
};
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
function elementAttrs(element: ComarkElement): ViewerElementAttrs {
|
|
360
|
+
const checked = element[1].checked;
|
|
361
|
+
return {
|
|
362
|
+
id: stringAttr(element, "id"),
|
|
363
|
+
href: stringAttr(element, "href"),
|
|
364
|
+
src: stringAttr(element, "src"),
|
|
365
|
+
alt: stringAttr(element, "alt"),
|
|
366
|
+
title: stringAttr(element, "title"),
|
|
367
|
+
className: stringAttr(element, "class"),
|
|
368
|
+
start: lineAttr(element, "start"),
|
|
369
|
+
checked: checked === true ? true : checked === false ? false : undefined,
|
|
370
|
+
task: element[1].task === true ? true : undefined,
|
|
371
|
+
alertType: stringAttr(element, "type"),
|
|
372
|
+
align: stringAttr(element, "align"),
|
|
373
|
+
};
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
function stringAttr(element: ComarkElement, key: string) {
|
|
377
|
+
const value = element[1][key];
|
|
378
|
+
if (value === undefined) return undefined;
|
|
379
|
+
// SAFETY: md4x string attributes are JSON strings.
|
|
380
|
+
return value as string;
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
function lineAttr(element: ComarkElement, key: string) {
|
|
384
|
+
const value = stringAttr(element, key);
|
|
385
|
+
if (value === undefined) return undefined;
|
|
386
|
+
const parsed = Number(value);
|
|
387
|
+
if (!Number.isInteger(parsed) || parsed < 1) return undefined;
|
|
388
|
+
return parsed;
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
function comarkText(nodes: ComarkNode[]): string {
|
|
392
|
+
return nodes
|
|
393
|
+
.map((node) => {
|
|
394
|
+
if (!Array.isArray(node)) return node;
|
|
395
|
+
const [, , ...children] = node;
|
|
396
|
+
return comarkText(children);
|
|
397
|
+
})
|
|
398
|
+
.join("");
|
|
399
|
+
}
|
package/src/registry.ts
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import fs from "node:fs/promises";
|
|
2
|
+
import os from "node:os";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import * as errore from "errore";
|
|
5
|
+
import { DiffmapRegistryError } from "./errors.js";
|
|
6
|
+
|
|
7
|
+
type RunningDiffmap = {
|
|
8
|
+
pid: number;
|
|
9
|
+
title: string;
|
|
10
|
+
url: string;
|
|
11
|
+
file: string;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
const registryDirectory = path.join(
|
|
15
|
+
os.tmpdir(),
|
|
16
|
+
`diffmap-${os.userInfo().uid}`,
|
|
17
|
+
);
|
|
18
|
+
|
|
19
|
+
export async function registerRunningDiffmap(entry: RunningDiffmap) {
|
|
20
|
+
const created = await fs
|
|
21
|
+
.mkdir(registryDirectory, { recursive: true, mode: 0o700 })
|
|
22
|
+
.catch(
|
|
23
|
+
(cause) =>
|
|
24
|
+
new DiffmapRegistryError({ reason: "create directory", cause }),
|
|
25
|
+
);
|
|
26
|
+
if (created instanceof Error) return created;
|
|
27
|
+
|
|
28
|
+
const registryPath = path.join(registryDirectory, `${entry.pid}.json`);
|
|
29
|
+
const written = await fs
|
|
30
|
+
.writeFile(registryPath, JSON.stringify(entry), { mode: 0o600 })
|
|
31
|
+
.catch(
|
|
32
|
+
(cause) => new DiffmapRegistryError({ reason: "write entry", cause }),
|
|
33
|
+
);
|
|
34
|
+
if (written instanceof Error) return written;
|
|
35
|
+
return registryPath;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export async function unregisterRunningDiffmap(registryPath: string) {
|
|
39
|
+
return await fs
|
|
40
|
+
.unlink(registryPath)
|
|
41
|
+
.catch(
|
|
42
|
+
(cause) => new DiffmapRegistryError({ reason: "remove entry", cause }),
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export async function listRunningDiffmaps() {
|
|
47
|
+
const names = await fs.readdir(registryDirectory).catch((cause: unknown) => {
|
|
48
|
+
if (isFileSystemError(cause) && cause.code === "ENOENT") return [];
|
|
49
|
+
return new DiffmapRegistryError({ reason: "read directory", cause });
|
|
50
|
+
});
|
|
51
|
+
if (names instanceof Error) return names;
|
|
52
|
+
|
|
53
|
+
const [entries, readErrors] = errore.partition(
|
|
54
|
+
await Promise.all(
|
|
55
|
+
names
|
|
56
|
+
.filter((name) => name.endsWith(".json"))
|
|
57
|
+
.map((name) => readRegistryEntry(path.join(registryDirectory, name))),
|
|
58
|
+
),
|
|
59
|
+
);
|
|
60
|
+
const readError = readErrors.at(0);
|
|
61
|
+
if (readError !== undefined) return readError;
|
|
62
|
+
|
|
63
|
+
const [running, cleanupErrors] = errore.partition(
|
|
64
|
+
await Promise.all(
|
|
65
|
+
entries.map(async (entry) => {
|
|
66
|
+
const reachable = await isRunning(entry);
|
|
67
|
+
if (reachable) return entry;
|
|
68
|
+
|
|
69
|
+
const removed = await unregisterRunningDiffmap(
|
|
70
|
+
path.join(registryDirectory, `${entry.pid}.json`),
|
|
71
|
+
);
|
|
72
|
+
if (removed instanceof Error) return removed;
|
|
73
|
+
return undefined;
|
|
74
|
+
}),
|
|
75
|
+
),
|
|
76
|
+
);
|
|
77
|
+
const cleanupError = cleanupErrors.at(0);
|
|
78
|
+
if (cleanupError !== undefined) return cleanupError;
|
|
79
|
+
return running.filter((entry) => entry !== undefined);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
async function readRegistryEntry(registryPath: string) {
|
|
83
|
+
const source = await fs
|
|
84
|
+
.readFile(registryPath, "utf8")
|
|
85
|
+
.catch(
|
|
86
|
+
(cause) => new DiffmapRegistryError({ reason: "read entry", cause }),
|
|
87
|
+
);
|
|
88
|
+
if (source instanceof Error) return source;
|
|
89
|
+
return errore.try({
|
|
90
|
+
// SAFETY: registerRunningDiffmap is the only writer for registry entries.
|
|
91
|
+
try: () => JSON.parse(source) as RunningDiffmap,
|
|
92
|
+
catch: (cause) =>
|
|
93
|
+
new DiffmapRegistryError({ reason: "parse entry", cause }),
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
async function isRunning(entry: RunningDiffmap) {
|
|
98
|
+
const response = await fetch(`${entry.url}/__diffmap/meta`, {
|
|
99
|
+
signal: AbortSignal.timeout(500),
|
|
100
|
+
}).catch(
|
|
101
|
+
(cause) => new DiffmapRegistryError({ reason: "reach server", cause }),
|
|
102
|
+
);
|
|
103
|
+
if (response instanceof Error) return false;
|
|
104
|
+
if (!response.ok) return false;
|
|
105
|
+
|
|
106
|
+
// SAFETY: diffmap serves this shape from its private metadata endpoint.
|
|
107
|
+
const meta = await (
|
|
108
|
+
response.json() as Promise<{
|
|
109
|
+
pid: number;
|
|
110
|
+
file: string;
|
|
111
|
+
}>
|
|
112
|
+
).catch(
|
|
113
|
+
(cause) => new DiffmapRegistryError({ reason: "read server", cause }),
|
|
114
|
+
);
|
|
115
|
+
if (meta instanceof Error) return false;
|
|
116
|
+
return meta.pid === entry.pid && meta.file === entry.file;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function isFileSystemError(cause: unknown): cause is NodeJS.ErrnoException {
|
|
120
|
+
return cause instanceof Error && "code" in cause;
|
|
121
|
+
}
|