@reviewflow/ui 0.0.0-stage → 6.22.0
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 +15 -0
- package/README.md +95 -2
- package/package.json +101 -3
- package/src/board/bucket-section.tsx +94 -0
- package/src/board/dataSource.ts +44 -0
- package/src/board/facts.ts +183 -0
- package/src/board/index.ts +12 -0
- package/src/board/pull-request-board.tsx +239 -0
- package/src/board/pull-request-row.tsx +181 -0
- package/src/components/branch-diff/diff-themes.ts +6 -0
- package/src/components/branch-diff/diff-worker-pool.tsx +12 -0
- package/src/components/branch-diff/diff-worker-pool.web.tsx +56 -0
- package/src/components/branch-diff/diff-worker.ts +7 -0
- package/src/components/branch-diff/use-collapsed-files.ts +49 -0
- package/src/components/branch-diff/use-diff-settings.tsx +97 -0
- package/src/components/branch-diff/whitespace-button.tsx +32 -0
- package/src/components/copyable-code-block.tsx +101 -0
- package/src/components/label-pill.tsx +84 -0
- package/src/components/link-in-row.ts +6 -0
- package/src/components/markdown-suggestion.tsx +60 -0
- package/src/components/markdown-text.tsx +1067 -0
- package/src/components/pull-request-actions-merge.tsx +341 -0
- package/src/components/pull-request-actions-review.tsx +218 -0
- package/src/components/pull-request-checks.tsx +86 -0
- package/src/components/pull-request-commit-files.ts +143 -0
- package/src/components/pull-request-commits.tsx +221 -0
- package/src/components/pull-request-conversation-card.tsx +220 -0
- package/src/components/pull-request-conversation-edit.tsx +147 -0
- package/src/components/pull-request-conversation-event.tsx +267 -0
- package/src/components/pull-request-conversation-person.tsx +28 -0
- package/src/components/pull-request-conversation-thread.tsx +158 -0
- package/src/components/pull-request-diff-annotations.tsx +443 -0
- package/src/components/pull-request-diff-collapse.ts +43 -0
- package/src/components/pull-request-diff-data.ts +174 -0
- package/src/components/pull-request-diff-impacts-summary.tsx +97 -0
- package/src/components/pull-request-diff-impacts.ts +45 -0
- package/src/components/pull-request-diff-jump.ts +87 -0
- package/src/components/pull-request-diff-lines.ts +103 -0
- package/src/components/pull-request-diff-overview.tsx +416 -0
- package/src/components/pull-request-diff-view.tsx +438 -0
- package/src/components/pull-request-diff-viewer.tsx +1374 -0
- package/src/components/pull-request-files.tsx +533 -0
- package/src/components/pull-request-markdown-input.tsx +91 -0
- package/src/components/pull-request-panel.tsx +1270 -0
- package/src/components/pull-request-part-notice.tsx +82 -0
- package/src/components/pull-request-reactions.tsx +178 -0
- package/src/components/pull-request-review.tsx +164 -0
- package/src/components/pull-request-revisions.tsx +273 -0
- package/src/components/pull-request-screen.tsx +299 -0
- package/src/components/pull-request-suggestion-actions.tsx +201 -0
- package/src/components/pull-request-timeline.tsx +270 -0
- package/src/components/repository-attachment-banner.tsx +42 -0
- package/src/components/scroll-parent.ts +50 -0
- package/src/css.d.ts +1 -0
- package/src/dataSource.ts +242 -0
- package/src/editor/alerts.ts +105 -0
- package/src/editor/code-editor.native.tsx +25 -0
- package/src/editor/code-editor.tsx +112 -0
- package/src/editor/codeEditorExtensions.ts +65 -0
- package/src/editor/codeHighlight.ts +71 -0
- package/src/editor/commands.ts +175 -0
- package/src/editor/decorations.ts +425 -0
- package/src/editor/editable-file.tsx +224 -0
- package/src/editor/emojiCompletion.ts +110 -0
- package/src/editor/extensions.ts +149 -0
- package/src/editor/frontmatter.ts +76 -0
- package/src/editor/inlineFormat.ts +114 -0
- package/src/editor/lineTypes.ts +55 -0
- package/src/editor/markdown-editor.css +511 -0
- package/src/editor/markdown-editor.native.tsx +29 -0
- package/src/editor/markdown-editor.tsx +295 -0
- package/src/editor/nodeClasses.ts +69 -0
- package/src/index.ts +75 -0
- package/src/markdown-github/flatten.ts +49 -0
- package/src/markdown-github/html-tokens.ts +93 -0
- package/src/markdown-github/pair-html.ts +354 -0
- package/src/markdown-github/safe-url.ts +15 -0
- package/src/markdown-github/table-columns.ts +23 -0
- package/src/markdown-github/toggle-task.ts +164 -0
- package/src/nativewind-env.d.ts +1 -0
- package/src/prStatus.ts +90 -0
- package/src/services/PullRequestDraftsProvider.tsx +179 -0
- package/src/services/PullRequestReviewDraftProvider.tsx +178 -0
- package/src/services/PullRequestSuggestionBatchProvider.tsx +112 -0
- package/src/services/emojify.ts +13 -0
- package/src/services/errorToMessage.ts +6 -0
- package/src/services/expiringDraftStore.ts +102 -0
- package/src/services/formatDataAge.ts +12 -0
- package/src/services/formatRelativeDate.ts +56 -0
- package/src/services/githubImages.tsx +33 -0
- package/src/services/isViewerAuthor.ts +7 -0
- package/src/slots.tsx +34 -0
|
@@ -0,0 +1,1067 @@
|
|
|
1
|
+
import type { BlockNode, InlineNode, TableNode } from "@tanstack/markdown";
|
|
2
|
+
import { parseMarkdown } from "@tanstack/markdown/parser";
|
|
3
|
+
import {
|
|
4
|
+
Box,
|
|
5
|
+
Code,
|
|
6
|
+
ErrorMessage,
|
|
7
|
+
ExternalLink,
|
|
8
|
+
ExternalLinkText,
|
|
9
|
+
Icon,
|
|
10
|
+
InteractiveBox,
|
|
11
|
+
PressableBox,
|
|
12
|
+
Separator,
|
|
13
|
+
Text,
|
|
14
|
+
View,
|
|
15
|
+
} from "alouette";
|
|
16
|
+
import { CaretDownRegularIcon } from "alouette-icons/phosphor-icons/CaretDown";
|
|
17
|
+
import { CaretRightRegularIcon } from "alouette-icons/phosphor-icons/CaretRight";
|
|
18
|
+
import { CheckFatFillIcon } from "alouette-icons/phosphor-icons/CheckFat";
|
|
19
|
+
import {
|
|
20
|
+
type ReactNode,
|
|
21
|
+
createContext,
|
|
22
|
+
memo,
|
|
23
|
+
use,
|
|
24
|
+
useEffect,
|
|
25
|
+
useMemo,
|
|
26
|
+
useState,
|
|
27
|
+
} from "react";
|
|
28
|
+
import { Image } from "react-native";
|
|
29
|
+
import type { ViewProps } from "react-native";
|
|
30
|
+
import {
|
|
31
|
+
type GhImage,
|
|
32
|
+
collectImages,
|
|
33
|
+
isImageRun,
|
|
34
|
+
textOf,
|
|
35
|
+
} from "../markdown-github/flatten.ts";
|
|
36
|
+
import {
|
|
37
|
+
type GhBlockNode,
|
|
38
|
+
type GhElement,
|
|
39
|
+
type GhListItem,
|
|
40
|
+
type GhTableCell,
|
|
41
|
+
isInlineNode,
|
|
42
|
+
pairGithubHtml,
|
|
43
|
+
} from "../markdown-github/pair-html.ts";
|
|
44
|
+
import { isSafeUrl } from "../markdown-github/safe-url.ts";
|
|
45
|
+
import { columnWeights } from "../markdown-github/table-columns.ts";
|
|
46
|
+
import { toggleTask } from "../markdown-github/toggle-task.ts";
|
|
47
|
+
import { emojify } from "../services/emojify.ts";
|
|
48
|
+
import { imageSourceOf, useGithubImages } from "../services/githubImages.tsx";
|
|
49
|
+
import { CopyableCodeBlock } from "./copyable-code-block.tsx";
|
|
50
|
+
import { SuggestedChange } from "./markdown-suggestion.tsx";
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* An agent answers in markdown, so a transcript that shows the source shows
|
|
54
|
+
* `**this**` and a table as a row of pipes — which is most of what an agent's
|
|
55
|
+
* summary is made of.
|
|
56
|
+
*
|
|
57
|
+
* `@tanstack/markdown/parser` is the parser and nothing else: its `./react`
|
|
58
|
+
* entry point renders dom elements, and the webapp is react-native-web where a
|
|
59
|
+
* raw `<p>` is the exception that had to be argued for (see the archive
|
|
60
|
+
* button's `title`). What comes back is a typed tree, and every node below is
|
|
61
|
+
* an alouette component, so this renders on native too.
|
|
62
|
+
*
|
|
63
|
+
* The parser is pre-1.0 (0.0.13). The blast radius is this file: nothing else
|
|
64
|
+
* imports it, and a tree it refuses is caught where it is parsed.
|
|
65
|
+
*/
|
|
66
|
+
|
|
67
|
+
const markdownSeparatorClass = "my-xs border-border-muted";
|
|
68
|
+
|
|
69
|
+
const headingClass = (depth: number): string => {
|
|
70
|
+
if (depth === 1) return "font-heading-extrabold text-2xl";
|
|
71
|
+
if (depth === 2) return "font-heading-bold text-xl";
|
|
72
|
+
if (depth === 3) return "font-heading-bold text-lg";
|
|
73
|
+
return "font-body-bold text-sm";
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
/** A link needs its label as one string, so the run is flattened for it. */
|
|
77
|
+
const inlineText = (nodes: InlineNode[]): string =>
|
|
78
|
+
nodes
|
|
79
|
+
.map((node) => {
|
|
80
|
+
if (node.type === "text" || node.type === "inlineCode") return node.value;
|
|
81
|
+
if ("children" in node) return inlineText(node.children);
|
|
82
|
+
return "";
|
|
83
|
+
})
|
|
84
|
+
.join("");
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Inline nodes are rendered as children of one `Text`, which is what lets bold
|
|
88
|
+
* and code sit inside a sentence rather than breaking it into boxes: nested
|
|
89
|
+
* `Text` is how react-native does a run of styled text.
|
|
90
|
+
*/
|
|
91
|
+
function InlineNodes({ nodes }: { nodes: InlineNode[] }): ReactNode {
|
|
92
|
+
return nodes.map((node, index) => {
|
|
93
|
+
const key = `${node.type}-${String(index)}`;
|
|
94
|
+
|
|
95
|
+
if (node.type === "text") {
|
|
96
|
+
return <Text key={key}>{node.value}</Text>;
|
|
97
|
+
}
|
|
98
|
+
if (node.type === "inlineCode") {
|
|
99
|
+
return (
|
|
100
|
+
<Code key={key} accent="info" className="text-accent">
|
|
101
|
+
{node.value}
|
|
102
|
+
</Code>
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
if (node.type === "strong") {
|
|
106
|
+
return (
|
|
107
|
+
<Text key={key} className="font-body-bold">
|
|
108
|
+
<InlineNodes nodes={node.children} />
|
|
109
|
+
</Text>
|
|
110
|
+
);
|
|
111
|
+
}
|
|
112
|
+
if (node.type === "emphasis") {
|
|
113
|
+
return (
|
|
114
|
+
<Text key={key} className="italic">
|
|
115
|
+
<InlineNodes nodes={node.children} />
|
|
116
|
+
</Text>
|
|
117
|
+
);
|
|
118
|
+
}
|
|
119
|
+
if (node.type === "strike") {
|
|
120
|
+
return (
|
|
121
|
+
<Text key={key} className="line-through">
|
|
122
|
+
<InlineNodes nodes={node.children} />
|
|
123
|
+
</Text>
|
|
124
|
+
);
|
|
125
|
+
}
|
|
126
|
+
if (node.type === "link") {
|
|
127
|
+
if (!isSafeUrl(node.href)) {
|
|
128
|
+
return <Text key={key}>{inlineText(node.children)}</Text>;
|
|
129
|
+
}
|
|
130
|
+
return (
|
|
131
|
+
<ExternalLinkText
|
|
132
|
+
key={key}
|
|
133
|
+
accent="info"
|
|
134
|
+
icon={null}
|
|
135
|
+
size="sm"
|
|
136
|
+
href={node.href}
|
|
137
|
+
text={inlineText(node.children)}
|
|
138
|
+
/>
|
|
139
|
+
);
|
|
140
|
+
}
|
|
141
|
+
if (node.type === "image") {
|
|
142
|
+
return <Text key={key}>{`[${node.alt}]`}</Text>;
|
|
143
|
+
}
|
|
144
|
+
if (node.type === "break") return <Text key={key}>{"\n"}</Text>;
|
|
145
|
+
// Raw html is shown as its source rather than dropped: an agent writing a
|
|
146
|
+
// tag meant something by it, and this renders no dom to give it to.
|
|
147
|
+
if (node.type === "inlineHtml") {
|
|
148
|
+
return <Text key={key}>{node.value}</Text>;
|
|
149
|
+
}
|
|
150
|
+
return null;
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* A table is a grid of `Text`, not a dom `<table>`: the same reason the rest of
|
|
156
|
+
* this file is alouette. Every cell wraps and each column grows by the weight of
|
|
157
|
+
* its content (`columnWeights`). On web a flex item will not shrink below its
|
|
158
|
+
* min-content width, so without `min-w-0` and `wrap-anywhere` one unbreakable
|
|
159
|
+
* identifier widens its cell and shifts that row out of the grid.
|
|
160
|
+
*/
|
|
161
|
+
function MarkdownTable({ node }: { node: TableNode }): ReactNode {
|
|
162
|
+
const weights = columnWeights(
|
|
163
|
+
[node.header, ...node.rows].map((row) =>
|
|
164
|
+
row.map((cell) => inlineText(cell.children)),
|
|
165
|
+
),
|
|
166
|
+
);
|
|
167
|
+
return (
|
|
168
|
+
<View className="gap-0 rounded-sm border border-border-muted">
|
|
169
|
+
<View className="flex-row gap-xs border-border-muted border-b px-xs py-xxs">
|
|
170
|
+
{node.header.map((cell, index) => (
|
|
171
|
+
<Text
|
|
172
|
+
key={`header-${String(index)}`}
|
|
173
|
+
className="min-w-0 basis-0 text-xs font-body-bold web:wrap-anywhere"
|
|
174
|
+
style={{ flexGrow: weights[index] }}
|
|
175
|
+
>
|
|
176
|
+
<InlineNodes nodes={cell.children} />
|
|
177
|
+
</Text>
|
|
178
|
+
))}
|
|
179
|
+
</View>
|
|
180
|
+
{node.rows.map((row, rowIndex) => (
|
|
181
|
+
<View
|
|
182
|
+
key={`row-${String(rowIndex)}`}
|
|
183
|
+
className="flex-row gap-xs px-xs py-xxs"
|
|
184
|
+
>
|
|
185
|
+
{row.map((cell, index) => (
|
|
186
|
+
<Text
|
|
187
|
+
key={`cell-${String(index)}`}
|
|
188
|
+
className="min-w-0 basis-0 select-auto text-xs web:wrap-anywhere"
|
|
189
|
+
style={{ flexGrow: weights[index] }}
|
|
190
|
+
>
|
|
191
|
+
<InlineNodes nodes={cell.children} />
|
|
192
|
+
</Text>
|
|
193
|
+
))}
|
|
194
|
+
</View>
|
|
195
|
+
))}
|
|
196
|
+
</View>
|
|
197
|
+
);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
const blockquoteRole = "blockquote" as NonNullable<ViewProps["role"]>;
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* alouette's `Blockquote` wraps its children in a `Paragraph`, a `<p>` on web,
|
|
204
|
+
* and a markdown quote holds blocks — a list or a table there is a `<div>` in a
|
|
205
|
+
* `<p>`. Same frame, block children — until alouette#374 lands.
|
|
206
|
+
*/
|
|
207
|
+
function MarkdownQuote({ children }: { children: ReactNode }): ReactNode {
|
|
208
|
+
return (
|
|
209
|
+
<Box role={blockquoteRole} className="gap-xs border-l-4 border-accent pl-m">
|
|
210
|
+
{children}
|
|
211
|
+
</Box>
|
|
212
|
+
);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function BlockNodes({ nodes }: { nodes: BlockNode[] }): ReactNode {
|
|
216
|
+
return nodes.map((node, index) => {
|
|
217
|
+
const key = `${node.type}-${String(index)}`;
|
|
218
|
+
|
|
219
|
+
if (node.type === "paragraph") {
|
|
220
|
+
return (
|
|
221
|
+
<Text key={key} className="select-auto text-sm">
|
|
222
|
+
<InlineNodes nodes={node.children} />
|
|
223
|
+
</Text>
|
|
224
|
+
);
|
|
225
|
+
}
|
|
226
|
+
if (node.type === "heading") {
|
|
227
|
+
return (
|
|
228
|
+
<Text key={key} className={`select-auto ${headingClass(node.depth)}`}>
|
|
229
|
+
<InlineNodes nodes={node.children} />
|
|
230
|
+
</Text>
|
|
231
|
+
);
|
|
232
|
+
}
|
|
233
|
+
if (node.type === "code") {
|
|
234
|
+
return (
|
|
235
|
+
<CopyableCodeBlock
|
|
236
|
+
key={key}
|
|
237
|
+
size="sm"
|
|
238
|
+
title={node.title ?? node.lang}
|
|
239
|
+
text={node.value}
|
|
240
|
+
/>
|
|
241
|
+
);
|
|
242
|
+
}
|
|
243
|
+
if (node.type === "list") {
|
|
244
|
+
// Inline rather than its own component, because an item holds blocks and
|
|
245
|
+
// a list is therefore mutually recursive with this: one of the two would
|
|
246
|
+
// have to be used before it is defined.
|
|
247
|
+
return (
|
|
248
|
+
<View key={key} className="gap-xxs">
|
|
249
|
+
{node.items.map((item, itemIndex) => (
|
|
250
|
+
<View
|
|
251
|
+
key={`item-${String(itemIndex)}`}
|
|
252
|
+
className="flex-row items-baseline gap-xs pl-xs"
|
|
253
|
+
>
|
|
254
|
+
<Text className="text-sm text-muted">
|
|
255
|
+
{node.ordered
|
|
256
|
+
? `${String((node.start ?? 1) + itemIndex)}.`
|
|
257
|
+
: "•"}
|
|
258
|
+
</Text>
|
|
259
|
+
<View className="min-w-0 flex-1 gap-xxs">
|
|
260
|
+
<BlockNodes nodes={item.children} />
|
|
261
|
+
</View>
|
|
262
|
+
</View>
|
|
263
|
+
))}
|
|
264
|
+
</View>
|
|
265
|
+
);
|
|
266
|
+
}
|
|
267
|
+
if (node.type === "table") return <MarkdownTable key={key} node={node} />;
|
|
268
|
+
if (node.type === "blockquote" || node.type === "callout") {
|
|
269
|
+
return (
|
|
270
|
+
<MarkdownQuote key={key}>
|
|
271
|
+
<BlockNodes nodes={node.children} />
|
|
272
|
+
</MarkdownQuote>
|
|
273
|
+
);
|
|
274
|
+
}
|
|
275
|
+
if (node.type === "thematicBreak") {
|
|
276
|
+
return <Separator key={key} className={markdownSeparatorClass} />;
|
|
277
|
+
}
|
|
278
|
+
if (node.type === "html") {
|
|
279
|
+
return (
|
|
280
|
+
<Text key={key} className="select-auto font-mono text-xs text-muted">
|
|
281
|
+
{node.value}
|
|
282
|
+
</Text>
|
|
283
|
+
);
|
|
284
|
+
}
|
|
285
|
+
return null;
|
|
286
|
+
});
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/*
|
|
290
|
+
* GitHub rendering. A second set of components over the paired tree
|
|
291
|
+
* (`markdown-github/pair-html.ts`) rather than flags in the ones above, so the
|
|
292
|
+
* transcript's rendering cannot change with it.
|
|
293
|
+
*/
|
|
294
|
+
|
|
295
|
+
interface TaskToggleValue {
|
|
296
|
+
/** Null: the boxes are read-only. */
|
|
297
|
+
toggle: ((taskIndex: number) => void) | null;
|
|
298
|
+
busy: boolean;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
const TaskToggleContext = createContext<TaskToggleValue>({
|
|
302
|
+
toggle: null,
|
|
303
|
+
busy: false,
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
const externalLinkBehavior = {
|
|
307
|
+
native: "webBrowser",
|
|
308
|
+
web: "targetBlank",
|
|
309
|
+
} as const;
|
|
310
|
+
|
|
311
|
+
const maxImageWidth = 600;
|
|
312
|
+
|
|
313
|
+
/**
|
|
314
|
+
* An image has no size until it is loaded, and react-native-web renders it at
|
|
315
|
+
* 0x0 without one. The size is data read at runtime, so it goes through `style`,
|
|
316
|
+
* like a label's colour in `label-pill.tsx`.
|
|
317
|
+
*/
|
|
318
|
+
function GithubImage({ src, alt }: GhImage): ReactNode {
|
|
319
|
+
const safe = isSafeUrl(src, ["http:", "https:"]);
|
|
320
|
+
const proxies = useGithubImages();
|
|
321
|
+
const source = safe ? imageSourceOf(src, proxies) : null;
|
|
322
|
+
const [size, setSize] = useState<
|
|
323
|
+
"failed" | { width: number; height: number } | null
|
|
324
|
+
>(null);
|
|
325
|
+
|
|
326
|
+
useEffect(() => {
|
|
327
|
+
if (source === null) return undefined;
|
|
328
|
+
let cancelled = false;
|
|
329
|
+
Image.getSize(
|
|
330
|
+
source,
|
|
331
|
+
(width, height) => {
|
|
332
|
+
if (!cancelled) setSize({ width, height });
|
|
333
|
+
},
|
|
334
|
+
() => {
|
|
335
|
+
if (!cancelled) setSize("failed");
|
|
336
|
+
},
|
|
337
|
+
);
|
|
338
|
+
return () => {
|
|
339
|
+
cancelled = true;
|
|
340
|
+
};
|
|
341
|
+
}, [source]);
|
|
342
|
+
|
|
343
|
+
if (!safe) return <Text className="text-sm">{alt || src}</Text>;
|
|
344
|
+
// a host GitHub does not proxy would learn who reads the pull request
|
|
345
|
+
if (source === null || size === "failed") {
|
|
346
|
+
return (
|
|
347
|
+
<ExternalLinkText
|
|
348
|
+
accent="info"
|
|
349
|
+
size="sm"
|
|
350
|
+
href={src}
|
|
351
|
+
text={alt || src}
|
|
352
|
+
icon={null}
|
|
353
|
+
/>
|
|
354
|
+
);
|
|
355
|
+
}
|
|
356
|
+
if (size === null) {
|
|
357
|
+
return <Text className="text-xs text-muted">{alt}</Text>;
|
|
358
|
+
}
|
|
359
|
+
return (
|
|
360
|
+
<Image
|
|
361
|
+
source={{ uri: source }}
|
|
362
|
+
alt={alt}
|
|
363
|
+
resizeMode="contain"
|
|
364
|
+
style={{
|
|
365
|
+
width: Math.min(size.width, maxImageWidth),
|
|
366
|
+
maxWidth: "100%",
|
|
367
|
+
aspectRatio: size.width / Math.max(size.height, 1),
|
|
368
|
+
}}
|
|
369
|
+
/>
|
|
370
|
+
);
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
function GithubImageLink({
|
|
374
|
+
href,
|
|
375
|
+
images,
|
|
376
|
+
}: {
|
|
377
|
+
href: string;
|
|
378
|
+
images: GhImage[];
|
|
379
|
+
}): ReactNode {
|
|
380
|
+
return (
|
|
381
|
+
<ExternalLink
|
|
382
|
+
as={PressableBox}
|
|
383
|
+
variant="soft"
|
|
384
|
+
href={href}
|
|
385
|
+
openLinkBehavior={externalLinkBehavior}
|
|
386
|
+
className="rounded-sm"
|
|
387
|
+
>
|
|
388
|
+
<View className="flex-row flex-wrap items-center gap-xs">
|
|
389
|
+
{images.map((image, index) => (
|
|
390
|
+
<GithubImage key={`${image.src}-${String(index)}`} {...image} />
|
|
391
|
+
))}
|
|
392
|
+
</View>
|
|
393
|
+
</ExternalLink>
|
|
394
|
+
);
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
const droppedTags = new Set([
|
|
398
|
+
"script",
|
|
399
|
+
"style",
|
|
400
|
+
"iframe",
|
|
401
|
+
"object",
|
|
402
|
+
"embed",
|
|
403
|
+
"textarea",
|
|
404
|
+
"title",
|
|
405
|
+
"head",
|
|
406
|
+
]);
|
|
407
|
+
|
|
408
|
+
/** The class of a tag that only styles its text, or none. */
|
|
409
|
+
const textTagClass = (tag: string): string | undefined => {
|
|
410
|
+
if (tag === "b" || tag === "strong") return "font-body-bold";
|
|
411
|
+
if (tag === "i" || tag === "em" || tag === "cite" || tag === "var") {
|
|
412
|
+
return "italic";
|
|
413
|
+
}
|
|
414
|
+
if (tag === "s" || tag === "del" || tag === "strike") return "line-through";
|
|
415
|
+
if (tag === "ins" || tag === "u") return "underline";
|
|
416
|
+
// No baseline shift outside the web: the size alone sets them apart there.
|
|
417
|
+
if (tag === "sub") return "text-xs web:align-sub";
|
|
418
|
+
if (tag === "sup") return "text-xs web:align-super";
|
|
419
|
+
if (tag === "small") return "text-xs";
|
|
420
|
+
return undefined;
|
|
421
|
+
};
|
|
422
|
+
|
|
423
|
+
function GithubInlines({ nodes }: { nodes: GhBlockNode[] }): ReactNode {
|
|
424
|
+
const renderElement = (node: GhElement, key: string): ReactNode => {
|
|
425
|
+
const { tag, attrs, children } = node;
|
|
426
|
+
|
|
427
|
+
if (droppedTags.has(tag)) return null;
|
|
428
|
+
if (tag === "br") return <Text key={key}>{"\n"}</Text>;
|
|
429
|
+
if (tag === "img") {
|
|
430
|
+
return (
|
|
431
|
+
<GithubImage key={key} src={attrs.src ?? ""} alt={attrs.alt ?? ""} />
|
|
432
|
+
);
|
|
433
|
+
}
|
|
434
|
+
if (tag === "picture") {
|
|
435
|
+
return collectImages(children)
|
|
436
|
+
.slice(0, 1)
|
|
437
|
+
.map((image) => <GithubImage key={image.src} {...image} />);
|
|
438
|
+
}
|
|
439
|
+
if (tag === "code") {
|
|
440
|
+
return (
|
|
441
|
+
<Code key={key} accent="info" className="text-accent">
|
|
442
|
+
{textOf(children)}
|
|
443
|
+
</Code>
|
|
444
|
+
);
|
|
445
|
+
}
|
|
446
|
+
if (tag === "kbd") return <Code key={key}>{textOf(children)}</Code>;
|
|
447
|
+
if (tag === "a") {
|
|
448
|
+
const href = attrs.href;
|
|
449
|
+
if (href === undefined || !isSafeUrl(href)) {
|
|
450
|
+
return <GithubInlines key={key} nodes={children} />;
|
|
451
|
+
}
|
|
452
|
+
const images = collectImages(children);
|
|
453
|
+
if (images.length > 0) {
|
|
454
|
+
return <GithubImageLink key={key} href={href} images={images} />;
|
|
455
|
+
}
|
|
456
|
+
return (
|
|
457
|
+
<ExternalLinkText
|
|
458
|
+
key={key}
|
|
459
|
+
accent="info"
|
|
460
|
+
icon={null}
|
|
461
|
+
size="sm"
|
|
462
|
+
href={href}
|
|
463
|
+
text={emojify(textOf(children))}
|
|
464
|
+
/>
|
|
465
|
+
);
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
const className = textTagClass(tag);
|
|
469
|
+
if (className === undefined) {
|
|
470
|
+
return <GithubInlines key={key} nodes={children} />;
|
|
471
|
+
}
|
|
472
|
+
return (
|
|
473
|
+
<Text key={key} className={className}>
|
|
474
|
+
<GithubInlines nodes={children} />
|
|
475
|
+
</Text>
|
|
476
|
+
);
|
|
477
|
+
};
|
|
478
|
+
|
|
479
|
+
return nodes.map((node, index) => {
|
|
480
|
+
const key = `${node.type}-${String(index)}`;
|
|
481
|
+
|
|
482
|
+
switch (node.type) {
|
|
483
|
+
case "text":
|
|
484
|
+
return <Text key={key}>{emojify(node.value)}</Text>;
|
|
485
|
+
case "inlineCode":
|
|
486
|
+
return (
|
|
487
|
+
<Code key={key} accent="info" className="text-accent">
|
|
488
|
+
{node.value}
|
|
489
|
+
</Code>
|
|
490
|
+
);
|
|
491
|
+
case "strong":
|
|
492
|
+
return (
|
|
493
|
+
<Text key={key} className="font-body-bold">
|
|
494
|
+
<GithubInlines nodes={node.children} />
|
|
495
|
+
</Text>
|
|
496
|
+
);
|
|
497
|
+
case "emphasis":
|
|
498
|
+
return (
|
|
499
|
+
<Text key={key} className="italic">
|
|
500
|
+
<GithubInlines nodes={node.children} />
|
|
501
|
+
</Text>
|
|
502
|
+
);
|
|
503
|
+
case "strike":
|
|
504
|
+
return (
|
|
505
|
+
<Text key={key} className="line-through">
|
|
506
|
+
<GithubInlines nodes={node.children} />
|
|
507
|
+
</Text>
|
|
508
|
+
);
|
|
509
|
+
case "link": {
|
|
510
|
+
if (!isSafeUrl(node.href)) {
|
|
511
|
+
return <GithubInlines key={key} nodes={node.children} />;
|
|
512
|
+
}
|
|
513
|
+
const images = collectImages(node.children);
|
|
514
|
+
if (images.length > 0) {
|
|
515
|
+
return <GithubImageLink key={key} href={node.href} images={images} />;
|
|
516
|
+
}
|
|
517
|
+
return (
|
|
518
|
+
<ExternalLinkText
|
|
519
|
+
key={key}
|
|
520
|
+
accent="info"
|
|
521
|
+
icon={null}
|
|
522
|
+
size="sm"
|
|
523
|
+
href={node.href}
|
|
524
|
+
text={emojify(textOf(node.children))}
|
|
525
|
+
/>
|
|
526
|
+
);
|
|
527
|
+
}
|
|
528
|
+
case "image":
|
|
529
|
+
return <GithubImage key={key} src={node.src} alt={node.alt} />;
|
|
530
|
+
case "break":
|
|
531
|
+
return <Text key={key}>{"\n"}</Text>;
|
|
532
|
+
case "element":
|
|
533
|
+
return renderElement(node, key);
|
|
534
|
+
case "footnoteReference":
|
|
535
|
+
case "paragraph":
|
|
536
|
+
case "heading":
|
|
537
|
+
case "code":
|
|
538
|
+
case "list":
|
|
539
|
+
case "table":
|
|
540
|
+
case "blockquote":
|
|
541
|
+
case "callout":
|
|
542
|
+
case "thematicBreak":
|
|
543
|
+
default:
|
|
544
|
+
return null;
|
|
545
|
+
}
|
|
546
|
+
});
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
function GithubTable({
|
|
550
|
+
header,
|
|
551
|
+
rows,
|
|
552
|
+
}: {
|
|
553
|
+
header: GhTableCell[];
|
|
554
|
+
rows: GhTableCell[][];
|
|
555
|
+
}): ReactNode {
|
|
556
|
+
const weights = columnWeights(
|
|
557
|
+
[header, ...rows].map((row) => row.map((cell) => textOf(cell.children))),
|
|
558
|
+
);
|
|
559
|
+
return (
|
|
560
|
+
<View className="gap-0 rounded-sm border border-border-muted">
|
|
561
|
+
<View className="flex-row gap-xs border-border-muted border-b px-xs py-xxs">
|
|
562
|
+
{header.map((cell, index) => (
|
|
563
|
+
<Text
|
|
564
|
+
key={`header-${String(index)}`}
|
|
565
|
+
className="min-w-0 basis-0 text-xs font-body-bold web:wrap-anywhere"
|
|
566
|
+
style={{ flexGrow: weights[index] }}
|
|
567
|
+
>
|
|
568
|
+
<GithubInlines nodes={cell.children} />
|
|
569
|
+
</Text>
|
|
570
|
+
))}
|
|
571
|
+
</View>
|
|
572
|
+
{rows.map((row, rowIndex) => (
|
|
573
|
+
<View
|
|
574
|
+
key={`row-${String(rowIndex)}`}
|
|
575
|
+
className="flex-row gap-xs px-xs py-xxs"
|
|
576
|
+
>
|
|
577
|
+
{row.map((cell, index) => (
|
|
578
|
+
<Text
|
|
579
|
+
key={`cell-${String(index)}`}
|
|
580
|
+
className="min-w-0 basis-0 select-auto text-xs web:wrap-anywhere"
|
|
581
|
+
style={{ flexGrow: weights[index] }}
|
|
582
|
+
>
|
|
583
|
+
<GithubInlines nodes={cell.children} />
|
|
584
|
+
</Text>
|
|
585
|
+
))}
|
|
586
|
+
</View>
|
|
587
|
+
))}
|
|
588
|
+
</View>
|
|
589
|
+
);
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
/** Its content arrives rendered: this is defined before the component that renders blocks. */
|
|
593
|
+
function GithubDetails({
|
|
594
|
+
initiallyOpen,
|
|
595
|
+
summary,
|
|
596
|
+
children,
|
|
597
|
+
}: {
|
|
598
|
+
initiallyOpen: boolean;
|
|
599
|
+
summary: ReactNode;
|
|
600
|
+
children: ReactNode;
|
|
601
|
+
}): ReactNode {
|
|
602
|
+
const [open, setOpen] = useState(initiallyOpen);
|
|
603
|
+
|
|
604
|
+
return (
|
|
605
|
+
<View className="gap-xs">
|
|
606
|
+
<PressableBox
|
|
607
|
+
variant="soft"
|
|
608
|
+
role="button"
|
|
609
|
+
aria-expanded={open}
|
|
610
|
+
className="flex-row items-center gap-xs self-start rounded-sm px-xs py-xxs"
|
|
611
|
+
onPress={() => {
|
|
612
|
+
setOpen((value) => !value);
|
|
613
|
+
}}
|
|
614
|
+
>
|
|
615
|
+
<Icon
|
|
616
|
+
icon={open ? <CaretDownRegularIcon /> : <CaretRightRegularIcon />}
|
|
617
|
+
size={14}
|
|
618
|
+
className="text-muted"
|
|
619
|
+
/>
|
|
620
|
+
{summary}
|
|
621
|
+
</PressableBox>
|
|
622
|
+
{open ? (
|
|
623
|
+
<View className="gap-xs border-border-muted border-l pl-m">
|
|
624
|
+
{children}
|
|
625
|
+
</View>
|
|
626
|
+
) : null}
|
|
627
|
+
</View>
|
|
628
|
+
);
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
/**
|
|
632
|
+
* alouette's `Checkbox` owns a plain-text label, and a task's text is rich (a
|
|
633
|
+
* link, emphasis, code), so the box is drawn here beside the rendered content.
|
|
634
|
+
* Its name is the flattened text.
|
|
635
|
+
*/
|
|
636
|
+
function GithubTask({
|
|
637
|
+
item,
|
|
638
|
+
label,
|
|
639
|
+
}: {
|
|
640
|
+
item: GhListItem;
|
|
641
|
+
label: string;
|
|
642
|
+
}): ReactNode {
|
|
643
|
+
const { toggle, busy } = use(TaskToggleContext);
|
|
644
|
+
const { taskIndex } = item;
|
|
645
|
+
const disabled = toggle === null || busy || taskIndex === undefined;
|
|
646
|
+
const checked = item.checked === true;
|
|
647
|
+
|
|
648
|
+
// Set in a line of text so the row can align it on the content's baseline: a
|
|
649
|
+
// box pinned to the top sat above the text.
|
|
650
|
+
return (
|
|
651
|
+
<Text className="text-sm">
|
|
652
|
+
<InteractiveBox
|
|
653
|
+
withFocusVisibleOutline
|
|
654
|
+
withPressEffect={false}
|
|
655
|
+
role="checkbox"
|
|
656
|
+
aria-checked={checked}
|
|
657
|
+
aria-disabled={disabled}
|
|
658
|
+
aria-label={label}
|
|
659
|
+
disabled={disabled}
|
|
660
|
+
className="size-[18px] items-center justify-center rounded-[5px] border-2 border-interactive-pressable web:align-middle"
|
|
661
|
+
onPress={() => {
|
|
662
|
+
if (taskIndex !== undefined) toggle?.(taskIndex);
|
|
663
|
+
}}
|
|
664
|
+
>
|
|
665
|
+
{checked ? (
|
|
666
|
+
<Icon
|
|
667
|
+
icon={<CheckFatFillIcon />}
|
|
668
|
+
size={12}
|
|
669
|
+
className="text-interactive-pressable"
|
|
670
|
+
/>
|
|
671
|
+
) : null}
|
|
672
|
+
</InteractiveBox>
|
|
673
|
+
</Text>
|
|
674
|
+
);
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
const elementItems = (node: GhElement): GhListItem[] =>
|
|
678
|
+
node.children.flatMap((child): GhListItem[] =>
|
|
679
|
+
child.type === "element" && child.tag === "li"
|
|
680
|
+
? [{ type: "listItem", children: child.children }]
|
|
681
|
+
: [],
|
|
682
|
+
);
|
|
683
|
+
|
|
684
|
+
const headingDepth = (tag: string): 1 | 2 | 3 | 4 | 5 | 6 | undefined => {
|
|
685
|
+
switch (tag) {
|
|
686
|
+
case "h1":
|
|
687
|
+
return 1;
|
|
688
|
+
case "h2":
|
|
689
|
+
return 2;
|
|
690
|
+
case "h3":
|
|
691
|
+
return 3;
|
|
692
|
+
case "h4":
|
|
693
|
+
return 4;
|
|
694
|
+
case "h5":
|
|
695
|
+
return 5;
|
|
696
|
+
case "h6":
|
|
697
|
+
return 6;
|
|
698
|
+
default:
|
|
699
|
+
return undefined;
|
|
700
|
+
}
|
|
701
|
+
};
|
|
702
|
+
|
|
703
|
+
/** Null for a plain item; a task's accessible name is its flattened text. */
|
|
704
|
+
const taskLabel = (item: GhListItem): string | null => {
|
|
705
|
+
if (item.checked === undefined) return null;
|
|
706
|
+
return emojify(textOf(item.children)).trim() || "Task item";
|
|
707
|
+
};
|
|
708
|
+
|
|
709
|
+
/**
|
|
710
|
+
* A run of inline nodes is one `Text`, so a sentence with a `<sub>` in it stays
|
|
711
|
+
* a sentence; a run of nothing but pictures is a row of them, which is what a
|
|
712
|
+
* line of badges is.
|
|
713
|
+
*/
|
|
714
|
+
function GithubRun({ nodes }: { nodes: GhBlockNode[] }): ReactNode {
|
|
715
|
+
if (isImageRun(nodes)) {
|
|
716
|
+
return (
|
|
717
|
+
<View className="flex-row flex-wrap items-center gap-xs">
|
|
718
|
+
<GithubInlines
|
|
719
|
+
nodes={nodes.filter(
|
|
720
|
+
(node) => node.type !== "text" && node.type !== "break",
|
|
721
|
+
)}
|
|
722
|
+
/>
|
|
723
|
+
</View>
|
|
724
|
+
);
|
|
725
|
+
}
|
|
726
|
+
return (
|
|
727
|
+
<Text className="select-auto text-sm">
|
|
728
|
+
<GithubInlines nodes={nodes} />
|
|
729
|
+
</Text>
|
|
730
|
+
);
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
function GithubCode({
|
|
734
|
+
node,
|
|
735
|
+
}: {
|
|
736
|
+
node: Extract<GhBlockNode, { type: "code" }>;
|
|
737
|
+
}): ReactNode {
|
|
738
|
+
if (node.lang === "suggestion") return <SuggestedChange value={node.value} />;
|
|
739
|
+
return (
|
|
740
|
+
<CopyableCodeBlock
|
|
741
|
+
size="sm"
|
|
742
|
+
title={node.title ?? node.lang}
|
|
743
|
+
text={node.value}
|
|
744
|
+
/>
|
|
745
|
+
);
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
function GithubBlocks({ nodes }: { nodes: GhBlockNode[] }): ReactNode {
|
|
749
|
+
const rendered: ReactNode[] = [];
|
|
750
|
+
let run: GhBlockNode[] = [];
|
|
751
|
+
|
|
752
|
+
const flush = (): void => {
|
|
753
|
+
if (run.length === 0) return;
|
|
754
|
+
const blank = textOf(run).trim() === "" && collectImages(run).length === 0;
|
|
755
|
+
if (!blank) {
|
|
756
|
+
rendered.push(
|
|
757
|
+
<GithubRun key={`run-${String(rendered.length)}`} nodes={run} />,
|
|
758
|
+
);
|
|
759
|
+
}
|
|
760
|
+
run = [];
|
|
761
|
+
};
|
|
762
|
+
|
|
763
|
+
const renderList = ({
|
|
764
|
+
key,
|
|
765
|
+
ordered,
|
|
766
|
+
start,
|
|
767
|
+
items,
|
|
768
|
+
}: {
|
|
769
|
+
key: string;
|
|
770
|
+
ordered: boolean;
|
|
771
|
+
start: number;
|
|
772
|
+
items: GhListItem[];
|
|
773
|
+
}): ReactNode => (
|
|
774
|
+
<View key={key} className="gap-xxs">
|
|
775
|
+
{items.map((item, itemIndex) => {
|
|
776
|
+
const label = taskLabel(item);
|
|
777
|
+
|
|
778
|
+
return (
|
|
779
|
+
<View
|
|
780
|
+
key={`item-${String(itemIndex)}`}
|
|
781
|
+
className="flex-row items-baseline gap-xs pl-xs"
|
|
782
|
+
>
|
|
783
|
+
{label === null ? (
|
|
784
|
+
<Text className="text-sm text-muted">
|
|
785
|
+
{ordered ? `${String(start + itemIndex)}.` : "•"}
|
|
786
|
+
</Text>
|
|
787
|
+
) : (
|
|
788
|
+
<GithubTask item={item} label={label} />
|
|
789
|
+
)}
|
|
790
|
+
<View className="min-w-0 flex-1 gap-xxs">
|
|
791
|
+
<GithubBlocks nodes={item.children} />
|
|
792
|
+
</View>
|
|
793
|
+
</View>
|
|
794
|
+
);
|
|
795
|
+
})}
|
|
796
|
+
</View>
|
|
797
|
+
);
|
|
798
|
+
|
|
799
|
+
const renderElement = (node: GhElement, key: string): ReactNode => {
|
|
800
|
+
const { tag, attrs, children } = node;
|
|
801
|
+
const depth = headingDepth(tag);
|
|
802
|
+
|
|
803
|
+
if (droppedTags.has(tag)) return null;
|
|
804
|
+
if (tag === "details") {
|
|
805
|
+
const summary = children.find(
|
|
806
|
+
(child): child is GhElement =>
|
|
807
|
+
child.type === "element" && child.tag === "summary",
|
|
808
|
+
);
|
|
809
|
+
return (
|
|
810
|
+
<GithubDetails
|
|
811
|
+
key={key}
|
|
812
|
+
initiallyOpen={"open" in attrs}
|
|
813
|
+
summary={
|
|
814
|
+
<Text className="select-auto font-body-bold text-sm">
|
|
815
|
+
{summary ? <GithubInlines nodes={summary.children} /> : "Details"}
|
|
816
|
+
</Text>
|
|
817
|
+
}
|
|
818
|
+
>
|
|
819
|
+
<GithubBlocks nodes={children.filter((child) => child !== summary)} />
|
|
820
|
+
</GithubDetails>
|
|
821
|
+
);
|
|
822
|
+
}
|
|
823
|
+
if (depth !== undefined) {
|
|
824
|
+
return (
|
|
825
|
+
<Text key={key} className={`select-auto ${headingClass(depth)}`}>
|
|
826
|
+
<GithubInlines nodes={children} />
|
|
827
|
+
</Text>
|
|
828
|
+
);
|
|
829
|
+
}
|
|
830
|
+
if (tag === "hr") {
|
|
831
|
+
return <Separator key={key} className={markdownSeparatorClass} />;
|
|
832
|
+
}
|
|
833
|
+
if (tag === "ul" || tag === "ol") {
|
|
834
|
+
const start = Number.parseInt(attrs.start ?? "1", 10);
|
|
835
|
+
return renderList({
|
|
836
|
+
key,
|
|
837
|
+
ordered: tag === "ol",
|
|
838
|
+
start: Number.isNaN(start) ? 1 : start,
|
|
839
|
+
items: elementItems(node),
|
|
840
|
+
});
|
|
841
|
+
}
|
|
842
|
+
if (tag === "pre") {
|
|
843
|
+
return (
|
|
844
|
+
<CopyableCodeBlock
|
|
845
|
+
key={key}
|
|
846
|
+
size="sm"
|
|
847
|
+
text={textOf(children).replace(/^\n+|\n+$/g, "")}
|
|
848
|
+
/>
|
|
849
|
+
);
|
|
850
|
+
}
|
|
851
|
+
if (tag === "blockquote") {
|
|
852
|
+
return (
|
|
853
|
+
<MarkdownQuote key={key}>
|
|
854
|
+
<GithubBlocks nodes={children} />
|
|
855
|
+
</MarkdownQuote>
|
|
856
|
+
);
|
|
857
|
+
}
|
|
858
|
+
if (tag === "tr") {
|
|
859
|
+
return (
|
|
860
|
+
<View key={key} className="flex-row gap-xs">
|
|
861
|
+
<GithubBlocks nodes={children} />
|
|
862
|
+
</View>
|
|
863
|
+
);
|
|
864
|
+
}
|
|
865
|
+
return (
|
|
866
|
+
<View
|
|
867
|
+
key={key}
|
|
868
|
+
className={
|
|
869
|
+
tag === "td" || tag === "th" ? "min-w-0 flex-1 gap-xs" : "gap-xs"
|
|
870
|
+
}
|
|
871
|
+
>
|
|
872
|
+
<GithubBlocks nodes={children} />
|
|
873
|
+
</View>
|
|
874
|
+
);
|
|
875
|
+
};
|
|
876
|
+
|
|
877
|
+
nodes.forEach((node, index) => {
|
|
878
|
+
const key = `${node.type}-${String(index)}`;
|
|
879
|
+
|
|
880
|
+
if (isInlineNode(node)) {
|
|
881
|
+
run.push(node);
|
|
882
|
+
return;
|
|
883
|
+
}
|
|
884
|
+
flush();
|
|
885
|
+
|
|
886
|
+
switch (node.type) {
|
|
887
|
+
case "paragraph":
|
|
888
|
+
rendered.push(<GithubRun key={key} nodes={node.children} />);
|
|
889
|
+
break;
|
|
890
|
+
case "heading":
|
|
891
|
+
rendered.push(
|
|
892
|
+
<Text key={key} className={`select-auto ${headingClass(node.depth)}`}>
|
|
893
|
+
<GithubInlines nodes={node.children} />
|
|
894
|
+
</Text>,
|
|
895
|
+
);
|
|
896
|
+
break;
|
|
897
|
+
case "code":
|
|
898
|
+
rendered.push(<GithubCode key={key} node={node} />);
|
|
899
|
+
break;
|
|
900
|
+
case "list":
|
|
901
|
+
rendered.push(
|
|
902
|
+
renderList({
|
|
903
|
+
key,
|
|
904
|
+
ordered: node.ordered,
|
|
905
|
+
start: node.start ?? 1,
|
|
906
|
+
items: node.items,
|
|
907
|
+
}),
|
|
908
|
+
);
|
|
909
|
+
break;
|
|
910
|
+
case "table":
|
|
911
|
+
rendered.push(
|
|
912
|
+
<GithubTable key={key} header={node.header} rows={node.rows} />,
|
|
913
|
+
);
|
|
914
|
+
break;
|
|
915
|
+
case "blockquote":
|
|
916
|
+
case "callout":
|
|
917
|
+
rendered.push(
|
|
918
|
+
<MarkdownQuote key={key}>
|
|
919
|
+
<GithubBlocks nodes={node.children} />
|
|
920
|
+
</MarkdownQuote>,
|
|
921
|
+
);
|
|
922
|
+
break;
|
|
923
|
+
case "thematicBreak":
|
|
924
|
+
rendered.push(
|
|
925
|
+
<Separator key={key} className={markdownSeparatorClass} />,
|
|
926
|
+
);
|
|
927
|
+
break;
|
|
928
|
+
case "element":
|
|
929
|
+
rendered.push(renderElement(node, key));
|
|
930
|
+
break;
|
|
931
|
+
case "text":
|
|
932
|
+
case "inlineCode":
|
|
933
|
+
case "strong":
|
|
934
|
+
case "emphasis":
|
|
935
|
+
case "strike":
|
|
936
|
+
case "footnoteReference":
|
|
937
|
+
case "link":
|
|
938
|
+
case "image":
|
|
939
|
+
case "break":
|
|
940
|
+
default:
|
|
941
|
+
break;
|
|
942
|
+
}
|
|
943
|
+
});
|
|
944
|
+
flush();
|
|
945
|
+
|
|
946
|
+
return rendered;
|
|
947
|
+
}
|
|
948
|
+
|
|
949
|
+
const errorText = (error: unknown): string =>
|
|
950
|
+
error instanceof Error ? error.message : String(error);
|
|
951
|
+
|
|
952
|
+
function GithubMarkdown({
|
|
953
|
+
text,
|
|
954
|
+
onTaskToggle,
|
|
955
|
+
}: Pick<MarkdownTextProps, "onTaskToggle" | "text">): ReactNode {
|
|
956
|
+
const [busy, setBusy] = useState(false);
|
|
957
|
+
const [error, setError] = useState<string | null>(null);
|
|
958
|
+
|
|
959
|
+
const paired = useMemo(() => {
|
|
960
|
+
try {
|
|
961
|
+
return pairGithubHtml(parseMarkdown(text, { allowHtml: true }).children);
|
|
962
|
+
} catch {
|
|
963
|
+
return null;
|
|
964
|
+
}
|
|
965
|
+
}, [text]);
|
|
966
|
+
|
|
967
|
+
if (paired === null) {
|
|
968
|
+
return <Text className="select-auto text-sm">{text}</Text>;
|
|
969
|
+
}
|
|
970
|
+
|
|
971
|
+
// Boxes are pressable only when every marker in the source can be matched to
|
|
972
|
+
// a task item of the tree; `toggleTask` refuses otherwise.
|
|
973
|
+
const editable =
|
|
974
|
+
onTaskToggle !== undefined &&
|
|
975
|
+
paired.taskCount > 0 &&
|
|
976
|
+
toggleTask(text, 0, paired.tasks) !== null;
|
|
977
|
+
|
|
978
|
+
const toggle = (taskIndex: number): void => {
|
|
979
|
+
const next = toggleTask(text, taskIndex, paired.tasks);
|
|
980
|
+
if (next === null || onTaskToggle === undefined) return;
|
|
981
|
+
setBusy(true);
|
|
982
|
+
setError(null);
|
|
983
|
+
onTaskToggle(next).then(
|
|
984
|
+
() => {
|
|
985
|
+
setBusy(false);
|
|
986
|
+
},
|
|
987
|
+
(error_: unknown) => {
|
|
988
|
+
setError(errorText(error_));
|
|
989
|
+
setBusy(false);
|
|
990
|
+
},
|
|
991
|
+
);
|
|
992
|
+
};
|
|
993
|
+
|
|
994
|
+
return (
|
|
995
|
+
// oxlint-disable-next-line react/jsx-no-constructed-context-values -- built after the early return above, so no hook can hold it; its consumers are one message's task boxes
|
|
996
|
+
<TaskToggleContext value={{ toggle: editable ? toggle : null, busy }}>
|
|
997
|
+
<Box className="gap-xs">
|
|
998
|
+
<GithubBlocks nodes={paired.children} />
|
|
999
|
+
{error === null ? null : (
|
|
1000
|
+
<ErrorMessage size="sm" variant="flat">
|
|
1001
|
+
{error}
|
|
1002
|
+
</ErrorMessage>
|
|
1003
|
+
)}
|
|
1004
|
+
</Box>
|
|
1005
|
+
</TaskToggleContext>
|
|
1006
|
+
);
|
|
1007
|
+
}
|
|
1008
|
+
|
|
1009
|
+
/**
|
|
1010
|
+
* Markdown rendered with alouette components, falling back to the source text.
|
|
1011
|
+
*
|
|
1012
|
+
* The fallback is the point: this is a *display* of something a model wrote,
|
|
1013
|
+
* and the format has no schema either. A parse that throws must cost the
|
|
1014
|
+
* formatting, never the message — the operator opened the screen to read what
|
|
1015
|
+
* the agent said.
|
|
1016
|
+
*/
|
|
1017
|
+
export interface MarkdownTextProps {
|
|
1018
|
+
text: string;
|
|
1019
|
+
/**
|
|
1020
|
+
* GitHub-flavoured rendering for a pull request's description and comments:
|
|
1021
|
+
* the HTML GitHub allows (`<details>`, `<sub>`, `<br>`…), `:shortcode:` emoji.
|
|
1022
|
+
* Off for an agent's transcript, which is not GitHub's markdown.
|
|
1023
|
+
*/
|
|
1024
|
+
github?: boolean;
|
|
1025
|
+
/**
|
|
1026
|
+
* Makes task-list checkboxes pressable: called with the whole source, the
|
|
1027
|
+
* pressed box flipped, for the caller to save. Absent, they are read-only.
|
|
1028
|
+
*/
|
|
1029
|
+
onTaskToggle?: (nextText: string) => Promise<void>;
|
|
1030
|
+
}
|
|
1031
|
+
|
|
1032
|
+
function PlainMarkdown({ text }: Pick<MarkdownTextProps, "text">): ReactNode {
|
|
1033
|
+
const document = useMemo(() => {
|
|
1034
|
+
try {
|
|
1035
|
+
return parseMarkdown(text);
|
|
1036
|
+
} catch {
|
|
1037
|
+
return null;
|
|
1038
|
+
}
|
|
1039
|
+
}, [text]);
|
|
1040
|
+
|
|
1041
|
+
if (document === null) {
|
|
1042
|
+
return <Text className="select-auto text-sm">{text}</Text>;
|
|
1043
|
+
}
|
|
1044
|
+
|
|
1045
|
+
return (
|
|
1046
|
+
<Box className="gap-xs">
|
|
1047
|
+
<BlockNodes nodes={document.children} />
|
|
1048
|
+
</Box>
|
|
1049
|
+
);
|
|
1050
|
+
}
|
|
1051
|
+
|
|
1052
|
+
/**
|
|
1053
|
+
* Memoized because a transcript renders one per message and grows with every
|
|
1054
|
+
* appended event: only the new message should be parsed and rendered.
|
|
1055
|
+
*/
|
|
1056
|
+
function MarkdownTextBase({
|
|
1057
|
+
text,
|
|
1058
|
+
github = false,
|
|
1059
|
+
onTaskToggle,
|
|
1060
|
+
}: MarkdownTextProps): ReactNode {
|
|
1061
|
+
if (github) {
|
|
1062
|
+
return <GithubMarkdown text={text} onTaskToggle={onTaskToggle} />;
|
|
1063
|
+
}
|
|
1064
|
+
return <PlainMarkdown text={text} />;
|
|
1065
|
+
}
|
|
1066
|
+
|
|
1067
|
+
export const MarkdownText = memo(MarkdownTextBase);
|