| 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 | |
| 8 | export type Span = { text: string; bold?: boolean; italic?: boolean; code?: boolean; href?: string }; |
| 9 | |
| 10 | export 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. */ |
| 20 | export 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. */ |
| 103 | function 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 | |
| 122 | const DIVIDER = /^\s*\|?\s*:?-{2,}:?\s*(\|\s*:?-{2,}:?\s*)*\|?\s*$/; |
| 123 | const ITEM = /^(\s*)([-*+]|\d{1,9}[.)])\s+(.*)$/; |
| 124 | |
| 125 | /** Markdown as blocks. */ |
| 126 | export 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. */ |
| 203 | export function spanText(spans: Span[]): string { |
| 204 | return spans.map((s) => s.text).join(""); |
| 205 | } |