mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-04 23:44:41 +00:00
114 lines
4.6 KiB
TypeScript
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 ? "" : "[";
|
|
}
|