md.ts
8.4 kB · typescript · 217 lines
1import type { Element, Root as Hast, RootContent as HastContent } from "hast";2import type { Heading, Nodes, Paragraph, Root } from "mdast";3import rehypeStringify from "rehype-stringify";4import remarkGfm from "remark-gfm";5import remarkMath from "remark-math";6import remarkParse from "remark-parse";7import remarkRehype from "remark-rehype";8import { unified } from "unified";9import { visit } from "unist-util-visit";1011/* TYPES */1213export type Link = (url: string) => string;1415export type Options = {16 link?: Link;17 math?: (tex: string, display: boolean) => string;18 widget?: (name: string, view: string, caption: string) => string;19};2021type Raw = { type: "raw"; value: string };2223/* TEXT */2425const ESC: Record<string, string> = { "&": "&", "<": "<", ">": ">", '"': """ };2627export const escape = (text: unknown) => String(text).replace(/[&<>"]/g, (c) => ESC[c]!);2829export const slug = (text: string) => text.toLowerCase().replace(/[^\p{L}\p{N} _-]/gu, "").replace(/ /g, "-");3031const TEX: Record<string, string> = { lceil: "⌈", rceil: "⌉", lfloor: "⌊", rfloor: "⌋", ne: "≠", neq: "≠", le: "≤", leq: "≤", ge: "≥", geq: "≥", times: "×", infty: "∞", pi: "π", cdot: "·", to: "→", left: "", right: "" };3233export function plain(text: string): string {34 return text35 .replace(/\\([a-zA-Z]+)\s?/g, (m, name: string) => (name in TEX ? TEX[name]! : m))36 .replace(/\s+([⌉⌋])/g, "$1")37 .replace(/!\[([^\]]*)\]\([^)]*\)/g, "$1")38 .replace(/\[([^\]]*)\]\([^)]*\)/g, "$1")39 .replace(/`([^`]*)`/g, "$1")40 .replace(/\*\*([^*]*)\*\*/g, "$1")41 .replace(/\*([^*]*)\*/g, "$1")42 .replace(/\$\$?([^$]*)\$\$?/g, "$1")43 .replace(/\s+/g, " ")44 .trim();45}4647export function title(md: string): string {48 const m = md.match(/^# (.+)$/m);49 return m ? plain(m[1]!) : "";50}5152export function summary(md: string, max = 200): string {53 const skip = /^(#{1,6} |```|\$\$|\||!\[)/;54 const line = md.split("\n").find((l) => l.trim() && !skip.test(l)) ?? "";55 const text = plain(line.replace(/^(- |\d+\. |> )/, ""));56 if (text.length <= max) return text;57 const cut = text.lastIndexOf(" ", max);58 return text.slice(0, cut > 0 ? cut : max);59}6061export function front(md: string): { data: Record<string, string>; body: string } {62 const m = md.match(/^---\n([\s\S]*?)\n---\n?/);63 if (!m) return { data: {}, body: md };64 const data: Record<string, string> = {};65 for (const line of m[1]!.split("\n")) {66 const at = line.indexOf(":");67 if (at > 0) data[line.slice(0, at).trim()] = line.slice(at + 1).trim();68 }69 return { data, body: md.slice(m[0].length) };70}7172/* SHAPE */7374const WORD = /[\p{L}\p{N}]/u;7576const PROTECTED = new Set(["code", "inlineCode", "math", "inlineMath", "html"]);7778const unescape = (tex: string) => tex.replace(/\\\\([!-\/:-@[-`{-~])/g, "\\$1");7980function literal(src: string, tree: Root): string {81 const keep: [number, number][] = [];82 visit(tree, (node) => {83 if (PROTECTED.has(node.type)) keep.push([node.position!.start.offset!, node.position!.end.offset!]);84 });85 keep.sort((a, b) => a[0] - b[0]);86 let out = "";87 let at = 0;88 const prose = (from: number, to: number) => {89 for (let i = from; i < to; i++) out += src[i] === "*" && WORD.test(src[i - 1] ?? "") && WORD.test(src[i + 1] ?? "") ? "\\*" : src[i]!;90 };91 for (const [from, to] of keep) {92 if (from < at) continue;93 prose(at, from);94 out += src.slice(from, to);95 at = to;96 }97 prose(at, src.length);98 return out;99}100101const FIGURE = /^!\[([^\]]*)\]\(([^)]*)\)$/;102103const WIDGET = /^!\[([^\]]*)\]\(demos\/([a-z0-9-]+)\/([a-z0-9-]+)\)$/;104105const source = (src: string, node: Nodes) => src.slice(node.position!.start.offset, node.position!.end.offset);106107const headingText = (src: string, node: Heading) =>108 source(src, node).replace(/^#{1,6}[ \t]+/, "").replace(/[ \t]+#+[ \t]*$/, "").replace(/\n[ \t]*[=-]+[ \t]*$/, "").trim();109110function figure(src: string, node: Paragraph, opts: Options): Raw | null {111 if (node.children.length !== 1 || node.children[0]!.type !== "image") return null;112 const text = source(src, node);113 const widget = opts.widget && text.match(WIDGET);114 if (widget) return { type: "raw", value: opts.widget!(widget[2]!, widget[3]!, inline(widget[1]!, opts)) };115 const image = text.match(FIGURE);116 if (!image) return null;117 const href = (opts.link ?? ((u) => u))(image[2]!.trim());118 const alt = (node.children[0] as { alt?: string }).alt ?? "";119 return { type: "raw", value: `<figure><img src="${escape(href)}" alt="${escape(alt)}"><figcaption>${inline(image[1]!, opts)}</figcaption></figure>` };120}121122function shape(src: string, opts: Options) {123 return (tree: Root) => {124 const ids = new Map<string, number>();125 visit(tree, (node, index, parent) => {126 if (node.type === "heading") {127 const base = slug(plain(headingText(src, node)));128 const seen = ids.get(base) ?? 0;129 ids.set(base, seen + 1);130 node.data = { ...node.data, hProperties: { id: seen ? `${base}-${seen}` : base } };131 } else if (node.type === "list") node.spread = false;132 else if (node.type === "listItem") node.spread = false;133 else if ((node.type === "link" || node.type === "image" || node.type === "definition") && opts.link) node.url = opts.link(node.url);134 else if (node.type === "paragraph" && parent && index !== undefined) {135 const only = node.children.length === 1 ? node.children[0]! : null;136 if (only && only.type === "inlineMath" && source(src, node).startsWith("$$")) {137 parent.children[index] = { type: "math", value: only.value };138 return;139 }140 const raw = figure(src, node, opts);141 if (raw) parent.children[index] = raw as never;142 }143 });144 };145}146147/* TABLES */148149const blank = (node: HastContent) => node.type === "text" && !node.value.trim();150151function tables() {152 return (tree: Hast) => {153 visit(tree, "element", (node: Element, index, parent) => {154 if (node.tagName === "th" || node.tagName === "td") {155 const align = node.properties.align;156 delete node.properties.align;157 if (align === "center" || align === "right") node.properties.className = [align];158 } else if (node.tagName === "tr" || node.tagName === "thead") node.children = node.children.filter((kid) => !blank(kid));159 else if (node.tagName === "table" && parent && index !== undefined) {160 node.children = node.children.filter((kid) => !blank(kid));161 parent.children[index] = { type: "element", tagName: "div", properties: { className: ["table"] }, children: [node] };162 }163 });164 };165}166167/* RENDER */168169const code = (tex: string) => `<code>${escape(tex)}</code>`;170171function parser(opts: Options) {172 const chain = unified().use(remarkParse).use(remarkGfm);173 return opts.math ? chain.use(remarkMath) : chain;174}175176export function render(md: string, opts: Options = {}): string {177 const raw = md.replace(/\r\n?/g, "\n");178 const src = literal(raw, parser(opts).parse(raw));179 const math = opts.math ?? code;180 const handlers = {181 math: (_: unknown, node: { value: string }) => ({ type: "raw", value: math(unescape(node.value), true) }),182 inlineMath: (_: unknown, node: { value: string }) => ({ type: "raw", value: math(unescape(node.value), false) }),183 html: (_: unknown, node: { value: string }) => ({ type: "text", value: node.value }),184 raw: (_: unknown, node: { value: string }) => ({ type: "raw", value: node.value }),185 };186 const out = parser(opts)187 .use(shape, src, opts)188 .use(remarkRehype, { allowDangerousHtml: true, handlers: handlers as never })189 .use(tables)190 .use(rehypeStringify, { allowDangerousHtml: true, characterReferences: { useNamedReferences: true } })191 .processSync(src);192 return String(out).replace(/\n$/, "");193}194195export function inline(text: string, opts: Options = {}): string {196 const html = render(text, opts);197 const m = html.match(/^<p>([\s\S]*)<\/p>$/);198 return m ? m[1]! : html;199}200201/* SHEET */202203export function sheet(md: string, link?: Link) {204 const src = md.replace(/\r\n?/g, "\n");205 const tree = parser({}).parse(src);206 const [first, second] = tree.children;207 const head = first && first.type === "heading" && first.depth === 1 ? first : null;208 const para = head && second && second.type === "paragraph" ? second : null;209 const lead = para ? source(src, para) : "";210 const from = para ? para.position!.end.offset! : head ? head.position!.end.offset! : 0;211 return {212 title: head ? plain(headingText(src, head)) : "",213 lead: lead ? inline(lead, { link }) : "",214 text: plain(lead),215 body: render(src.slice(from), { link }),216 };217}