// Markdown, the vault's flavour of it, in one file that runs in two places: // the server imports it to render the first paint, and the browser loads // it to render the live preview while you type. One renderer, so what you // see while writing is what the page shows afterwards. // // Safe by construction: the text is escaped first, and every tag comes // from a pattern matched on the escaped text — nothing a note says can // open a tag the renderer did not write. Links go only to http(s), mailto // and this site's own paths. // // Beyond CommonMark's everyday core: [[wikilinks]] (with |alias and // #heading), #tags, ==highlights==, ~~strikes~~, - [ ] tasks, tables, // and Obsidian's callouts (> [!note] Title). const ESC = { "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }; const UNESC = { "&": "&", "<": "<", ">": ">", """: '"', "'": "'" }; /** @param {string} s */ export const escapeHtml = (s) => s.replace(/[&<>"']/g, (c) => ESC[c] ?? c); /** @param {string} s */ const unescapeHtml = (s) => s.replace(/&(?:amp|lt|gt|quot|#39);/g, (e) => UNESC[e] ?? e); /** A heading's anchor: lower case, words joined by dashes. @param {string} s */ export const slug = (s) => s .toLowerCase() .replace(/[^\p{L}\p{N}\s-]/gu, "") .trim() .replace(/\s+/g, "-"); /** The text with fenced code and inline code removed, for finding links and tags. @param {string} md */ const prose = (md) => md.replace(/^(```|~~~)[^\n]*\n[\s\S]*?(?:^\1[^\n]*$|(?![\s\S]))/gm, "").replace(/`[^`\n]*`/g, ""); const WIKI = /(!?)\[\[([^\[\]\n|]+?)(?:\|([^\[\]\n]+?))?\]\]/g; const TAG = /(^|[\s(])#([\p{L}\p{N}_/-]*[\p{L}_/-][\p{L}\p{N}_/-]*)/gu; /** The note titles a note links to, in order, once each (no #heading). @param {string} md */ export function links(md) { const seen = new Set(); const out = []; for (const m of prose(md).matchAll(WIKI)) { const target = m[2].split("#")[0].trim(); const key = target.toLowerCase(); if (target !== "" && !seen.has(key)) { seen.add(key); out.push(target); } } return out; } /** The tags a note carries, lower-cased, once each, without the #. @param {string} md */ export function tags(md) { const out = new Set(); for (const m of prose(md).matchAll(TAG)) out.add(m[2].toLowerCase()); return [...out]; } /** The note's headings, for an outline. @param {string} md */ export function headings(md) { const out = []; for (const m of prose(md).matchAll(/^(#{1,6})[ \t]+(.*?)[ \t]*#*[ \t]*$/gm)) { const level = m[1].length; const text = m[2].trim(); if (text === "") continue; out.push({ level, text, id: slug(text) }); } return out; } const SAFE_URL = /^(?:https?:\/\/|mailto:|\/(?!\/)|#)/i; /** * One line's inline marks, on text that is already escaped. * @param {string} text escaped * @param {Options} o */ function inline(text, o) { // Code spans first: what is inside them is no mark. const parts = text.split(/(`+)([^`]|[^`][\s\S]*?[^`])\1(?!`)/); let out = ""; for (let i = 0; i < parts.length; i++) { if (i % 3 === 0) out += marks(parts[i], o); else if (i % 3 === 2) out += `${parts[i]}`; } return out; } /** @param {string} s escaped @param {Options} o */ function marks(s, o) { const held = []; const hold = (html) => `\u0000${held.push(html) - 1}\u0000`; s = s .replace(WIKI, (_, embed, target, alias) => { const [title, heading] = unescapeHtml(target).split("#"); const name = title.trim(); const shown = alias ?? target; const href = o.resolve(name); const anchor = heading ? `#${slug(heading)}` : ""; return hold( href === null ? `${shown}` : `${shown}`, ); }) .replace(/\[([^\]\n]+)\]\(([^)\s]+)\)/g, (whole, label, url) => SAFE_URL.test(unescapeHtml(url)) ? hold(`${label}`) : whole, ) .replace(/(^|[\s(])(https?:\/\/(?:(?!&(?:quot|#39|lt|gt);)[^\s<])*[^\s<.,;:!?)'"&])/g, (_, lead, url) => lead + hold(`${url}`), ) .replace(TAG, (_, lead, tag) => lead + hold(`#${tag}`), ); s = s .replace(/\*\*(?=\S)([^*]*?\S)\*\*/g, "$1") .replace(/__(?=\S)([^_]*?\S)__/g, "$1") .replace(/(^|[^*\w])\*(?=\S)([^*]*?\S)\*(?!\w)/g, "$1$2") .replace(/(^|[^_\w])_(?=\S)([^_]*?\S)_(?!\w)/g, "$1$2") .replace(/~~(?=\S)([^~]*?\S)~~/g, "$1") .replace(/==(?=\S)([^=]*?\S)==/g, "$1"); return s.replace(/\u0000(\d+)\u0000/g, (_, i) => held[Number(i)]); } /** * @typedef {{ resolve: (title: string) => string | null }} Options * `resolve` turns a linked note's title into its address, or null when no * such note exists yet (the link is drawn as one to create). */ const CALLOUTS = new Set(["note", "info", "tip", "success", "question", "warning", "danger", "bug", "example", "quote", "abstract", "todo", "failure"]); /** * A whole note, as HTML. * @param {string} md * @param {Partial} [options] */ export function render(md, options = {}) { const o = { resolve: options.resolve ?? (() => null) }; return blocks(md.replace(/\r\n?/g, "\n").split("\n"), o); } /** @param {string[]} lines @param {Options} o */ function blocks(lines, o) { let out = ""; let i = 0; const para = []; const flush = () => { if (para.length > 0) out += `

${para.map((l) => inline(escapeHtml(l), o)).join("
")}

\n`; para.length = 0; }; while (i < lines.length) { const line = lines[i]; let m; if (/^\s*$/.test(line)) { flush(); i++; } else if ((m = /^(```|~~~)\s*([\w+-]*)/.exec(line))) { flush(); const fence = m[1]; const code = []; i++; while (i < lines.length && !lines[i].startsWith(fence)) code.push(lines[i++]); i++; out += `
${escapeHtml(code.join("\n"))}
\n`; } else if ((m = /^(#{1,6})[ \t]+(.*?)[ \t]*#*[ \t]*$/.exec(line))) { flush(); const n = m[1].length; out += `${inline(escapeHtml(m[2]), o)}\n`; i++; } else if (/^ {0,3}([-*_])(?:[ \t]*\1){2,}[ \t]*$/.test(line)) { flush(); out += "
\n"; i++; } else if (/^ {0,3}>/.test(line)) { flush(); const quoted = []; while (i < lines.length && /^ {0,3}>/.test(lines[i])) quoted.push(lines[i++].replace(/^ {0,3}> ?/, "")); const callout = /^\[!(\w+)\]([+-]?)[ \t]*(.*)$/.exec(quoted[0] ?? ""); if (callout) { const kind = callout[1].toLowerCase(); const cls = CALLOUTS.has(kind) ? kind : "note"; const heading = callout[3] || kind.charAt(0).toUpperCase() + kind.slice(1); out += `

${inline(escapeHtml(heading), o)}

${blocks(quoted.slice(1), o).trimEnd()}
\n`; } else out += `
${blocks(quoted, o).trimEnd()}
\n`; } else if (/^\s*([-*+]|\d+[.)])\s/.test(line)) { flush(); const items = []; while (i < lines.length && (/^\s*([-*+]|\d+[.)])\s/.test(lines[i]) || (/^\s{2,}\S/.test(lines[i]) && items.length > 0))) items.push(lines[i++]); out += list(items, o); } else if (/^\s*\|.*\|\s*$/.test(line) && /^\s*\|?[\s:|-]*-[\s:|-]*\|?\s*$/.test(lines[i + 1] ?? "")) { flush(); const cells = (l) => l.trim().replace(/^\||\|$/g, "").split("|").map((c) => c.trim()); const head = cells(line); const align = cells(lines[i + 1]).map((c) => (c.startsWith(":") && c.endsWith(":") ? "center" : c.endsWith(":") ? "right" : "")); const style = (k) => (align[k] ? ` style="text-align:${align[k]}"` : ""); i += 2; let body = ""; while (i < lines.length && /^\s*\|.*\|\s*$/.test(lines[i])) { body += `${cells(lines[i++]).map((c, k) => `${inline(escapeHtml(c), o)}`).join("")}`; } out += `
${head.map((c, k) => `${inline(escapeHtml(c), o)}`).join("")}${body}
\n`; } else { para.push(line); i++; } } flush(); return out; } /** Lines of a list, nested by indentation. @param {string[]} lines @param {Options} o */ function list(lines, o) { const indent = (l) => l.match(/^\s*/)[0].replace(/\t/g, " ").length; const base = indent(lines[0]); const ordered = /^\s*\d+[.)]\s/.test(lines[0]); let html = ""; let i = 0; while (i < lines.length) { const m = /^\s*(?:[-*+]|\d+[.)])\s+(.*)$/.exec(lines[i]); const text = m ? m[1] : lines[i].trim(); i++; const child = []; while (i < lines.length && indent(lines[i]) > base) child.push(lines[i++]); const task = /^\[([ xX])\]\s+(.*)$/.exec(text); const body = task ? `${inline(escapeHtml(task[2]), o)}` : inline(escapeHtml(text), o); html += `${body}${child.length > 0 ? list(child, o).trimEnd() : ""}`; } return ordered ? `
    ${html}
\n` : `
    ${html}
\n`; }