| 1 | export type MarkdownTableAlignment = "left" | "center" | "right" | null; |
| 2 | |
| 3 | export interface VirtualMarkdownTableData { |
| 4 | align: MarkdownTableAlignment[]; |
| 5 | header: string[]; |
| 6 | rows: string[][]; |
| 7 | } |
| 8 | |
| 9 | export interface ExtractedMarkdownTables { |
| 10 | text: string; |
| 11 | markerPrefix: string; |
| 12 | tables: VirtualMarkdownTableData[]; |
| 13 | } |
| 14 | |
| 15 | const LARGE_TABLE_MIN_ROWS = 50; |
| 16 | const UNSAFE_INLINE_MARKDOWN_RE = /[\\`*_{}\[\]<>!&$~^]/; |
| 17 | |
| 18 | function stripOuterPipe(line: string): string { |
| 19 | let value = line.trim(); |
| 20 | if (value.startsWith("|")) value = value.slice(1); |
| 21 | if (value.endsWith("|")) value = value.slice(0, -1); |
| 22 | return value; |
| 23 | } |
| 24 | |
| 25 | function splitPlainRow(line: string): string[] | null { |
| 26 | if (!line.includes("|") || /^\s{4}/.test(line)) return null; |
| 27 | const cells = stripOuterPipe(line).split("|").map((cell) => cell.trim()); |
| 28 | if (cells.length < 2 || cells.some((cell) => UNSAFE_INLINE_MARKDOWN_RE.test(cell))) return null; |
| 29 | return cells; |
| 30 | } |
| 31 | |
| 32 | function delimiterAlignment(line: string): MarkdownTableAlignment[] | null { |
| 33 | if (!line.includes("|") || /^\s{4}/.test(line)) return null; |
| 34 | const cells = stripOuterPipe(line).split("|").map((cell) => cell.trim()); |
| 35 | if (cells.length < 2 || cells.some((cell) => !/^:?-+:?$/.test(cell))) return null; |
| 36 | return cells.map((cell) => { |
| 37 | const left = cell.startsWith(":"); |
| 38 | const right = cell.endsWith(":"); |
| 39 | if (left && right) return "center"; |
| 40 | if (right) return "right"; |
| 41 | if (left) return "left"; |
| 42 | return null; |
| 43 | }); |
| 44 | } |
| 45 | |
| 46 | function normalizeRow(cells: string[], columns: number): string[] { |
| 47 | if (cells.length >= columns) return cells.slice(0, columns); |
| 48 | return [...cells, ...Array<string>(columns - cells.length).fill("")]; |
| 49 | } |
| 50 | |
| 51 | function fenceStart(line: string): { marker: string; length: number } | null { |
| 52 | const match = /^ {0,3}(`{3,}|~{3,})/.exec(line); |
| 53 | return match ? { marker: match[1][0], length: match[1].length } : null; |
| 54 | } |
| 55 | |
| 56 | function fenceEnd(line: string, fence: { marker: string; length: number }): boolean { |
| 57 | const escaped = fence.marker === "`" ? "`" : "~"; |
| 58 | return new RegExp(`^ {0,3}${escaped}{${fence.length},}[ \\t]*$`).test(line); |
| 59 | } |
| 60 | |
| 61 | function unusedMarkerPrefix(text: string): string { |
| 62 | let suffix = 0; |
| 63 | let prefix = "REASONIXLARGETABLE"; |
| 64 | while (text.includes(prefix)) { |
| 65 | suffix += 1; |
| 66 | prefix = `REASONIXLARGETABLE${suffix}`; |
| 67 | } |
| 68 | return prefix; |
| 69 | } |
| 70 | |
| 71 | /** |
| 72 | * Extract only large, top-level GFM tables whose cells are provably plain |
| 73 | * text. Complex tables keep flowing through remark-gfm unchanged. This avoids |
| 74 | * micromark's super-linear giant-table path without reimplementing inline |
| 75 | * Markdown, references, footnotes, math, escapes, entities, or raw HTML. |
| 76 | */ |
| 77 | export function extractLargePlainMarkdownTables(text: string): ExtractedMarkdownTables { |
| 78 | const lines = text.split("\n"); |
| 79 | const output: string[] = []; |
| 80 | const tables: VirtualMarkdownTableData[] = []; |
| 81 | const markerPrefix = unusedMarkerPrefix(text); |
| 82 | let fence: { marker: string; length: number } | null = null; |
| 83 | |
| 84 | for (let index = 0; index < lines.length;) { |
| 85 | const line = lines[index]; |
| 86 | if (fence) { |
| 87 | output.push(line); |
| 88 | if (fenceEnd(line, fence)) fence = null; |
| 89 | index += 1; |
| 90 | continue; |
| 91 | } |
| 92 | const openingFence = fenceStart(line); |
| 93 | if (openingFence) { |
| 94 | fence = openingFence; |
| 95 | output.push(line); |
| 96 | index += 1; |
| 97 | continue; |
| 98 | } |
| 99 | |
| 100 | const header = splitPlainRow(line); |
| 101 | const align = index + 1 < lines.length ? delimiterAlignment(lines[index + 1]) : null; |
| 102 | if (!header || !align || header.length !== align.length) { |
| 103 | output.push(line); |
| 104 | index += 1; |
| 105 | continue; |
| 106 | } |
| 107 | |
| 108 | const rows: string[][] = []; |
| 109 | let rowIndex = index + 2; |
| 110 | while (rowIndex < lines.length) { |
| 111 | const cells = splitPlainRow(lines[rowIndex]); |
| 112 | if (!cells) break; |
| 113 | rows.push(normalizeRow(cells, header.length)); |
| 114 | rowIndex += 1; |
| 115 | } |
| 116 | // Fail closed when a complex/non-plain row might still belong to the same |
| 117 | // GFM table. Only a blank line or EOF is an unambiguous fast-path end. |
| 118 | const cleanEnd = rowIndex === lines.length || lines[rowIndex].trim() === ""; |
| 119 | if (rows.length <= LARGE_TABLE_MIN_ROWS || !cleanEnd) { |
| 120 | output.push(line); |
| 121 | index += 1; |
| 122 | continue; |
| 123 | } |
| 124 | |
| 125 | const tableIndex = tables.length; |
| 126 | tables.push({ align, header, rows }); |
| 127 | output.push(`${markerPrefix}${tableIndex}`); |
| 128 | index = rowIndex; |
| 129 | } |
| 130 | |
| 131 | return { text: output.join("\n"), markerPrefix, tables }; |
| 132 | } |
| 133 |