Files
paseo/packages/app/src/components/markdown/html.ts
T

114 lines
4.6 KiB
TypeScript

import { Parser } from "htmlparser2";
import { fromMarkdown } from "mdast-util-from-markdown";
import { decodeString } from "micromark-util-decode-string";
import type { Nodes } from "mdast";
const ATTRIBUTE = String.raw`\s+[A-Za-z_:][\w.:-]*(?:\s*=\s*(?:[^\s"'=<>\x60]+|'[^']*'|"[^"]*"))?`;
const TAG = String.raw`<(?:[A-Za-z][A-Za-z0-9-]*(?:${ATTRIBUTE})*\s*\/?|\/[A-Za-z][A-Za-z0-9-]*\s*)>`;
const HTML = new RegExp(
String.raw`(?<!\\)(?:<!--[\s\S]*?-->|<code\b[^>]*>[\s\S]*?<\/code\s*>|${TAG})`,
"gi",
);
const EMPHASIS: Readonly<Record<string, string>> = { b: "**", strong: "**", i: "*", em: "*" };
/** Convert comment HTML to Markdown while preserving the original spelling of code. */
export function normalizeMarkdownHtml(source: string): string {
if (!source.includes("<")) return source;
const htmlRanges: { start: number; end: number }[] = [];
function findHtml(node: Nodes) {
if (node.type === "html" && node.position) {
htmlRanges.push({ start: node.position.start.offset!, end: node.position.end.offset! });
} else if ("children" in node) node.children.forEach(findHtml);
}
findHtml(fromMarkdown(source));
const codeRanges: { start: number; end: number }[] = [];
// Disable HTML parsing so code inside <details> remains visible to the Markdown parser.
const tree = fromMarkdown(source, {
extensions: [{ disable: { null: ["htmlFlow", "htmlText"] } }],
});
function protectCode(node: Nodes) {
if (node.type === "code" || node.type === "inlineCode") {
const start = node.position?.start.offset;
const end = node.position?.end.offset;
if (start !== undefined && end !== undefined) codeRanges.push({ start, end });
} else if ("children" in node) node.children.forEach(protectCode);
}
protectCode(tree);
const links: (string | undefined)[] = [];
const convert = (raw: string, offset: number): string => {
if (codeRanges.some(({ start, end }) => offset >= start && offset < end)) return raw;
if (raw.startsWith("<!--")) return "";
const code = /^<code\b[^>]*>([\s\S]*?)<\/code\s*>$/i.exec(raw);
if (code)
return inlineCode(
code[1].replace(/&(?:#(?:[xX][\da-fA-F]+|\d+)|[a-zA-Z][a-zA-Z\d]+);/g, (entity) =>
decodeString(entity),
),
);
const closing = raw.startsWith("</");
const name = /^<\/?([\w-]+)/.exec(raw)?.[1].toLowerCase() ?? "";
if (EMPHASIS[name]) return EMPHASIS[name];
if (name === "br") return closing ? "" : " \n";
if (name === "summary") return closing ? "**\n\n" : "**";
if (name === "details")
return (closing ? offset + raw.length === source.length : offset === 0) ? "" : "\n\n";
if (name === "a") return markdownAnchor(raw, closing, links);
if (name === "img" && !closing) {
const { src, alt = "" } = attributes(raw);
return src ? `![${alt.replace(/[[\]\\]/g, "\\$&")}](<${markdownUrl(src)}>)` : alt;
}
return "";
};
let result = "";
let cursor = 0;
for (const range of htmlRanges) {
if (range.start < cursor) continue;
result += source.slice(cursor, range.start);
// Inline HTML code has separate opening/closing AST nodes. Convert the pair
// together so its contents keep backslashes and get a safe backtick delimiter.
const code = /^<code\b[^>]*>[\s\S]*?<\/code\s*>/i.exec(source.slice(range.start));
if (code) {
result += convert(code[0], range.start);
cursor = range.start + code[0].length;
} else {
result += source
.slice(range.start, range.end)
.replace(HTML, (raw, offset) => convert(raw, range.start + offset));
cursor = range.end;
}
}
return result + source.slice(cursor);
}
function attributes(tag: string): Record<string, string> {
let result: Record<string, string> = {};
const parser = new Parser({
onopentag: (_name, value) => {
result = value;
},
});
parser.end(tag);
return result;
}
function markdownUrl(url: string): string {
return url.replace(/[<>\r\n]/g, (character) => encodeURIComponent(character));
}
function inlineCode(content: string): string {
const runs = content.match(/`+/g) ?? [];
const marker = "`".repeat(Math.max(0, ...runs.map((run) => run.length)) + 1);
const padding = content.startsWith("`") || content.endsWith("`") ? " " : "";
return `${marker}${padding}${content}${padding}${marker}`;
}
function markdownAnchor(raw: string, closing: boolean, links: (string | undefined)[]): string {
if (closing) {
const href = links.pop();
return href === undefined ? "" : `](<${markdownUrl(href)}>)`;
}
const { href } = attributes(raw);
links.push(href);
return href === undefined ? "" : "[";
}