@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,320 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import {
|
|
4
|
+
isClassDeclaration,
|
|
5
|
+
isEnumDeclaration,
|
|
6
|
+
isEnumMember,
|
|
7
|
+
isFunctionDeclaration,
|
|
8
|
+
isGetAccessorDeclaration,
|
|
9
|
+
isInterfaceDeclaration,
|
|
10
|
+
isMethodDeclaration,
|
|
11
|
+
isMethodSignatureDeclaration,
|
|
12
|
+
isModuleDeclaration,
|
|
13
|
+
isPropertyDeclaration,
|
|
14
|
+
isPropertySignatureDeclaration,
|
|
15
|
+
isSetAccessorDeclaration,
|
|
16
|
+
isTypeAliasDeclaration,
|
|
17
|
+
isVariableDeclaration,
|
|
18
|
+
type Node,
|
|
19
|
+
type SourceFile,
|
|
20
|
+
} from "typescript/unstable/ast";
|
|
21
|
+
import {
|
|
22
|
+
API,
|
|
23
|
+
SymbolFlags,
|
|
24
|
+
type Project,
|
|
25
|
+
type Symbol as TypeScriptSymbol,
|
|
26
|
+
} from "typescript/unstable/sync";
|
|
27
|
+
import * as errore from "errore";
|
|
28
|
+
import { DiffmapDefinitionError } from "./errors.js";
|
|
29
|
+
|
|
30
|
+
export type SourceDefinition = {
|
|
31
|
+
path: string;
|
|
32
|
+
contents: string;
|
|
33
|
+
start: number;
|
|
34
|
+
end: number;
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
export type DefinitionResponse = {
|
|
38
|
+
definition?: SourceDefinition;
|
|
39
|
+
error?: string;
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
export function findDefinition(workspaceRoot: string, params: URLSearchParams) {
|
|
43
|
+
const requestedPath = params.get("path");
|
|
44
|
+
const line = Number(params.get("line"));
|
|
45
|
+
const column = Number(params.get("column"));
|
|
46
|
+
const lineText = params.get("text");
|
|
47
|
+
if (
|
|
48
|
+
requestedPath === null ||
|
|
49
|
+
lineText === null ||
|
|
50
|
+
!Number.isSafeInteger(line) ||
|
|
51
|
+
line < 1 ||
|
|
52
|
+
!Number.isSafeInteger(column) ||
|
|
53
|
+
column < 0
|
|
54
|
+
)
|
|
55
|
+
return new DiffmapDefinitionError({ reason: "Invalid source position." });
|
|
56
|
+
|
|
57
|
+
const fileName = path.resolve(workspaceRoot, requestedPath);
|
|
58
|
+
if (!fileName.startsWith(workspaceRoot + path.sep)) {
|
|
59
|
+
return new DiffmapDefinitionError({
|
|
60
|
+
reason: "Path escapes the workspace.",
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
if (!/\.[cm]?[jt]sx?$/i.test(fileName)) {
|
|
64
|
+
return new DiffmapDefinitionError({
|
|
65
|
+
reason: "Definition navigation supports TypeScript and JavaScript.",
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
const contents = readSource(
|
|
69
|
+
fileName,
|
|
70
|
+
"This file is no longer in the workspace.",
|
|
71
|
+
);
|
|
72
|
+
if (contents instanceof Error) return contents;
|
|
73
|
+
const lines = contents.split(/\r?\n/);
|
|
74
|
+
if (lines[line - 1] !== lineText || column >= lineText.length) {
|
|
75
|
+
return new DiffmapDefinitionError({
|
|
76
|
+
reason:
|
|
77
|
+
"This diff line differs from the current workspace. Its definition cannot be resolved.",
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
return withProject(workspaceRoot, fileName, (project, source) => {
|
|
81
|
+
const position = source.getPositionOfLineAndCharacter(line - 1, column);
|
|
82
|
+
let symbol = project.checker.getSymbolAtPosition(fileName, position);
|
|
83
|
+
if (symbol === undefined) return undefined;
|
|
84
|
+
if ((symbol.flags & SymbolFlags.Alias) !== 0) {
|
|
85
|
+
symbol = project.checker.getAliasedSymbol(symbol);
|
|
86
|
+
}
|
|
87
|
+
const handle = symbol.valueDeclaration ?? symbol.declarations[0];
|
|
88
|
+
const declaration = handle?.resolve();
|
|
89
|
+
if (declaration === undefined) return undefined;
|
|
90
|
+
const target = declaration.getSourceFile();
|
|
91
|
+
const name = declarationName(declaration);
|
|
92
|
+
const start = name?.getStart(target) ?? declaration.getStart(target);
|
|
93
|
+
const end = name?.getEnd() ?? declaration.getEnd();
|
|
94
|
+
return {
|
|
95
|
+
path: path.relative(workspaceRoot, target.fileName),
|
|
96
|
+
contents: target.text,
|
|
97
|
+
start: target.getLineAndCharacterOfPosition(start).line + 1,
|
|
98
|
+
end: target.getLineAndCharacterOfPosition(end).line + 1,
|
|
99
|
+
} satisfies SourceDefinition;
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export function readSourceReference(
|
|
104
|
+
workspaceRoot: string,
|
|
105
|
+
params: URLSearchParams,
|
|
106
|
+
) {
|
|
107
|
+
const requestedPath = params.get("path");
|
|
108
|
+
if (requestedPath === null || requestedPath.length === 0)
|
|
109
|
+
return new DiffmapDefinitionError({ reason: "Missing file path." });
|
|
110
|
+
const fileName = path.resolve(workspaceRoot, requestedPath);
|
|
111
|
+
if (!fileName.startsWith(workspaceRoot + path.sep))
|
|
112
|
+
return new DiffmapDefinitionError({
|
|
113
|
+
reason: "Path escapes the workspace.",
|
|
114
|
+
});
|
|
115
|
+
const contents = readSource(fileName, `Could not read ${requestedPath}.`);
|
|
116
|
+
if (contents instanceof Error) return contents;
|
|
117
|
+
const symbol = params.get("symbol");
|
|
118
|
+
if (symbol !== null) {
|
|
119
|
+
if (!/\.[cm]?[jt]sx?$/i.test(fileName))
|
|
120
|
+
return new DiffmapDefinitionError({
|
|
121
|
+
reason: "Symbol references support TypeScript and JavaScript.",
|
|
122
|
+
});
|
|
123
|
+
return withProject(workspaceRoot, fileName, (project, sourceFile) => {
|
|
124
|
+
const matches = sourceDeclarations(project, sourceFile, symbol);
|
|
125
|
+
if (matches.length === 0)
|
|
126
|
+
return new DiffmapDefinitionError({
|
|
127
|
+
reason: `Symbol "${symbol}" was not found in ${requestedPath}.`,
|
|
128
|
+
});
|
|
129
|
+
if (matches.length > 1)
|
|
130
|
+
return new DiffmapDefinitionError({
|
|
131
|
+
reason: `Symbol "${symbol}" is ambiguous. Use a qualified name: ${matches.map((match) => match.name).join(", ")}.`,
|
|
132
|
+
});
|
|
133
|
+
const match = matches[0]!;
|
|
134
|
+
const start = Math.min(
|
|
135
|
+
...match.nodes.map((node) => node.getStart(sourceFile)),
|
|
136
|
+
);
|
|
137
|
+
const end = Math.max(...match.nodes.map((node) => node.getEnd()));
|
|
138
|
+
return {
|
|
139
|
+
path: path.relative(workspaceRoot, fileName),
|
|
140
|
+
contents,
|
|
141
|
+
start: sourceFile.getLineAndCharacterOfPosition(start).line + 1,
|
|
142
|
+
end:
|
|
143
|
+
sourceFile.getLineAndCharacterOfPosition(Math.max(start, end - 1))
|
|
144
|
+
.line + 1,
|
|
145
|
+
} satisfies SourceDefinition;
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
const lineCount = contents.split("\n").length;
|
|
149
|
+
const start = params.has("start") ? Number(params.get("start")) : 1;
|
|
150
|
+
const end = params.has("end") ? Number(params.get("end")) : lineCount;
|
|
151
|
+
if (
|
|
152
|
+
!Number.isSafeInteger(start) ||
|
|
153
|
+
!Number.isSafeInteger(end) ||
|
|
154
|
+
start < 1 ||
|
|
155
|
+
end < start ||
|
|
156
|
+
end > lineCount
|
|
157
|
+
)
|
|
158
|
+
return new DiffmapDefinitionError({
|
|
159
|
+
reason: `Invalid line range for ${requestedPath}.`,
|
|
160
|
+
});
|
|
161
|
+
return {
|
|
162
|
+
path: path.relative(workspaceRoot, fileName),
|
|
163
|
+
contents,
|
|
164
|
+
start,
|
|
165
|
+
end,
|
|
166
|
+
} satisfies SourceDefinition;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function readSource(fileName: string, reason: string) {
|
|
170
|
+
return errore.try({
|
|
171
|
+
try: () => readFileSync(fileName, "utf8"),
|
|
172
|
+
catch: (cause) => new DiffmapDefinitionError({ reason, cause }),
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function withProject<T>(
|
|
177
|
+
workspaceRoot: string,
|
|
178
|
+
fileName: string,
|
|
179
|
+
run: (project: Project, source: SourceFile) => T,
|
|
180
|
+
) {
|
|
181
|
+
return errore.try({
|
|
182
|
+
try: () => {
|
|
183
|
+
using resources = new errore.DisposableStack();
|
|
184
|
+
const api = new API({ cwd: workspaceRoot });
|
|
185
|
+
resources.defer(() => api.close());
|
|
186
|
+
const snapshot = api.updateSnapshot({ openFiles: [fileName] });
|
|
187
|
+
resources.defer(() => snapshot.dispose());
|
|
188
|
+
const project = snapshot.getDefaultProjectForFile(fileName);
|
|
189
|
+
const source = project?.program.getSourceFile(fileName);
|
|
190
|
+
if (project === undefined || source === undefined)
|
|
191
|
+
return new DiffmapDefinitionError({
|
|
192
|
+
reason: "TypeScript could not open this source file.",
|
|
193
|
+
});
|
|
194
|
+
return run(project, source);
|
|
195
|
+
},
|
|
196
|
+
catch: (cause) =>
|
|
197
|
+
new DiffmapDefinitionError({
|
|
198
|
+
reason: "TypeScript could not resolve this source file.",
|
|
199
|
+
cause,
|
|
200
|
+
}),
|
|
201
|
+
});
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
type SourceDeclaration = {
|
|
205
|
+
name: string;
|
|
206
|
+
nodes: Node[];
|
|
207
|
+
};
|
|
208
|
+
|
|
209
|
+
function sourceDeclarations(
|
|
210
|
+
project: Project,
|
|
211
|
+
source: SourceFile,
|
|
212
|
+
requestedName: string,
|
|
213
|
+
): SourceDeclaration[] {
|
|
214
|
+
const resolved = resolveSourceSymbol(project, source, requestedName);
|
|
215
|
+
if (resolved.length > 0) return resolved;
|
|
216
|
+
|
|
217
|
+
const matches = new Map<string, Node[]>();
|
|
218
|
+
const visit = (node: Node, parents: string[]) => {
|
|
219
|
+
const nameNode = isSourceDeclaration(node)
|
|
220
|
+
? declarationName(node)
|
|
221
|
+
: undefined;
|
|
222
|
+
const name =
|
|
223
|
+
nameNode === undefined
|
|
224
|
+
? undefined
|
|
225
|
+
: declarationNameText(nameNode, source);
|
|
226
|
+
const qualifiedName =
|
|
227
|
+
name === undefined ? undefined : [...parents, name].join(".");
|
|
228
|
+
if (
|
|
229
|
+
qualifiedName !== undefined &&
|
|
230
|
+
(qualifiedName === requestedName ||
|
|
231
|
+
qualifiedName.endsWith(`.${requestedName}`))
|
|
232
|
+
) {
|
|
233
|
+
const declarations = matches.get(qualifiedName) ?? [];
|
|
234
|
+
declarations.push(node);
|
|
235
|
+
matches.set(qualifiedName, declarations);
|
|
236
|
+
}
|
|
237
|
+
const childParents =
|
|
238
|
+
name !== undefined && isDeclarationContainer(node)
|
|
239
|
+
? [...parents, name]
|
|
240
|
+
: parents;
|
|
241
|
+
node.forEachChild((child) => visit(child, childParents));
|
|
242
|
+
};
|
|
243
|
+
source.forEachChild((child) => visit(child, []));
|
|
244
|
+
return [...matches].map(([name, nodes]) => ({ name, nodes }));
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
function resolveSourceSymbol(
|
|
248
|
+
project: Project,
|
|
249
|
+
source: SourceFile,
|
|
250
|
+
requestedName: string,
|
|
251
|
+
): SourceDeclaration[] {
|
|
252
|
+
const [rootName, ...memberNames] = requestedName.split(".");
|
|
253
|
+
if (rootName === undefined || rootName.length === 0) return [];
|
|
254
|
+
let symbol = project.checker.resolveName(
|
|
255
|
+
rootName,
|
|
256
|
+
SymbolFlags.Value | SymbolFlags.Type | SymbolFlags.Namespace,
|
|
257
|
+
{ document: source.fileName, position: 0 },
|
|
258
|
+
);
|
|
259
|
+
for (const memberName of memberNames) {
|
|
260
|
+
if (symbol === undefined) return [];
|
|
261
|
+
symbol = symbolMember(symbol, memberName);
|
|
262
|
+
}
|
|
263
|
+
if (symbol === undefined) return [];
|
|
264
|
+
const nodes = symbol.declarations.flatMap((handle) => {
|
|
265
|
+
const node = handle.resolve();
|
|
266
|
+
if (node === undefined || node.getSourceFile().fileName !== source.fileName)
|
|
267
|
+
return [];
|
|
268
|
+
return [node];
|
|
269
|
+
});
|
|
270
|
+
return nodes.length === 0 ? [] : [{ name: requestedName, nodes }];
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
function symbolMember(symbol: TypeScriptSymbol, name: string) {
|
|
274
|
+
return (
|
|
275
|
+
[...symbol.getMembers()].find(([memberName]) => memberName === name)?.[1] ??
|
|
276
|
+
[...symbol.getExports()].find(([exportName]) => exportName === name)?.[1]
|
|
277
|
+
);
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
function declarationName(node: Node): Node | undefined {
|
|
281
|
+
if (!("name" in node)) return undefined;
|
|
282
|
+
return (node as Node & { readonly name?: Node }).name;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
function declarationNameText(name: Node, source: SourceFile) {
|
|
286
|
+
if ("text" in name) {
|
|
287
|
+
const text = (name as Node & { readonly text?: unknown }).text;
|
|
288
|
+
if (typeof text === "string") return text;
|
|
289
|
+
}
|
|
290
|
+
return name.getText(source);
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
function isSourceDeclaration(node: Node) {
|
|
294
|
+
return (
|
|
295
|
+
isClassDeclaration(node) ||
|
|
296
|
+
isEnumDeclaration(node) ||
|
|
297
|
+
isEnumMember(node) ||
|
|
298
|
+
isFunctionDeclaration(node) ||
|
|
299
|
+
isGetAccessorDeclaration(node) ||
|
|
300
|
+
isInterfaceDeclaration(node) ||
|
|
301
|
+
isMethodDeclaration(node) ||
|
|
302
|
+
isMethodSignatureDeclaration(node) ||
|
|
303
|
+
isModuleDeclaration(node) ||
|
|
304
|
+
isPropertyDeclaration(node) ||
|
|
305
|
+
isPropertySignatureDeclaration(node) ||
|
|
306
|
+
isSetAccessorDeclaration(node) ||
|
|
307
|
+
isTypeAliasDeclaration(node) ||
|
|
308
|
+
isVariableDeclaration(node)
|
|
309
|
+
);
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
function isDeclarationContainer(node: Node) {
|
|
313
|
+
return (
|
|
314
|
+
isClassDeclaration(node) ||
|
|
315
|
+
isEnumDeclaration(node) ||
|
|
316
|
+
isFunctionDeclaration(node) ||
|
|
317
|
+
isInterfaceDeclaration(node) ||
|
|
318
|
+
isModuleDeclaration(node)
|
|
319
|
+
);
|
|
320
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { parseAnnotation, type SourceAnnotation } from "./annotations.js";
|
|
2
|
+
|
|
3
|
+
export type DiagramAnnotation = {
|
|
4
|
+
target: "node" | "edge";
|
|
5
|
+
id: string;
|
|
6
|
+
annotation: SourceAnnotation;
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
export function parseDiagramAnnotations(source: string): DiagramAnnotation[] {
|
|
10
|
+
return source.split("\n").flatMap((line) => {
|
|
11
|
+
const directive = /^\s*%%\s+ref\s+(node|edge):([^\s]+)\s+(.+)$/.exec(line);
|
|
12
|
+
if (directive === null) {
|
|
13
|
+
if (/^\s*%%\s+ref\b/.test(line))
|
|
14
|
+
return [
|
|
15
|
+
{
|
|
16
|
+
target: "node",
|
|
17
|
+
id: "",
|
|
18
|
+
annotation: { text: line, references: [] },
|
|
19
|
+
},
|
|
20
|
+
];
|
|
21
|
+
return [];
|
|
22
|
+
}
|
|
23
|
+
const annotation = parseAnnotation(directive[3]!);
|
|
24
|
+
if (annotation.text.trim().length === 0)
|
|
25
|
+
annotation.text = `${directive[1]} ${directive[2]}`;
|
|
26
|
+
return [
|
|
27
|
+
{
|
|
28
|
+
target: directive[1] === "node" ? "node" : "edge",
|
|
29
|
+
id: directive[2]!,
|
|
30
|
+
annotation,
|
|
31
|
+
},
|
|
32
|
+
];
|
|
33
|
+
});
|
|
34
|
+
}
|
package/src/errors.ts
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import * as errore from "errore";
|
|
2
|
+
|
|
3
|
+
export class DiffmapServeError extends errore.createTaggedError({
|
|
4
|
+
name: "DiffmapServeError",
|
|
5
|
+
message: "diffmap server failed: $reason",
|
|
6
|
+
}) {}
|
|
7
|
+
|
|
8
|
+
export class DiffmapFileError extends errore.createTaggedError({
|
|
9
|
+
name: "DiffmapFileError",
|
|
10
|
+
message: "diffmap could not read $path: $reason",
|
|
11
|
+
}) {}
|
|
12
|
+
|
|
13
|
+
export class DiffmapRegistryError extends errore.createTaggedError({
|
|
14
|
+
name: "DiffmapRegistryError",
|
|
15
|
+
message: "diffmap registry failed: $reason",
|
|
16
|
+
}) {}
|
|
17
|
+
|
|
18
|
+
export class DiffmapMermaidError extends errore.createTaggedError({
|
|
19
|
+
name: "DiffmapMermaidError",
|
|
20
|
+
message: "diffmap could not render mermaid",
|
|
21
|
+
}) {}
|
|
22
|
+
|
|
23
|
+
export class DiffmapParseError extends errore.createTaggedError({
|
|
24
|
+
name: "DiffmapParseError",
|
|
25
|
+
message: "diffmap could not parse markdown",
|
|
26
|
+
}) {}
|
|
27
|
+
|
|
28
|
+
export class DiffmapAnnotationError extends errore.createTaggedError({
|
|
29
|
+
name: "DiffmapAnnotationError",
|
|
30
|
+
message: "diffmap source annotation: $reason",
|
|
31
|
+
}) {}
|
|
32
|
+
|
|
33
|
+
export class DiffmapDefinitionError extends errore.createTaggedError({
|
|
34
|
+
name: "DiffmapDefinitionError",
|
|
35
|
+
message: "$reason",
|
|
36
|
+
}) {}
|
|
37
|
+
|
|
38
|
+
export class DiffmapShareError extends errore.createTaggedError({
|
|
39
|
+
name: "DiffmapShareError",
|
|
40
|
+
message: "diffmap share failed: $reason",
|
|
41
|
+
}) {}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { parseFence, type Fence } from "./parseFence.js";
|
|
2
|
+
|
|
3
|
+
const fencePattern = /^```([^\n]*)\r?\n([\s\S]*?)^```/gm;
|
|
4
|
+
const titlePattern = /^#\s+(.+)$/m;
|
|
5
|
+
|
|
6
|
+
export function extractTitle(source: string) {
|
|
7
|
+
const match = titlePattern.exec(source);
|
|
8
|
+
if (match?.[1] === undefined) return "diffmap";
|
|
9
|
+
return match[1];
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function extractFences(source: string) {
|
|
13
|
+
const fences: Fence[] = [];
|
|
14
|
+
for (const match of source.matchAll(fencePattern)) {
|
|
15
|
+
const lang = match[1] ?? "";
|
|
16
|
+
const body = match[2] ?? "";
|
|
17
|
+
fences.push(parseFence(lang.trim(), body));
|
|
18
|
+
}
|
|
19
|
+
return fences;
|
|
20
|
+
}
|
|
@@ -0,0 +1,292 @@
|
|
|
1
|
+
import { useEffect, useState } from "react";
|
|
2
|
+
import { Button, Link, P } from "maui";
|
|
3
|
+
import { style, useStyles } from "purse-styles";
|
|
4
|
+
import { init } from "md4x/standalone";
|
|
5
|
+
import { parseViewerDocument } from "../parseViewer.ts";
|
|
6
|
+
import type { ViewerDocument } from "../parseViewer.ts";
|
|
7
|
+
import { ViewerApp } from "../viewer/ViewerApp.tsx";
|
|
8
|
+
import { loadGistMarkdown, type GistDocument } from "./fetchGist.ts";
|
|
9
|
+
import { GistLanding, GistLoading, GistStatus } from "./GistStatus.tsx";
|
|
10
|
+
import {
|
|
11
|
+
gistPath,
|
|
12
|
+
parseGistPath,
|
|
13
|
+
rewritePiGistUrl,
|
|
14
|
+
type GistRoute,
|
|
15
|
+
} from "./route.ts";
|
|
16
|
+
|
|
17
|
+
let parserReady: Promise<void> | undefined;
|
|
18
|
+
|
|
19
|
+
function readyGistParser() {
|
|
20
|
+
parserReady ??= init();
|
|
21
|
+
return parserReady;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
type GistView =
|
|
25
|
+
| { kind: "not-found"; gistId: string }
|
|
26
|
+
| { kind: "forbidden"; gistId: string }
|
|
27
|
+
| { kind: "no-markdown"; gistId: string; htmlUrl: string }
|
|
28
|
+
| {
|
|
29
|
+
kind: "missing-file";
|
|
30
|
+
gistId: string;
|
|
31
|
+
htmlUrl: string;
|
|
32
|
+
files: { name: string }[];
|
|
33
|
+
file: string;
|
|
34
|
+
}
|
|
35
|
+
| { kind: "error"; gistId: string; message: string }
|
|
36
|
+
| { kind: "parse-error"; gistId: string; htmlUrl: string; message: string }
|
|
37
|
+
| {
|
|
38
|
+
kind: "ready";
|
|
39
|
+
gist: GistDocument;
|
|
40
|
+
file: string;
|
|
41
|
+
document: ViewerDocument;
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
export function GistApp() {
|
|
45
|
+
const [route, setRoute] = useState(readRoute);
|
|
46
|
+
const gistKey =
|
|
47
|
+
route.kind === "gist" ? `${route.gistId}:${route.file ?? ""}` : undefined;
|
|
48
|
+
const [loaded, setLoaded] = useState<{ key: string; view: GistView }>();
|
|
49
|
+
|
|
50
|
+
useEffect(() => {
|
|
51
|
+
const onPop = () => setRoute(readRoute());
|
|
52
|
+
window.addEventListener("popstate", onPop);
|
|
53
|
+
return () => window.removeEventListener("popstate", onPop);
|
|
54
|
+
}, []);
|
|
55
|
+
|
|
56
|
+
useEffect(() => {
|
|
57
|
+
if (route.kind !== "gist") return;
|
|
58
|
+
const gistId = route.gistId;
|
|
59
|
+
const file = route.file;
|
|
60
|
+
const key = `${gistId}:${file ?? ""}`;
|
|
61
|
+
const abort = new AbortController();
|
|
62
|
+
// oxlint-disable-next-line typescript/no-floating-promises -- The effect owns this load; abort on cleanup.
|
|
63
|
+
void loadView(gistId, file, abort.signal).then(
|
|
64
|
+
(next) => {
|
|
65
|
+
if (!abort.signal.aborted) setLoaded({ key, view: next });
|
|
66
|
+
},
|
|
67
|
+
(cause: unknown) => {
|
|
68
|
+
if (abort.signal.aborted) return;
|
|
69
|
+
setLoaded({
|
|
70
|
+
key,
|
|
71
|
+
view: {
|
|
72
|
+
kind: "error",
|
|
73
|
+
gistId,
|
|
74
|
+
message:
|
|
75
|
+
cause instanceof Error
|
|
76
|
+
? cause.message
|
|
77
|
+
: "Failed to load this gist.",
|
|
78
|
+
},
|
|
79
|
+
});
|
|
80
|
+
},
|
|
81
|
+
);
|
|
82
|
+
return () => abort.abort();
|
|
83
|
+
}, [route]);
|
|
84
|
+
|
|
85
|
+
if (route.kind === "home") return <GistLanding />;
|
|
86
|
+
if (route.kind === "invalid") {
|
|
87
|
+
return (
|
|
88
|
+
<GistStatus title="Not a gist id">
|
|
89
|
+
<P>
|
|
90
|
+
<code>{route.value}</code> is not a 32-character GitHub gist id. Use
|
|
91
|
+
the path <code>/g/<gistId></code>.
|
|
92
|
+
</P>
|
|
93
|
+
</GistStatus>
|
|
94
|
+
);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
if (loaded === undefined || loaded.key !== gistKey) return <GistLoading />;
|
|
98
|
+
const view = loaded.view;
|
|
99
|
+
if (view.kind === "not-found") {
|
|
100
|
+
return (
|
|
101
|
+
<GistStatus title="Gist not found" gistId={view.gistId}>
|
|
102
|
+
<P>GitHub has no gist with this id, or it is no longer available.</P>
|
|
103
|
+
</GistStatus>
|
|
104
|
+
);
|
|
105
|
+
}
|
|
106
|
+
if (view.kind === "forbidden") {
|
|
107
|
+
return (
|
|
108
|
+
<GistStatus title="Failed to load" gistId={view.gistId}>
|
|
109
|
+
<P>
|
|
110
|
+
GitHub refused this request. Unauthenticated API calls are limited to
|
|
111
|
+
60 per hour per IP. Wait and refresh, or open the gist on GitHub.
|
|
112
|
+
</P>
|
|
113
|
+
</GistStatus>
|
|
114
|
+
);
|
|
115
|
+
}
|
|
116
|
+
if (view.kind === "no-markdown") {
|
|
117
|
+
return (
|
|
118
|
+
<GistStatus title="No markdown" gistUrl={view.htmlUrl}>
|
|
119
|
+
<P>
|
|
120
|
+
This gist has no <code>.md</code> or <code>.mdx</code> file.
|
|
121
|
+
</P>
|
|
122
|
+
</GistStatus>
|
|
123
|
+
);
|
|
124
|
+
}
|
|
125
|
+
if (view.kind === "missing-file") {
|
|
126
|
+
return (
|
|
127
|
+
<GistStatus title="File not found" gistUrl={view.htmlUrl}>
|
|
128
|
+
<P>
|
|
129
|
+
<code>{view.file}</code> is not a markdown file in this gist.
|
|
130
|
+
</P>
|
|
131
|
+
<FileLinks gistId={view.gistId} files={view.files} />
|
|
132
|
+
</GistStatus>
|
|
133
|
+
);
|
|
134
|
+
}
|
|
135
|
+
if (view.kind === "error") {
|
|
136
|
+
return (
|
|
137
|
+
<GistStatus title="Failed to load" gistId={view.gistId}>
|
|
138
|
+
<P>{view.message}</P>
|
|
139
|
+
</GistStatus>
|
|
140
|
+
);
|
|
141
|
+
}
|
|
142
|
+
if (view.kind === "parse-error") {
|
|
143
|
+
return (
|
|
144
|
+
<GistStatus
|
|
145
|
+
title="Could not parse this walkthrough"
|
|
146
|
+
gistUrl={view.htmlUrl}
|
|
147
|
+
>
|
|
148
|
+
<P>{view.message}</P>
|
|
149
|
+
</GistStatus>
|
|
150
|
+
);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
return (
|
|
154
|
+
<ViewerApp
|
|
155
|
+
key={`${view.gist.id}:${view.file}`}
|
|
156
|
+
document={view.document}
|
|
157
|
+
mode="gist"
|
|
158
|
+
headerActions={
|
|
159
|
+
<GistHeader
|
|
160
|
+
gistId={view.gist.id}
|
|
161
|
+
htmlUrl={view.gist.htmlUrl}
|
|
162
|
+
files={view.gist.files}
|
|
163
|
+
current={view.file}
|
|
164
|
+
onSelect={(file) => {
|
|
165
|
+
const next = gistPath(view.gist.id, file);
|
|
166
|
+
window.history.pushState({}, "", next);
|
|
167
|
+
setRoute(parseGistPath(next));
|
|
168
|
+
}}
|
|
169
|
+
/>
|
|
170
|
+
}
|
|
171
|
+
/>
|
|
172
|
+
);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
async function loadView(
|
|
176
|
+
gistId: string,
|
|
177
|
+
file: string | undefined,
|
|
178
|
+
signal: AbortSignal,
|
|
179
|
+
): Promise<GistView> {
|
|
180
|
+
await readyGistParser();
|
|
181
|
+
const loaded = await loadGistMarkdown(gistId, file, signal);
|
|
182
|
+
if (loaded.status === "not-found") return { kind: "not-found", gistId };
|
|
183
|
+
if (loaded.status === "forbidden") return { kind: "forbidden", gistId };
|
|
184
|
+
if (loaded.status === "error") {
|
|
185
|
+
return { kind: "error", gistId, message: loaded.message };
|
|
186
|
+
}
|
|
187
|
+
if (loaded.status === "no-markdown") {
|
|
188
|
+
return { kind: "no-markdown", gistId, htmlUrl: loaded.htmlUrl };
|
|
189
|
+
}
|
|
190
|
+
if (loaded.status === "missing-file") {
|
|
191
|
+
return {
|
|
192
|
+
kind: "missing-file",
|
|
193
|
+
gistId,
|
|
194
|
+
htmlUrl: loaded.htmlUrl,
|
|
195
|
+
files: loaded.files,
|
|
196
|
+
file: file ?? "",
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
const parsed = parseViewerDocument(loaded.gist.chosen.content);
|
|
200
|
+
if (parsed instanceof Error) {
|
|
201
|
+
return {
|
|
202
|
+
kind: "parse-error",
|
|
203
|
+
gistId,
|
|
204
|
+
htmlUrl: loaded.gist.htmlUrl,
|
|
205
|
+
message: parsed.message,
|
|
206
|
+
};
|
|
207
|
+
}
|
|
208
|
+
return {
|
|
209
|
+
kind: "ready",
|
|
210
|
+
gist: loaded.gist,
|
|
211
|
+
file: loaded.gist.chosen.name,
|
|
212
|
+
document: parsed,
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function readRoute(): GistRoute {
|
|
217
|
+
const rewritten = rewritePiGistUrl(
|
|
218
|
+
window.location.pathname,
|
|
219
|
+
window.location.hash,
|
|
220
|
+
);
|
|
221
|
+
if (rewritten !== undefined) return parseGistPath(rewritten);
|
|
222
|
+
const parsed = parseGistPath(window.location.pathname);
|
|
223
|
+
if (parsed.kind === "empty") return { kind: "home" };
|
|
224
|
+
return parsed;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function GistHeader(props: {
|
|
228
|
+
gistId: string;
|
|
229
|
+
htmlUrl: string;
|
|
230
|
+
files: { name: string }[];
|
|
231
|
+
current: string;
|
|
232
|
+
onSelect: (file: string) => void;
|
|
233
|
+
}) {
|
|
234
|
+
return (
|
|
235
|
+
<>
|
|
236
|
+
{props.files.length > 1 && (
|
|
237
|
+
<FileLinks
|
|
238
|
+
gistId={props.gistId}
|
|
239
|
+
files={props.files}
|
|
240
|
+
current={props.current}
|
|
241
|
+
onSelect={props.onSelect}
|
|
242
|
+
/>
|
|
243
|
+
)}
|
|
244
|
+
<Link href={props.htmlUrl}>Gist</Link>
|
|
245
|
+
</>
|
|
246
|
+
);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
function FileLinks(props: {
|
|
250
|
+
gistId: string;
|
|
251
|
+
files: { name: string }[];
|
|
252
|
+
current?: string;
|
|
253
|
+
onSelect?: (file: string) => void;
|
|
254
|
+
}) {
|
|
255
|
+
const group = useStyles(fileStyles.group);
|
|
256
|
+
return (
|
|
257
|
+
<div className={group} aria-label="Markdown files">
|
|
258
|
+
{props.files.map((file) => {
|
|
259
|
+
const href = gistPath(props.gistId, file.name);
|
|
260
|
+
const current = file.name === props.current;
|
|
261
|
+
const select = props.onSelect;
|
|
262
|
+
if (select === undefined) {
|
|
263
|
+
return (
|
|
264
|
+
<Link key={file.name} href={href}>
|
|
265
|
+
{file.name}
|
|
266
|
+
</Link>
|
|
267
|
+
);
|
|
268
|
+
}
|
|
269
|
+
return (
|
|
270
|
+
<Button
|
|
271
|
+
key={file.name}
|
|
272
|
+
variant={current ? "primary" : "quiet"}
|
|
273
|
+
onClick={() => {
|
|
274
|
+
select(file.name);
|
|
275
|
+
}}
|
|
276
|
+
>
|
|
277
|
+
{file.name}
|
|
278
|
+
</Button>
|
|
279
|
+
);
|
|
280
|
+
})}
|
|
281
|
+
</div>
|
|
282
|
+
);
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
const fileStyles = {
|
|
286
|
+
group: style({
|
|
287
|
+
display: "flex",
|
|
288
|
+
flexWrap: "wrap",
|
|
289
|
+
gap: "6px",
|
|
290
|
+
alignItems: "center",
|
|
291
|
+
}),
|
|
292
|
+
};
|