Skip to content
Source
Sign inSign up
Open History to see the last commit

markdownDetails.ts

100 lines3.7 KB

1import type { RootContent } from "mdast";
2import { unified } from "unified";
3import remarkParse from "remark-parse";
4​
5import { decodeHtmlEntities, markdownHtmlTags } from "./markdownDocument";
6​
7export type MarkdownDetailsSegment =
8 | { readonly kind: "markdown"; readonly markdown: string; readonly sourceOffset: number }
9 | {
10 readonly kind: "details";
11 readonly summary: string;
12 readonly body: string;
13 readonly open: boolean;
14 readonly sourceOffset: number;
15 };
16​
17type OpenFold = { start: number; openTagEnd: number; open: boolean };
18​
19const parser = unified().use(remarkParse);
20const DETAILS_TAG = /^<(\/?)details\b/i;
21const SUMMARY = /^\s*<summary\b[^>]*>([\s\S]*?)<\/summary\s*>/i;
22​
23/**
24 * Splits top-level `<details>` blocks out of chat Markdown so they can render as folds.
25 * Tags inside code, or a fold opened inline in a paragraph, stay Markdown. A fold whose
26 * closing tag has not streamed yet runs to the end.
27 */
28export function splitMarkdownDetails(markdown: string): ReadonlyArray<MarkdownDetailsSegment> {
29 if (!/<details\b/i.test(markdown)) return [{ kind: "markdown", markdown, sourceOffset: 0 }];
30​
31 const folds: Array<OpenFold & { end: number; closeTagLength: number }> = [];
32 let depth = 0;
33 // Asserted so the scan closure's writes are not narrowed away after the loop.
34 let current = null as OpenFold | null;
35 // A fold opens only at block level; once open, a tag inside a paragraph can close it.
36 const scan = (node: RootContent, inline: boolean) => {
37 if (node.type !== "html") {
38 if (depth > 0 && "children" in node) {
39 for (const child of node.children) scan(child, true);
40 }
41 return;
42 }
43 if (node.position?.start.offset === undefined) return;
44 const nodeStart = node.position.start.offset;
45 for (const tag of markdownHtmlTags(node.value)) {
46 const details = DETAILS_TAG.exec(tag[0]);
47 if (!details || (inline && depth === 0)) continue;
48 const start = nodeStart + tag.index;
49 if (details[1] !== "/") {
50 if (depth === 0) {
51 // Quoted values are blanked so `title="is open"` does not count as the attribute.
52 const attributes = tag[0].replace(/"[^"]*"|'[^']*'/g, '""');
53 current = {
54 start,
55 openTagEnd: start + tag[0].length,
56 open: /\sopen\b/i.test(attributes),
57 };
58 }
59 depth += 1;
60 } else if (depth > 0) {
61 depth -= 1;
62 if (depth === 0 && current) {
63 folds.push({ ...current, end: start + tag[0].length, closeTagLength: tag[0].length });
64 current = null;
65 }
66 }
67 }
68 };
69 for (const node of parser.parse(markdown).children) scan(node, false);
70 if (current) folds.push({ ...current, end: markdown.length, closeTagLength: 0 });
71 if (folds.length === 0) return [{ kind: "markdown", markdown, sourceOffset: 0 }];
72​
73 const segments: MarkdownDetailsSegment[] = [];
74 let cursor = 0;
75 for (const fold of folds) {
76 if (fold.start > cursor) {
77 segments.push({
78 kind: "markdown",
79 markdown: markdown.slice(cursor, fold.start),
80 sourceOffset: cursor,
81 });
82 }
83 let body = markdown.slice(fold.openTagEnd, fold.end - fold.closeTagLength);
84 const summary = SUMMARY.exec(body);
85 if (summary) body = body.slice(summary[0].length);
86 const label = summary ? decodeHtmlEntities(summary[1]!.replace(/<[^>]*>/g, "")).trim() : "";
87 segments.push({
88 kind: "details",
89 summary: label || "Details",
90 body,
91 open: fold.open,
92 sourceOffset: fold.start,
93 });
94 cursor = fold.end;
95 }
96 if (cursor < markdown.length) {
97 segments.push({ kind: "markdown", markdown: markdown.slice(cursor), sourceOffset: cursor });
98 }
99 return segments;