Skip to content
205 linesCodeBlameRaw
1/**
2 * The Markdown agents write, read into a few kinds of block for the files
3 * `make_file` writes (pdf.ts, ooxml.ts). Not a full CommonMark parser: the
4 * subset a document needs (headings, paragraphs, bold, italic, code, links,
5 * lists, quotes, code blocks, rules and tables), read forgivingly. Pure.
6 */
7
8export type Span = { text: string; bold?: boolean; italic?: boolean; code?: boolean; href?: string };
9
10export type Block =
11 | { kind: "heading"; level: 1 | 2 | 3; spans: Span[] }
12 | { kind: "paragraph"; spans: Span[] }
13 | { kind: "item"; ordered: boolean; number: number; depth: number; spans: Span[] }
14 | { kind: "quote"; spans: Span[] }
15 | { kind: "code"; text: string }
16 | { kind: "rule" }
17 | { kind: "table"; header: Span[][]; rows: Span[][][] };
18
19/** Inline Markdown as spans: **bold**, *italic*, `code`, [links](url); ~~strike~~ and HTML tags read as their text. */
20export function parseSpans(text: string): Span[] {
21 const spans: Span[] = [];
22 const push = (span: Span) => {
23 if (!span.text) return;
24 const last = spans[spans.length - 1];
25 if (last && !!last.bold === !!span.bold && !!last.italic === !!span.italic && !!last.code === !!span.code && last.href === span.href) last.text += span.text;
26 else spans.push(span);
27 };
28 const walk = (input: string, marks: { bold?: boolean; italic?: boolean; href?: string }) => {
29 let i = 0;
30 let plain = "";
31 const flush = () => {
32 if (plain) push({ text: plain, ...marks });
33 plain = "";
34 };
35 while (i < input.length) {
36 const rest = input.slice(i);
37 const ch = input[i]!;
38 if (ch === "\\" && i + 1 < input.length && /[\\`*_{}[\]()#+\-.!|~>]/.test(input[i + 1]!)) {
39 plain += input[i + 1];
40 i += 2;
41 continue;
42 }
43 if (ch === "`") {
44 const end = input.indexOf("`", i + 1);
45 if (end > i) {
46 flush();
47 push({ text: input.slice(i + 1, end), code: true, ...(marks.href ? { href: marks.href } : {}) });
48 i = end + 1;
49 continue;
50 }
51 }
52 const link = /^\[([^\]]*)\]\(([^)\s]+)(?:\s+"[^"]*")?\)/.exec(rest);
53 if (link && !marks.href) {
54 flush();
55 walk(link[1]!, { ...marks, href: link[2]! });
56 i += link[0].length;
57 continue;
58 }
59 const image = /^!\[([^\]]*)\]\(([^)\s]+)[^)]*\)/.exec(rest);
60 if (image) {
61 flush();
62 push({ text: image[1] || "image", ...marks, href: image[2]! });
63 i += image[0].length;
64 continue;
65 }
66 const strong = /^(\*\*|__)(?=\S)([\s\S]*?\S)\1/.exec(rest);
67 if (strong && (strong[1] === "**" || !/\w/.test(input[i - 1] ?? ""))) {
68 flush();
69 walk(strong[2]!, { ...marks, bold: true });
70 i += strong[0].length;
71 continue;
72 }
73 const em = /^(\*|_)(?=\S)([\s\S]*?\S)\1(?!\1)/.exec(rest);
74 if (em && (em[1] === "*" || (!/\w/.test(input[i - 1] ?? "") && !/\w/.test(input[i + em[0].length] ?? "")))) {
75 flush();
76 walk(em[2]!, { ...marks, italic: true });
77 i += em[0].length;
78 continue;
79 }
80 const strike = /^~~(?=\S)([\s\S]*?\S)~~/.exec(rest);
81 if (strike) {
82 flush();
83 walk(strike[1]!, marks);
84 i += strike[0].length;
85 continue;
86 }
87 const tag = /^<\/?[A-Za-z][^>]*>/.exec(rest);
88 if (tag) {
89 plain += /^<br\s*\/?>$/i.test(tag[0]) ? " " : "";
90 i += tag[0].length;
91 continue;
92 }
93 plain += ch;
94 i += 1;
95 }
96 flush();
97 };
98 walk(text.replace(/\s+/g, " ").trim(), {});
99 return spans;
100}
101
102/** A table row's cells, without the outer pipes. */
103function cells(line: string): string[] {
104 let row = line.trim();
105 if (row.startsWith("|")) row = row.slice(1);
106 if (row.endsWith("|") && !row.endsWith("\\|")) row = row.slice(0, -1);
107 const out: string[] = [];
108 let cell = "";
109 for (let i = 0; i < row.length; i++) {
110 if (row[i] === "\\" && row[i + 1] === "|") {
111 cell += "|";
112 i++;
113 } else if (row[i] === "|") {
114 out.push(cell.trim());
115 cell = "";
116 } else cell += row[i];
117 }
118 out.push(cell.trim());
119 return out;
120}
121
122const DIVIDER = /^\s*\|?\s*:?-{2,}:?\s*(\|\s*:?-{2,}:?\s*)*\|?\s*$/;
123const ITEM = /^(\s*)([-*+]|\d{1,9}[.)])\s+(.*)$/;
124
125/** Markdown as blocks. */
126export function parseBlocks(markdown: string): Block[] {
127 const lines = String(markdown ?? "").replace(/\r\n?/g, "\n").replace(/\t/g, " ").split("\n");
128 const blocks: Block[] = [];
129 let paragraph: string[] = [];
130 const endParagraph = () => {
131 if (paragraph.length) blocks.push({ kind: "paragraph", spans: parseSpans(paragraph.join(" ")) });
132 paragraph = [];
133 };
134 for (let i = 0; i < lines.length; i++) {
135 const line = lines[i]!;
136 const fence = /^\s*(```+|~~~+)\s*([\w+-]*)/.exec(line);
137 if (fence) {
138 endParagraph();
139 const body: string[] = [];
140 i++;
141 while (i < lines.length && !lines[i]!.trim().startsWith(fence[1]!)) body.push(lines[i++]!);
142 // A Mermaid chart can't be drawn into a file: its source is kept, as code.
143 blocks.push({ kind: "code", text: body.join("\n") });
144 continue;
145 }
146 if (!line.trim()) {
147 endParagraph();
148 continue;
149 }
150 const heading = /^\s{0,3}(#{1,6})\s+(.*?)\s*#*\s*$/.exec(line);
151 if (heading) {
152 endParagraph();
153 blocks.push({ kind: "heading", level: Math.min(3, heading[1]!.length) as 1 | 2 | 3, spans: parseSpans(heading[2]!) });
154 continue;
155 }
156 if (/^\s{0,3}([-*_])(\s*\1){2,}\s*$/.test(line)) {
157 endParagraph();
158 blocks.push({ kind: "rule" });
159 continue;
160 }
161 if (line.includes("|") && i + 1 < lines.length && DIVIDER.test(lines[i + 1]!)) {
162 endParagraph();
163 const header = cells(line).map(parseSpans);
164 const rows: Span[][][] = [];
165 i += 2;
166 while (i < lines.length && lines[i]!.includes("|") && lines[i]!.trim()) rows.push(cells(lines[i++]!).map(parseSpans));
167 i--;
168 blocks.push({ kind: "table", header, rows });
169 continue;
170 }
171 const quote = /^\s{0,3}>\s?(.*)$/.exec(line);
172 if (quote) {
173 endParagraph();
174 const body = [quote[1]!];
175 while (i + 1 < lines.length && /^\s{0,3}>/.test(lines[i + 1]!)) body.push(lines[++i]!.replace(/^\s{0,3}>\s?/, ""));
176 // GitHub's alerts (> [!NOTE]) read as their text.
177 const text = body.join(" ").replace(/^\[!(NOTE|TIP|IMPORTANT|WARNING|CAUTION)\]\s*/i, (_, kind: string) => `${kind[0]!.toUpperCase()}${kind.slice(1).toLowerCase()}: `);
178 blocks.push({ kind: "quote", spans: parseSpans(text) });
179 continue;
180 }
181 const item = ITEM.exec(line);
182 if (item) {
183 endParagraph();
184 const marker = item[2]!;
185 const ordered = /\d/.test(marker);
186 let text = item[3]!;
187 // Lazy continuation lines belong to the item.
188 while (i + 1 < lines.length && lines[i + 1]!.trim() && !ITEM.test(lines[i + 1]!) && /^\s{2,}\S/.test(lines[i + 1]!) && !/^\s*(```|~~~|#|>|\|)/.test(lines[i + 1]!)) {
189 text += ` ${lines[++i]!.trim()}`;
190 }
191 const task = /^\[([ xX])\]\s+(.*)$/.exec(text);
192 if (task) text = `${task[1] === " " ? "[ ]" : "[x]"} ${task[2]}`;
193 blocks.push({ kind: "item", ordered, number: ordered ? Number.parseInt(marker, 10) : 0, depth: Math.min(3, Math.floor(item[1]!.length / 2)), spans: parseSpans(text) });
194 continue;
195 }
196 paragraph.push(line.trim());
197 }
198 endParagraph();
199 return blocks;
200}
201
202/** A span list's plain text. */
203export function spanText(spans: Span[]): string {
204 return spans.map((s) => s.text).join("");
205}