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> = { "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" };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}