1import type { Root, RootContent } from "mdast";
2import { unified } from "unified";
3import remarkParse from "remark-parse";
4import remarkGfm from "remark-gfm";
5
6const parser = unified().use(remarkParse).use(remarkGfm);
7export type MarkdownNode = Root | RootContent;
8
9function 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
16function 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
46const HTML_TAG = /<!--[\s\S]*?-->|<\/?[a-z](?:[^>"']|"[^"]*"|'[^']*')*>/gi;
47
48function 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. */
59export 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 `![${alt}](<${href}>)`;
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. */
127export 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
144export function parseMarkdownDocument(markdown: string): Root {
145 return parser.parse(markdownWithHtml(markdown));
146}