md.js
8.1 kB · javascript · 237 lines
1const ESC = { "&": "&", "<": "<", ">": ">", '"': """ };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}