| 1 | import type { Root, RootContent } from "mdast"; |
| 2 | import { unified } from "unified"; |
| 3 | import remarkParse from "remark-parse"; |
| 4 | import remarkGfm from "remark-gfm"; |
| 5 | |
| 6 | const parser = unified().use(remarkParse).use(remarkGfm); |
| 7 | export type MarkdownNode = Root | RootContent; |
| 8 | |
| 9 | function decodeCodePoint(codePoint: number, entity: string): string { |
| 10 | if (!Number.isInteger(codePoint) || codePoint < 0 || codePoint > 0x10ffff) { |
| 11 | return entity; |
| 12 | } |
| 13 | return String.fromCodePoint(codePoint); |
| 14 | } |
| 15 | |
| 16 | function decodeHtmlEntities(value: string): string { |
| 17 | return value.replace( |
| 18 | /&(?:#(\d+)|#x([0-9a-f]+)|amp|apos|gt|lt|nbsp|quot);/gi, |
| 19 | (entity, decimal: string | undefined, hexadecimal: string | undefined) => { |
| 20 | if (decimal) { |
| 21 | return decodeCodePoint(Number.parseInt(decimal, 10), entity); |
| 22 | } |
| 23 | if (hexadecimal) { |
| 24 | return decodeCodePoint(Number.parseInt(hexadecimal, 16), entity); |
| 25 | } |
| 26 | switch (entity.toLowerCase()) { |
| 27 | case "&amp;": |
| 28 | return "&"; |
| 29 | case "&apos;": |
| 30 | return "'"; |
| 31 | case "&gt;": |
| 32 | return ">"; |
| 33 | case "&lt;": |
| 34 | return "<"; |
| 35 | case "&nbsp;": |
| 36 | return "\u00a0"; |
| 37 | case "&quot;": |
| 38 | return '"'; |
| 39 | default: |
| 40 | return entity; |
| 41 | } |
| 42 | }, |
| 43 | ); |
| 44 | } |
| 45 | |
| 46 | const HTML_TAG = /<!--[\s\S]*?-->|<\/?[a-z](?:[^>"']|"[^"]*"|'[^']*')*>/gi; |
| 47 | |
| 48 | function attribute(tag: string, name: string): string | null { |
| 49 | const attributes = /([\w:-]+)\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s>]+))/g; |
| 50 | for (const match of tag.matchAll(attributes)) { |
| 51 | if (match[1]?.toLowerCase() === name) { |
| 52 | return decodeHtmlEntities(match[2] ?? match[3] ?? match[4] ?? ""); |
| 53 | } |
| 54 | } |
| 55 | return null; |
| 56 | } |
| 57 | |
| 58 | /** Called only for parsed HTML nodes, so Markdown code examples keep their literal tags. */ |
| 59 | export function markdownHtmlContent(content: string): string { |
| 60 | // An HTML block such as <details> can also contain Markdown fences and code spans. |
| 61 | const codeRanges: Array<{ start: number; end: number }> = []; |
| 62 | const tags = [...content.matchAll(HTML_TAG)]; |
| 63 | let tagIndex = 0; |
| 64 | const delimiters = [...content.matchAll(/`+|~{3,}/g)].filter((delimiter) => { |
| 65 | while (tags[tagIndex] && tags[tagIndex]!.index + tags[tagIndex]![0].length <= delimiter.index) |
| 66 | tagIndex += 1; |
| 67 | const tag = tags[tagIndex]; |
| 68 | return !tag || delimiter.index < tag.index; |
| 69 | }); |
| 70 | for (let index = 0; index < delimiters.length; index += 1) { |
| 71 | const opening = delimiters[index]!; |
| 72 | const lineStart = content.lastIndexOf("\n", opening.index - 1) + 1; |
| 73 | const fenced = |
| 74 | opening[0].length >= 3 && /^ {0,3}$/.test(content.slice(lineStart, opening.index)); |
| 75 | if (opening[0][0] === "~" && !fenced) continue; |
| 76 | let closingIndex = -1; |
| 77 | for (let candidate = index + 1; candidate < delimiters.length; candidate += 1) { |
| 78 | const closing = delimiters[candidate]!; |
| 79 | if (!fenced && closing[0] === opening[0]) { |
| 80 | closingIndex = candidate; |
| 81 | break; |
| 82 | } |
| 83 | if (!fenced || closing[0][0] !== opening[0][0] || closing[0].length < opening[0].length) |
| 84 | continue; |
| 85 | const start = content.lastIndexOf("\n", closing.index - 1) + 1; |
| 86 | const nextLine = content.indexOf("\n", closing.index); |
| 87 | const end = nextLine === -1 ? content.length : nextLine; |
| 88 | if ( |
| 89 | /^ {0,3}$/.test(content.slice(start, closing.index)) && |
| 90 | /^\s*$/.test(content.slice(closing.index + closing[0].length, end)) |
| 91 | ) { |
| 92 | closingIndex = candidate; |
| 93 | break; |
| 94 | } |
| 95 | } |
| 96 | if (closingIndex === -1) { |
| 97 | if (fenced) codeRanges.push({ start: opening.index, end: content.length }); |
| 98 | continue; |
| 99 | } |
| 100 | const closing = delimiters[closingIndex]!; |
| 101 | codeRanges.push({ start: opening.index, end: closing.index + closing[0].length }); |
| 102 | index = closingIndex; |
| 103 | } |
| 104 | return content.replace(HTML_TAG, (tag, offset: number) => { |
| 105 | if (codeRanges.some((range) => offset >= range.start && offset < range.end)) return tag; |
| 106 | if (tag.startsWith("<!--")) return ""; |
| 107 | if (/^<img\b/i.test(tag)) { |
| 108 | const src = attribute(tag, "src"); |
| 109 | if (!src) return ""; |
| 110 | const alt = (attribute(tag, "alt") ?? "").replace(/[\\[\]&]/g, "\\$&"); |
| 111 | const href = src |
| 112 | .replace(/&/g, "\\&") |
| 113 | .replace(/</g, "%3C") |
| 114 | .replace(/>/g, "%3E") |
| 115 | .replace(/\n/g, ""); |
| 116 | return ``; |
| 117 | } |
| 118 | if (/^<summary\b/i.test(tag)) return "\n\n**"; |
| 119 | if (/^<\/summary\s*>/i.test(tag)) return "**\n\n"; |
| 120 | if (/^<br\b/i.test(tag)) return " \n"; |
| 121 | if (/^<\/?(?:p|div|details)\b/i.test(tag)) return "\n\n"; |
| 122 | return ""; |
| 123 | }); |
| 124 | } |
| 125 | |
| 126 | /** Convert parsed HTML fragments only, preserving Markdown code and source around them. */ |
| 127 | export function markdownWithHtml(markdown: string): string { |
| 128 | if (!/<(?:!--|\/?[a-z])/i.test(markdown)) return markdown; |
| 129 | const edits: Array<{ start: number; end: number; content: string }> = []; |
| 130 | const visit = (node: MarkdownNode) => { |
| 131 | if (node.type === "html") { |
| 132 | const start = node.position?.start.offset; |
| 133 | const end = node.position?.end.offset; |
| 134 | if (start !== undefined && end !== undefined) |
| 135 | edits.push({ start, end, content: markdownHtmlContent(node.value) }); |
| 136 | } else if ("children" in node) node.children.forEach(visit); |
| 137 | }; |
| 138 | visit(parser.parse(markdown)); |
| 139 | for (const edit of edits.reverse()) |
| 140 | markdown = markdown.slice(0, edit.start) + edit.content + markdown.slice(edit.end); |
| 141 | return markdown; |
| 142 | } |
| 143 | |
| 144 | export function parseMarkdownDocument(markdown: string): Root { |
| 145 | return parser.parse(markdownWithHtml(markdown)); |
| 146 | } |