@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.
Files changed (51) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +212 -0
  3. package/bin.js +5 -0
  4. package/index.html +38 -0
  5. package/package.json +71 -0
  6. package/src/annotations.ts +82 -0
  7. package/src/cli.ts +94 -0
  8. package/src/codeFont.ts +2 -0
  9. package/src/contentPlugin.ts +47 -0
  10. package/src/definitions.ts +320 -0
  11. package/src/diagramAnnotations.ts +34 -0
  12. package/src/errors.ts +41 -0
  13. package/src/extractDocument.ts +20 -0
  14. package/src/gist/GistApp.tsx +292 -0
  15. package/src/gist/GistStatus.tsx +137 -0
  16. package/src/gist/fetchGist.ts +128 -0
  17. package/src/gist/main.tsx +25 -0
  18. package/src/gist/route.test.ts +90 -0
  19. package/src/gist/route.ts +134 -0
  20. package/src/index.ts +22 -0
  21. package/src/invokeCwd.ts +10 -0
  22. package/src/mermaid.ts +29 -0
  23. package/src/normalizeDiff.ts +39 -0
  24. package/src/parseFence.ts +91 -0
  25. package/src/parseViewer.ts +399 -0
  26. package/src/registry.ts +121 -0
  27. package/src/serve.ts +350 -0
  28. package/src/share.ts +94 -0
  29. package/src/viewer/CallStackDiff.tsx +156 -0
  30. package/src/viewer/Check.tsx +24 -0
  31. package/src/viewer/CodeDiff.tsx +26 -0
  32. package/src/viewer/ComarkView.tsx +209 -0
  33. package/src/viewer/DiffButton.tsx +24 -0
  34. package/src/viewer/DoneButton.tsx +32 -0
  35. package/src/viewer/Fence.tsx +51 -0
  36. package/src/viewer/FileExcerpt.tsx +65 -0
  37. package/src/viewer/HtmlBlock.tsx +19 -0
  38. package/src/viewer/HtmlPlaceholder.tsx +29 -0
  39. package/src/viewer/MermaidBlock.tsx +126 -0
  40. package/src/viewer/SourceDiffPanel.tsx +305 -0
  41. package/src/viewer/TableOfContents.tsx +211 -0
  42. package/src/viewer/ViewerApp.tsx +257 -0
  43. package/src/viewer/diagramLinks.ts +74 -0
  44. package/src/viewer/diffsTheme.ts +4 -0
  45. package/src/viewer/main.tsx +17 -0
  46. package/src/viewer/pierre.ts +47 -0
  47. package/src/viewer/sourceNavigation.ts +104 -0
  48. package/src/viewer/styles.css +9 -0
  49. package/src/viewer/viewerMode.ts +9 -0
  50. package/src/viewer/vite-env.d.ts +4 -0
  51. package/vite.config.ts +41 -0
@@ -0,0 +1,209 @@
1
+ import { createElement, type ReactNode, useState } from "react";
2
+ import {
3
+ Checkbox,
4
+ colors,
5
+ monospace,
6
+ radius,
7
+ spacing,
8
+ Table,
9
+ TableHeader,
10
+ TableHead,
11
+ TableBody,
12
+ TableRow,
13
+ TableCell,
14
+ type TableAlign,
15
+ } from "maui";
16
+ import { style, useStyles } from "purse-styles";
17
+ import type {
18
+ ViewerDocument,
19
+ ViewerElement,
20
+ ViewerNode,
21
+ } from "../parseViewer.js";
22
+ import type { SourceNavigation } from "../annotations.js";
23
+ import { Fence } from "./Fence.tsx";
24
+ import { HtmlPlaceholder } from "./HtmlPlaceholder.tsx";
25
+ import { useViewerMode } from "./viewerMode.ts";
26
+
27
+ const voidTags = new Set(["img", "hr", "br"]);
28
+
29
+ export function ComarkView(
30
+ props: { document: ViewerDocument } & SourceNavigation,
31
+ ) {
32
+ const trustedHtml = useViewerMode() === "local";
33
+ return (
34
+ <>
35
+ {props.document.nodes.map((node, index) =>
36
+ renderNode(node, index, props, trustedHtml),
37
+ )}
38
+ </>
39
+ );
40
+ }
41
+
42
+ function renderNode(
43
+ node: ViewerNode,
44
+ key: number,
45
+ navigation: SourceNavigation,
46
+ trustedHtml: boolean,
47
+ ): ReactNode {
48
+ if (node.type === "text") return node.value;
49
+ if (node.type === "html") {
50
+ if (!trustedHtml) {
51
+ return <HtmlPlaceholder key={key} inline={node.block !== true} />;
52
+ }
53
+ if (node.block) {
54
+ return (
55
+ <div key={key} dangerouslySetInnerHTML={{ __html: node.source }} />
56
+ );
57
+ }
58
+ return <span key={key} dangerouslySetInnerHTML={{ __html: node.source }} />;
59
+ }
60
+ if (node.type === "view") {
61
+ return <Fence key={key} fence={node.fence} {...navigation} />;
62
+ }
63
+ return renderElement(node, key, navigation, trustedHtml);
64
+ }
65
+
66
+ function renderElement(
67
+ node: ViewerElement,
68
+ key: number,
69
+ navigation: SourceNavigation,
70
+ trustedHtml: boolean,
71
+ ): ReactNode {
72
+ if (node.tag === "table")
73
+ return renderTable(node, key, navigation, trustedHtml);
74
+ const children = node.children.map((child, index) =>
75
+ renderNode(child, index, navigation, trustedHtml),
76
+ );
77
+ if (node.tag === "alert") {
78
+ return (
79
+ <blockquote key={key} data-alert={node.attrs.alertType}>
80
+ {children}
81
+ </blockquote>
82
+ );
83
+ }
84
+ if (node.tag === "li" && node.attrs.task === true) {
85
+ return (
86
+ <TaskListItem key={key} node={node}>
87
+ {children}
88
+ </TaskListItem>
89
+ );
90
+ }
91
+ if (node.tag === "code") {
92
+ return <InlineChip key={key}>{children}</InlineChip>;
93
+ }
94
+ if (voidTags.has(node.tag)) {
95
+ return createElement(node.tag, { key, ...domAttrs(node) });
96
+ }
97
+ return createElement(node.tag, { key, ...domAttrs(node) }, children);
98
+ }
99
+
100
+ function tableElements(node: ViewerElement, tag: string): ViewerElement[] {
101
+ return node.children.filter(
102
+ (child): child is ViewerElement =>
103
+ child.type === "element" && child.tag === tag,
104
+ );
105
+ }
106
+
107
+ function tableAlign(node: ViewerElement): TableAlign | undefined {
108
+ if (node.attrs.align === "left") return "start";
109
+ if (node.attrs.align === "right") return "end";
110
+ if (node.attrs.align === "center") return "center";
111
+ return undefined;
112
+ }
113
+
114
+ function renderTable(
115
+ node: ViewerElement,
116
+ key: number,
117
+ navigation: SourceNavigation,
118
+ trustedHtml: boolean,
119
+ ) {
120
+ const headers = tableElements(node, "thead")
121
+ .flatMap((head) => tableElements(head, "tr"))
122
+ .flatMap((row) => tableElements(row, "th"));
123
+ const rows = tableElements(node, "tbody").flatMap((body) =>
124
+ tableElements(body, "tr"),
125
+ );
126
+ return (
127
+ <Table key={key} aria-label={headers.map(nodeText).join(", ")}>
128
+ <TableHeader>
129
+ {headers.map((header, index) => (
130
+ <TableHead
131
+ key={index}
132
+ id={`column-${index}`}
133
+ isRowHeader={index === 0}
134
+ align={tableAlign(header)}
135
+ >
136
+ {header.children.map((child, i) =>
137
+ renderNode(child, i, navigation, trustedHtml),
138
+ )}
139
+ </TableHead>
140
+ ))}
141
+ </TableHeader>
142
+ <TableBody>
143
+ {rows.map((row, index) => (
144
+ <TableRow key={index} id={`row-${index}`} textValue={nodeText(row)}>
145
+ {tableElements(row, "td").map((cell, i) => (
146
+ <TableCell key={i} align={tableAlign(cell)}>
147
+ {cell.children.map((child, j) =>
148
+ renderNode(child, j, navigation, trustedHtml),
149
+ )}
150
+ </TableCell>
151
+ ))}
152
+ </TableRow>
153
+ ))}
154
+ </TableBody>
155
+ </Table>
156
+ );
157
+ }
158
+
159
+ function TaskListItem(props: { node: ViewerElement; children: ReactNode }) {
160
+ const [checked, setChecked] = useState(props.node.attrs.checked === true);
161
+ return (
162
+ <li
163
+ id={props.node.attrs.id}
164
+ className={props.node.attrs.className}
165
+ data-task=""
166
+ >
167
+ <span className="diffmap-task-checkbox">
168
+ <Checkbox
169
+ label={nodeText(props.node)}
170
+ checked={checked}
171
+ setChecked={setChecked}
172
+ />
173
+ </span>
174
+ {props.children}
175
+ </li>
176
+ );
177
+ }
178
+
179
+ function nodeText(node: ViewerNode): string {
180
+ if (node.type === "text") return node.value;
181
+ if (node.type !== "element") return "";
182
+ return node.children.map(nodeText).join("");
183
+ }
184
+
185
+ function domAttrs(node: ViewerElement) {
186
+ return {
187
+ id: node.attrs.id,
188
+ href: node.attrs.href,
189
+ src: node.attrs.src,
190
+ alt: node.attrs.alt,
191
+ title: node.attrs.title,
192
+ className: node.attrs.className,
193
+ start: node.attrs.start,
194
+ };
195
+ }
196
+
197
+ function InlineChip(props: { children?: ReactNode }) {
198
+ const className = useStyles(inlineCodeClass);
199
+ return <code className={className}>{props.children}</code>;
200
+ }
201
+
202
+ const inlineCodeClass = style(
203
+ monospace,
204
+ radius.md,
205
+ spacing.padding({ x: 2, y: 1 }),
206
+ {
207
+ backgroundColor: colors.gray[4],
208
+ },
209
+ );
@@ -0,0 +1,24 @@
1
+ import { Button, GitDiff, backgroundColor } from "maui";
2
+ import { style, useStyles } from "purse-styles";
3
+
4
+ export function DiffButton(props: { pressed: boolean; onClick?: () => void }) {
5
+ const className = useStyles(pressedClass);
6
+ return (
7
+ <Button
8
+ className={className}
9
+ aria-pressed={props.pressed}
10
+ aria-expanded={props.pressed}
11
+ aria-controls="source-diff-panel"
12
+ onClick={props.onClick}
13
+ >
14
+ <GitDiff size="sm" />
15
+ Diff
16
+ </Button>
17
+ );
18
+ }
19
+
20
+ const pressedClass = style({
21
+ "&[aria-pressed='true']": {
22
+ backgroundColor: backgroundColor.elementActive,
23
+ },
24
+ });
@@ -0,0 +1,32 @@
1
+ import { Button, background, colors, iconSizeValues, text } from "maui";
2
+ import { style, useStyles } from "purse-styles";
3
+ import { Check } from "./Check.tsx";
4
+
5
+ const checkIcon = style({
6
+ width: iconSizeValues.sm,
7
+ height: iconSizeValues.sm,
8
+ });
9
+
10
+ export function DoneButton(props: { onClick?: () => void }) {
11
+ const checkClass = useStyles(checkIcon);
12
+ const primaryClass = useStyles(primaryButtonClass);
13
+ return (
14
+ <Button className={primaryClass} onClick={props.onClick}>
15
+ <Check className={checkClass} />
16
+ Done
17
+ </Button>
18
+ );
19
+ }
20
+
21
+ const primaryButtonClass = style(
22
+ background.accent,
23
+ text({ size: "xs", fontWeight: 400, color: "onAccent" }),
24
+ {
25
+ "&:hover": {
26
+ backgroundColor: colors.accent[10],
27
+ },
28
+ "&:active": {
29
+ backgroundColor: colors.accent[11],
30
+ },
31
+ },
32
+ );
@@ -0,0 +1,51 @@
1
+ import { CodeBlock } from "maui";
2
+ import type { Fence as FenceModel } from "../parseFence.js";
3
+ import type { SourceNavigation } from "../annotations.js";
4
+ import { CallStackDiff } from "./CallStackDiff.tsx";
5
+ import { CodeDiff } from "./CodeDiff.tsx";
6
+ import { FileExcerpt } from "./FileExcerpt.tsx";
7
+ import { HtmlBlock } from "./HtmlBlock.tsx";
8
+ import { HtmlPlaceholder } from "./HtmlPlaceholder.tsx";
9
+ import { MermaidBlock } from "./MermaidBlock.tsx";
10
+ import { useViewerMode } from "./viewerMode.ts";
11
+
12
+ export function Fence(props: { fence: FenceModel } & SourceNavigation) {
13
+ const fence = props.fence;
14
+ const trustedHtml = useViewerMode() === "local";
15
+ if (fence.kind === "mermaid")
16
+ return (
17
+ <MermaidBlock
18
+ source={fence.source}
19
+ annotations={fence.annotations}
20
+ selectedAnnotation={props.selectedAnnotation}
21
+ onSelectAnnotation={props.onSelectAnnotation}
22
+ />
23
+ );
24
+ if (fence.kind === "html") {
25
+ if (!trustedHtml) return <HtmlPlaceholder />;
26
+ return <HtmlBlock source={fence.source} />;
27
+ }
28
+ if (fence.kind === "source-diff") return undefined;
29
+ if (fence.kind === "callstack")
30
+ return (
31
+ <CallStackDiff
32
+ lines={fence.lines}
33
+ selectedAnnotation={props.selectedAnnotation}
34
+ onSelectAnnotation={props.onSelectAnnotation}
35
+ />
36
+ );
37
+ if (fence.kind === "diff") {
38
+ return <CodeDiff source={fence.source} path={fence.path} />;
39
+ }
40
+ if (fence.kind === "file") {
41
+ return (
42
+ <FileExcerpt
43
+ path={fence.path}
44
+ start={fence.start}
45
+ end={fence.end}
46
+ fallback={fence.source}
47
+ />
48
+ );
49
+ }
50
+ return <CodeBlock lang={fence.lang}>{fence.source}</CodeBlock>;
51
+ }
@@ -0,0 +1,65 @@
1
+ import { useEffect, useState } from "react";
2
+ import { File } from "@pierre/diffs/react";
3
+ import { useTheme } from "maui";
4
+ import { useStyles } from "purse-styles";
5
+ import { pierreFileOptions, pierreShell } from "./pierre.ts";
6
+ import { useViewerMode } from "./viewerMode.ts";
7
+
8
+ type FileExcerptPayload = {
9
+ path: string;
10
+ start: number;
11
+ end: number;
12
+ contents: string;
13
+ };
14
+
15
+ export function FileExcerpt(props: {
16
+ path: string;
17
+ start: number;
18
+ end: number;
19
+ fallback: string;
20
+ }) {
21
+ const { resolvedTheme } = useTheme();
22
+ const shell = useStyles(pierreShell);
23
+ const excerpt = useFileExcerpt(props);
24
+ const pierre = pierreFileOptions(resolvedTheme);
25
+
26
+ return (
27
+ <div className={shell} data-file-path={props.path} data-diffmap-kind="file">
28
+ <File
29
+ file={{
30
+ name: props.path,
31
+ contents: excerpt === undefined ? props.fallback : excerpt.contents,
32
+ }}
33
+ disableWorkerPool
34
+ options={pierre}
35
+ />
36
+ </div>
37
+ );
38
+ }
39
+
40
+ function useFileExcerpt(input: { path: string; start: number; end: number }) {
41
+ const [excerpt, setExcerpt] = useState<FileExcerptPayload>();
42
+ const mode = useViewerMode();
43
+ useEffect(() => {
44
+ if (mode === "gist") return;
45
+ const params = new URLSearchParams({
46
+ path: input.path,
47
+ start: String(input.start),
48
+ end: String(input.end),
49
+ });
50
+ void fetch(`/__diffmap/file?${params.toString()}`)
51
+ .then((response) => {
52
+ if (!response.ok) return undefined;
53
+ return response.json();
54
+ })
55
+ .then((value) => {
56
+ if (value === undefined) return;
57
+ // SAFETY: the diffmap CLI serves FileExcerpt JSON for this route.
58
+ setExcerpt(value as FileExcerptPayload);
59
+ })
60
+ .catch((cause) => {
61
+ console.warn("diffmap file excerpt failed", cause);
62
+ });
63
+ }, [input.path, input.start, input.end, mode]);
64
+ return excerpt;
65
+ }
@@ -0,0 +1,19 @@
1
+ import { radius, shadow, spacing } from "maui";
2
+ import { style, useStyles } from "purse-styles";
3
+
4
+ export function HtmlBlock(props: { source: string }) {
5
+ const className = useStyles(styles.shell);
6
+ return (
7
+ <div
8
+ className={className}
9
+ data-diffmap-kind="html"
10
+ dangerouslySetInnerHTML={{ __html: props.source }}
11
+ />
12
+ );
13
+ }
14
+
15
+ const styles = {
16
+ shell: style(radius.md, shadow.subtle, spacing.padding({ all: 4 }), {
17
+ minWidth: 0,
18
+ }),
19
+ };
@@ -0,0 +1,29 @@
1
+ import { colors, radius, spacing, text } from "maui";
2
+ import { style, useStyles } from "purse-styles";
3
+
4
+ export function HtmlPlaceholder(props: { inline?: boolean }) {
5
+ const inline = props.inline === true;
6
+ const className = useStyles(inline ? styles.inline : styles.block);
7
+ const copy = "HTML from this gist is not rendered.";
8
+ if (inline) return <span className={className}>{copy}</span>;
9
+ return (
10
+ <aside className={className} data-diffmap-kind="html-disabled">
11
+ {copy}
12
+ </aside>
13
+ );
14
+ }
15
+
16
+ const styles = {
17
+ block: style(
18
+ radius.md,
19
+ spacing.padding({ all: 4 }),
20
+ text({ size: "sm", color: "lowContrast" }),
21
+ {
22
+ minWidth: 0,
23
+ backgroundColor: colors.gray[3],
24
+ },
25
+ ),
26
+ inline: style(text({ size: "sm", color: "lowContrast" }), {
27
+ backgroundColor: colors.gray[3],
28
+ }),
29
+ };
@@ -0,0 +1,126 @@
1
+ import { useEffect, useMemo, useRef } from "react";
2
+ import { backgroundColor, colors, fontFamily } from "maui";
3
+ import { style, useStyles } from "purse-styles";
4
+ import { mermaidSvg } from "../mermaid.js";
5
+ import type { DiagramAnnotation } from "../diagramAnnotations.js";
6
+ import type { SourceNavigation } from "../annotations.js";
7
+ import { linkDiagram } from "./diagramLinks.js";
8
+
9
+ export function MermaidBlock(
10
+ props: {
11
+ source: string;
12
+ annotations: DiagramAnnotation[];
13
+ } & SourceNavigation,
14
+ ) {
15
+ const container = useRef<HTMLDivElement>(null);
16
+ const shell = useStyles(styles.shell);
17
+ const svg = useMemo(
18
+ () =>
19
+ mermaidSvg({
20
+ source: props.source,
21
+ bg: backgroundColor.app,
22
+ fg: colors.gray[12],
23
+ accent: colors.accent[9],
24
+ muted: colors.gray[11],
25
+ surface: backgroundColor.element,
26
+ border: colors.gray[6],
27
+ font: fontFamily,
28
+ }),
29
+ [props.source],
30
+ );
31
+ const linked = useMemo(
32
+ () => (svg instanceof Error ? svg : linkDiagram(svg, props.annotations)),
33
+ [svg, props.annotations],
34
+ );
35
+ // React replaces innerHTML when this object changes, which drops SVG focus.
36
+ const html = useMemo(
37
+ () => ({ __html: linked instanceof Error ? "" : linked }),
38
+ [linked],
39
+ );
40
+ useEffect(() => {
41
+ if (linked instanceof Error) return;
42
+ for (const target of container.current!.querySelectorAll(
43
+ "[data-source-index]",
44
+ )) {
45
+ const annotation =
46
+ props.annotations[Number(target.getAttribute("data-source-index"))]!
47
+ .annotation;
48
+ target.setAttribute(
49
+ "aria-pressed",
50
+ String(annotation === props.selectedAnnotation),
51
+ );
52
+ }
53
+ }, [linked, props.annotations, props.selectedAnnotation]);
54
+ if (linked instanceof Error) {
55
+ return (
56
+ <div ref={container} className={shell} data-diffmap-kind="mermaid">
57
+ {linked.message}
58
+ </div>
59
+ );
60
+ }
61
+ return (
62
+ <div
63
+ ref={container}
64
+ className={shell}
65
+ data-diffmap-kind="mermaid"
66
+ onClick={(event) => {
67
+ const target = event.target;
68
+ if (!(target instanceof Element)) return;
69
+ const element = target.closest("[data-source-index]");
70
+ if (element === null) return;
71
+ props.onSelectAnnotation(
72
+ props.annotations[Number(element.getAttribute("data-source-index"))]!
73
+ .annotation,
74
+ );
75
+ }}
76
+ onKeyDown={(event) => {
77
+ if (event.key !== "Enter" && event.key !== " ") return;
78
+ const target = event.target;
79
+ if (!(target instanceof Element)) return;
80
+ const element = target.closest("[data-source-index]");
81
+ if (element === null) return;
82
+ event.preventDefault();
83
+ props.onSelectAnnotation(
84
+ props.annotations[Number(element.getAttribute("data-source-index"))]!
85
+ .annotation,
86
+ );
87
+ }}
88
+ dangerouslySetInnerHTML={html}
89
+ />
90
+ );
91
+ }
92
+
93
+ const styles = {
94
+ shell: style({
95
+ width: "100%",
96
+ maxHeight: "60vh",
97
+ overflow: "auto",
98
+ minWidth: 0,
99
+ border: 0,
100
+ boxShadow: "none",
101
+ "& .source-target": { cursor: "pointer" },
102
+ "& .source-target:focus, & .source-target:focus-visible": {
103
+ outline: "none",
104
+ },
105
+ "& .source-hit": { pointerEvents: "stroke" },
106
+ "& .source-node:hover > g > rect, & .source-node:hover > g > polygon": {
107
+ fill: colors.blue[2],
108
+ stroke: colors.blue[8],
109
+ strokeWidth: 2,
110
+ },
111
+ "& .source-node:hover text": { fill: colors.blue[10] },
112
+ "& .source-node[aria-pressed='true'] > g > rect, & .source-node[aria-pressed='true'] > g > polygon":
113
+ { fill: colors.blue[3], stroke: colors.blue[9], strokeWidth: 2 },
114
+ "& .source-node[aria-pressed='true'] text": { fill: colors.blue[11] },
115
+ "& .source-edge:hover > .edge, & .source-edge:hover > .message > line, & .source-edge:hover > .message > polyline, & .source-edge:hover > .class-relationship, & .source-edge:hover > .er-relationship":
116
+ { stroke: colors.blue[8], strokeWidth: 1.5 },
117
+ "& .source-edge[aria-pressed='true'] > .edge, & .source-edge[aria-pressed='true'] > .message > line, & .source-edge[aria-pressed='true'] > .message > polyline, & .source-edge[aria-pressed='true'] > .class-relationship, & .source-edge[aria-pressed='true'] > .er-relationship":
118
+ { stroke: colors.blue[9], strokeWidth: 2 },
119
+ "& svg": {
120
+ display: "block",
121
+ width: "100%",
122
+ height: "auto",
123
+ maxHeight: "60vh",
124
+ },
125
+ }),
126
+ };