md.js

8.1 kB · javascript · 237 lines

1const ESC = { "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" };2const SEP = /^\|?\s*:?-+:?\s*(\|\s*:?-+:?\s*)*\|?\s*$/;3const BLOCK = /^(#{1,6} |```|\$\$|- |\d+\. |> |\|)/;45export const escape = (s) => String(s).replace(/[&<>"]/g, (c) => ESC[c]);67export function slug(text) {8  return text.toLowerCase().replace(/[^\p{L}\p{N} _-]/gu, "").replace(/ /g, "-");9}1011const TEX = { lceil: "\u2308", rceil: "\u2309", lfloor: "\u230a", rfloor: "\u230b", ne: "\u2260", neq: "\u2260", le: "\u2264", leq: "\u2264", ge: "\u2265", geq: "\u2265", times: "\u00d7", infty: "\u221e", pi: "\u03c0", cdot: "\u00b7", to: "\u2192", left: "", right: "" };1213export function plain(s) {14  return s15    .replace(/\\([a-zA-Z]+)\s?/g, (m, name) => (name in TEX ? TEX[name] : m))16    .replace(/\s+([\u2309\u230b])/g, "$1")17    .replace(/!\[([^\]]*)\]\([^)]*\)/g, "$1")18    .replace(/\[([^\]]*)\]\([^)]*\)/g, "$1")19    .replace(/`([^`]*)`/g, "$1")20    .replace(/\*\*([^*]*)\*\*/g, "$1")21    .replace(/\*([^*]*)\*/g, "$1")22    .replace(/\$\$?([^$]*)\$\$?/g, "$1")23    .replace(/\s+/g, " ")24    .trim();25}2627export function title(md) {28  const m = md.match(/^# (.+)$/m);29  return m ? plain(m[1]) : "";30}3132export function summary(md, max = 200) {33  const skip = /^(#{1,6} |```|\$\$|\||!\[)/;34  const line = md.split("\n").find((l) => l.trim() && !skip.test(l)) ?? "";35  const text = plain(line.replace(/^(- |\d+\. |> )/, ""));36  if (text.length <= max) return text;37  const cut = text.lastIndexOf(" ", max);38  return text.slice(0, cut > 0 ? cut : max);39}4041function bracket(src, i) {42  let depth = 0;43  let j = i;44  for (; j < src.length; j++) {45    if (src[j] === "[") depth++;46    else if (src[j] === "]" && --depth === 0) break;47  }48  if (j >= src.length || src[j + 1] !== "(") return null;49  let k = j + 2;50  for (depth = 1; k < src.length; k++) {51    if (src[k] === "(") depth++;52    else if (src[k] === ")" && --depth === 0) break;53  }54  if (k >= src.length) return null;55  return { text: src.slice(i + 1, j), url: src.slice(j + 2, k).trim(), end: k + 1 };56}5758const WORD = /[\p{L}\p{N}]/u;59const blank = (c) => c === undefined || c === " " || c === "\n";6061function closing(src, from, mark) {62  if (blank(src[from]) || WORD.test(src[from - mark.length - 1] ?? " ")) return -1;63  let j = src.indexOf(mark, from + 1);64  while (j > 0 && (blank(src[j - 1]) || WORD.test(src[j + mark.length] ?? " "))) j = src.indexOf(mark, j + 1);65  return j;66}6768const unescape = (tex) => tex.replace(/\\\\([!-\/:-@[-`{-~])/g, "\\$1");6970export function inline(src, ctx) {71  let out = "";72  let i = 0;73  while (i < src.length) {74    const c = src[i];75    if (c === "`") {76      const j = src.indexOf("`", i + 1);77      if (j > i + 1) {78        out += "<code>" + escape(src.slice(i + 1, j)) + "</code>";79        i = j + 1;80        continue;81      }82    } else if (c === "$") {83      const display = src[i + 1] === "$";84      const mark = display ? "$$" : "$";85      const j = src.indexOf(mark, i + mark.length);86      if (j > i + mark.length - 1 && src.slice(i + mark.length, j).trim()) {87        out += ctx.math(unescape(src.slice(i + mark.length, j)), display);88        i = j + mark.length;89        continue;90      }91    } else if (c === "!" && src[i + 1] === "[") {92      const m = bracket(src, i + 1);93      if (m) {94        out += `<img src="${escape(ctx.link(m.url))}" alt="${escape(m.text)}">`;95        i = m.end;96        continue;97      }98    } else if (c === "[") {99      const m = bracket(src, i);100      if (m) {101        out += `<a href="${escape(ctx.link(m.url))}">${inline(m.text, ctx)}</a>`;102        i = m.end;103        continue;104      }105    } else if (c === "*") {106      const strong = src[i + 1] === "*";107      const mark = strong ? "**" : "*";108      const j = closing(src, i + mark.length, mark);109      if (j > 0) {110        const tag = strong ? "strong" : "em";111        out += `<${tag}>${inline(src.slice(i + mark.length, j), ctx)}</${tag}>`;112        i = j + mark.length;113        continue;114      }115    }116    out += ESC[c] ?? c;117    i++;118  }119  return out;120}121122function cells(row) {123  const trimmed = row.trim().replace(/^\|/, "").replace(/\|$/, "");124  return trimmed.split(/(?<!\\)\|/).map((c) => c.trim().replace(/\\\|/g, "|"));125}126127function table(rows, ctx) {128  const align = cells(rows[1]).map((c) => {129    const l = c.startsWith(":");130    const r = c.endsWith(":");131    return l && r ? "center" : r ? "right" : l ? "left" : "";132  });133  const cell = (tag, text, k) => {134    const cls = align[k] && align[k] !== "left" ? ` class="${align[k]}"` : "";135    return `<${tag}${cls}>${inline(text, ctx)}</${tag}>`;136  };137  const head = cells(rows[0]).map((c, k) => cell("th", c, k)).join("");138  const body = rows139    .slice(2)140    .map((r) => "<tr>" + cells(r).map((c, k) => cell("td", c, k)).join("") + "</tr>")141    .join("\n");142  return `<div class="table"><table><thead><tr>${head}</tr></thead><tbody>\n${body}\n</tbody></table></div>`;143}144145function heading(line, ctx) {146  const level = line.indexOf(" ");147  const text = line.slice(level + 1).trim();148  const base = slug(plain(text));149  const seen = ctx.ids.get(base) ?? 0;150  ctx.ids.set(base, seen + 1);151  const id = seen ? `${base}-${seen}` : base;152  return `<h${level} id="${id}">${inline(text, ctx)}</h${level}>`;153}154155const WIDGET = /^!\[([^\]]*)\]\(demos\/([a-z0-9-]+)\/([a-z0-9-]+)\)$/;156157function paragraph(text, ctx) {158  const widget = ctx.widget && text.match(WIDGET);159  if (widget) return ctx.widget(widget[2], widget[3], inline(widget[1], ctx));160  const image = text.match(/^!\[([^\]]*)\]\(([^)]*)\)$/);161  if (image) {162    const src = escape(ctx.link(image[2].trim()));163    return `<figure><img src="${src}" alt="${escape(image[1])}"><figcaption>${inline(image[1], ctx)}</figcaption></figure>`;164  }165  return `<p>${inline(text, ctx)}</p>`;166}167168export function render(md, opts = {}) {169  const ctx = {170    link: opts.link ?? ((u) => u),171    math: opts.math ?? ((t) => `<code>${escape(t)}</code>`),172    widget: opts.widget,173    ids: new Map(),174  };175  const lines = md.replace(/\r\n?/g, "\n").split("\n");176  const out = [];177  let i = 0;178  const run = (test) => {179    const start = i;180    while (i < lines.length && test(lines[i])) i++;181    return lines.slice(start, i);182  };183  while (i < lines.length) {184    const line = lines[i];185    if (line.startsWith("```")) {186      i++;187      const code = run((l) => !l.startsWith("```"));188      i++;189      out.push(`<pre><code>${escape(code.join("\n"))}</code></pre>`);190    } else if (line.startsWith("$$")) {191      const single = line.trim().length > 4 && line.trim().endsWith("$$");192      let tex;193      if (single) {194        tex = line.trim().slice(2, -2);195        i++;196      } else {197        i++;198        const body = run((l) => !l.trim().endsWith("$$"));199        const last = i < lines.length ? lines[i].trim().slice(0, -2) : "";200        i++;201        tex = line.slice(2) + "\n" + body.join("\n") + "\n" + last;202      }203      out.push(ctx.math(unescape(tex.trim()), true));204    } else if (/^#{1,6} /.test(line)) {205      out.push(heading(line, ctx));206      i++;207    } else if (line.startsWith("|") && i + 1 < lines.length && SEP.test(lines[i + 1])) {208      out.push(table(run((l) => l.startsWith("|")), ctx));209    } else if (line.startsWith("- ")) {210      const items = run((l) => l.startsWith("- "));211      out.push("<ul>\n" + items.map((l) => `<li>${inline(l.slice(2), ctx)}</li>`).join("\n") + "\n</ul>");212    } else if (/^\d+\. /.test(line)) {213      const items = run((l) => /^\d+\. /.test(l));214      out.push("<ol>\n" + items.map((l) => `<li>${inline(l.replace(/^\d+\. /, ""), ctx)}</li>`).join("\n") + "\n</ol>");215    } else if (line.startsWith("> ")) {216      const quote = run((l) => l.startsWith("> "));217      out.push(`<blockquote><p>${inline(quote.map((l) => l.slice(2)).join("\n"), ctx)}</p></blockquote>`);218    } else if (!line.trim()) {219      i++;220    } else {221      const text = [lines[i++], ...run((l) => l.trim() && !BLOCK.test(l))];222      out.push(paragraph(text.join("\n"), ctx));223    }224  }225  return out.join("\n");226}227228export function front(md) {229  const m = md.match(/^---\n([\s\S]*?)\n---\n?/);230  if (!m) return { data: {}, body: md };231  const data = {};232  for (const line of m[1].split("\n")) {233    const at = line.indexOf(":");234    if (at > 0) data[line.slice(0, at).trim()] = line.slice(at + 1).trim();235  }236  return { data, body: md.slice(m[0].length) };237}