| 1 |
|
| 2 |
|
| 3 |
|
| 4 |
|
| 5 |
|
| 6 |
|
| 7 |
|
| 8 |
|
| 9 |
|
| 10 |
|
| 11 |
|
| 12 |
|
| 13 |
|
| 14 |
|
| 15 |
const ESC = { "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }; |
| 16 |
const UNESC = { "&": "&", "<": "<", ">": ">", """: '"', "'": "'" }; |
| 17 |
|
| 18 |
|
| 19 |
export const escapeHtml = (s) => s.replace(/[&<>"']/g, (c) => ESC[c] ?? c); |
| 20 |
|
| 21 |
const unescapeHtml = (s) => s.replace(/&(?:amp|lt|gt|quot|#39);/g, (e) => UNESC[e] ?? e); |
| 22 |
|
| 23 |
|
| 24 |
export const slug = (s) => |
| 25 |
s |
| 26 |
.toLowerCase() |
| 27 |
.replace(/[^\p{L}\p{N}\s-]/gu, "") |
| 28 |
.trim() |
| 29 |
.replace(/\s+/g, "-"); |
| 30 |
|
| 31 |
|
| 32 |
const prose = (md) => md.replace(/^(```|~~~)[^\n]*\n[\s\S]*?(?:^\1[^\n]*$|(?![\s\S]))/gm, "").replace(/`[^`\n]*`/g, ""); |
| 33 |
|
| 34 |
const WIKI = /(!?)\[\[([^\[\]\n|]+?)(?:\|([^\[\]\n]+?))?\]\]/g; |
| 35 |
const TAG = /(^|[\s(])#([\p{L}\p{N}_/-]*[\p{L}_/-][\p{L}\p{N}_/-]*)/gu; |
| 36 |
|
| 37 |
|
| 38 |
export function links(md) { |
| 39 |
const seen = new Set(); |
| 40 |
const out = []; |
| 41 |
for (const m of prose(md).matchAll(WIKI)) { |
| 42 |
const target = m[2].split("#")[0].trim(); |
| 43 |
const key = target.toLowerCase(); |
| 44 |
if (target !== "" && !seen.has(key)) { |
| 45 |
seen.add(key); |
| 46 |
out.push(target); |
| 47 |
} |
| 48 |
} |
| 49 |
return out; |
| 50 |
} |
| 51 |
|
| 52 |
|
| 53 |
|
| 54 |
|
| 55 |
|
| 56 |
|
| 57 |
|
| 58 |
|
| 59 |
|
| 60 |
export function frontmatter(md) { |
| 61 |
const m = /^---[ \t]*\r?\n([\s\S]*?)\r?\n---[ \t]*(?:\r?\n|$)/.exec(md); |
| 62 |
if (!m) return null; |
| 63 |
|
| 64 |
const props = []; |
| 65 |
for (const line of m[1].split(/\r?\n/)) { |
| 66 |
const item = /^\s+-\s+(.*)$/.exec(line); |
| 67 |
if (item && props.length > 0) { |
| 68 |
props[props.length - 1][1].push(unquote(item[1])); |
| 69 |
continue; |
| 70 |
} |
| 71 |
const kv = /^([\w][\w .-]*?)\s*:\s*(.*)$/.exec(line); |
| 72 |
if (!kv) continue; |
| 73 |
const value = kv[2].trim(); |
| 74 |
const listed = /^\[(.*)\]$/.exec(value); |
| 75 |
props.push([kv[1], listed ? listed[1].split(",").map(unquote).filter((v) => v !== "") : value === "" ? [] : [unquote(value)]]); |
| 76 |
} |
| 77 |
return { props, body: md.slice(m[0].length) }; |
| 78 |
} |
| 79 |
|
| 80 |
const unquote = (s) => s.trim().replace(/^(["'])(.*)\1$/, "$2"); |
| 81 |
|
| 82 |
|
| 83 |
export function tags(md) { |
| 84 |
const out = new Set(); |
| 85 |
const fm = frontmatter(md); |
| 86 |
for (const [key, values] of fm?.props ?? []) |
| 87 |
if (/^tags?$/i.test(key)) |
| 88 |
for (const v of values.flatMap((v) => v.split(/[\s,]+/))) { |
| 89 |
const tag = v.replace(/^#/, "").toLowerCase(); |
| 90 |
if (/^[\p{L}\p{N}_/-]*[\p{L}_/-][\p{L}\p{N}_/-]*$/u.test(tag)) out.add(tag); |
| 91 |
} |
| 92 |
for (const m of prose(fm?.body ?? md).matchAll(TAG)) out.add(m[2].toLowerCase()); |
| 93 |
return [...out]; |
| 94 |
} |
| 95 |
|
| 96 |
|
| 97 |
export function headings(md) { |
| 98 |
const out = []; |
| 99 |
for (const m of prose(md).matchAll(/^(#{1,6})[ \t]+(.*?)[ \t]*#*[ \t]*$/gm)) { |
| 100 |
const level = m[1].length; |
| 101 |
const text = m[2].trim(); |
| 102 |
if (text === "") continue; |
| 103 |
out.push({ level, text, id: slug(text) }); |
| 104 |
} |
| 105 |
return out; |
| 106 |
} |
| 107 |
|
| 108 |
const SAFE_URL = /^(?:https?:\/\/|mailto:|\/(?!\/)|#)/i; |
| 109 |
|
| 110 |
|
| 111 |
|
| 112 |
|
| 113 |
|
| 114 |
|
| 115 |
function inline(text, o) { |
| 116 |
|
| 117 |
const parts = text.split(/(`+)([^`]|[^`][\s\S]*?[^`])\1(?!`)/); |
| 118 |
let out = ""; |
| 119 |
for (let i = 0; i < parts.length; i++) { |
| 120 |
if (i % 3 === 0) out += marks(parts[i], o); |
| 121 |
else if (i % 3 === 2) out += `<code>${parts[i]}</code>`; |
| 122 |
} |
| 123 |
return out; |
| 124 |
} |
| 125 |
|
| 126 |
|
| 127 |
function marks(s, o) { |
| 128 |
const held = []; |
| 129 |
const hold = (html) => `\u0000${held.push(html) - 1}\u0000`; |
| 130 |
s = s |
| 131 |
.replace(WIKI, (_, embed, target, alias) => { |
| 132 |
const [title, heading] = unescapeHtml(target).split("#"); |
| 133 |
const name = title.trim(); |
| 134 |
const shown = alias ?? target; |
| 135 |
const href = o.resolve(name); |
| 136 |
const anchor = heading ? `#${slug(heading)}` : ""; |
| 137 |
return hold( |
| 138 |
href === null |
| 139 |
? `<a class="wikilink unresolved" href="${o.base}/open?title=${encodeURIComponent(name)}" data-title="${escapeHtml(name)}">${shown}</a>` |
| 140 |
: `<a class="wikilink${embed ? " embed" : ""}" href="${escapeHtml(href)}${anchor}" data-title="${escapeHtml(name)}">${shown}</a>`, |
| 141 |
); |
| 142 |
}) |
| 143 |
.replace(/\[([^\]\n]+)\]\(([^)\s]+)\)/g, (whole, label, url) => |
| 144 |
SAFE_URL.test(unescapeHtml(url)) |
| 145 |
? hold(`<a href="${url}"${/^https?:/i.test(url) ? ' rel="noopener noreferrer" target="_blank"' : ""}>${label}</a>`) |
| 146 |
: whole, |
| 147 |
) |
| 148 |
.replace(/(^|[\s(])(https?:\/\/(?:(?!&(?:quot|#39|lt|gt);)[^\s<])*[^\s<.,;:!?)'"&])/g, (_, lead, url) => |
| 149 |
lead + hold(`<a href="${url}" rel="noopener noreferrer" target="_blank">${url}</a>`), |
| 150 |
) |
| 151 |
.replace(TAG, (_, lead, tag) => |
| 152 |
lead + hold(`<a class="tag" href="${o.base}/search?q=${encodeURIComponent(`#${unescapeHtml(tag)}`)}">#${tag}</a>`), |
| 153 |
); |
| 154 |
s = s |
| 155 |
.replace(/\*\*(?=\S)([^*]*?\S)\*\*/g, "<strong>$1</strong>") |
| 156 |
.replace(/__(?=\S)([^_]*?\S)__/g, "<strong>$1</strong>") |
| 157 |
.replace(/(^|[^*\w])\*(?=\S)([^*]*?\S)\*(?!\w)/g, "$1<em>$2</em>") |
| 158 |
.replace(/(^|[^_\w])_(?=\S)([^_]*?\S)_(?!\w)/g, "$1<em>$2</em>") |
| 159 |
.replace(/~~(?=\S)([^~]*?\S)~~/g, "<del>$1</del>") |
| 160 |
.replace(/==(?=\S)([^=]*?\S)==/g, "<mark>$1</mark>"); |
| 161 |
return s.replace(/\u0000(\d+)\u0000/g, (_, i) => held[Number(i)]); |
| 162 |
} |
| 163 |
|
| 164 |
|
| 165 |
|
| 166 |
|
| 167 |
|
| 168 |
|
| 169 |
|
| 170 |
|
| 171 |
|
| 172 |
const CALLOUTS = new Set(["note", "info", "tip", "success", "question", "warning", "danger", "bug", "example", "quote", "abstract", "todo", "failure"]); |
| 173 |
|
| 174 |
|
| 175 |
|
| 176 |
|
| 177 |
|
| 178 |
|
| 179 |
export function render(md, options = {}) { |
| 180 |
const o = { resolve: options.resolve ?? (() => null), base: options.base ?? "" }; |
| 181 |
const text = md.replace(/\r\n?/g, "\n"); |
| 182 |
const fm = frontmatter(text); |
| 183 |
if (fm === null) return blocks(text.split("\n"), o); |
| 184 |
const props = fm.props |
| 185 |
.map(([k, v]) => `<dt>${escapeHtml(k)}</dt><dd>${v.map((x) => inline(escapeHtml(x), o)).join(", ") || "—"}</dd>`) |
| 186 |
.join(""); |
| 187 |
return `${props === "" ? "" : `<dl class="properties">${props}</dl>\n`}${blocks(fm.body.split("\n"), o)}`; |
| 188 |
} |
| 189 |
|
| 190 |
|
| 191 |
function blocks(lines, o) { |
| 192 |
let out = ""; |
| 193 |
let i = 0; |
| 194 |
const para = []; |
| 195 |
const flush = () => { |
| 196 |
if (para.length > 0) out += `<p>${para.map((l) => inline(escapeHtml(l), o)).join("<br>")}</p>\n`; |
| 197 |
para.length = 0; |
| 198 |
}; |
| 199 |
while (i < lines.length) { |
| 200 |
const line = lines[i]; |
| 201 |
let m; |
| 202 |
if (/^\s*$/.test(line)) { |
| 203 |
flush(); |
| 204 |
i++; |
| 205 |
} else if ((m = /^(```|~~~)\s*([\w+-]*)/.exec(line))) { |
| 206 |
flush(); |
| 207 |
const fence = m[1]; |
| 208 |
const code = []; |
| 209 |
i++; |
| 210 |
while (i < lines.length && !lines[i].startsWith(fence)) code.push(lines[i++]); |
| 211 |
i++; |
| 212 |
out += `<pre><code${m[2] ? ` class="lang-${escapeHtml(m[2])}"` : ""}>${escapeHtml(code.join("\n"))}</code></pre>\n`; |
| 213 |
} else if ((m = /^(#{1,6})[ \t]+(.*?)[ \t]*#*[ \t]*$/.exec(line))) { |
| 214 |
flush(); |
| 215 |
const n = m[1].length; |
| 216 |
out += `<h${n} id="${escapeHtml(slug(m[2]))}">${inline(escapeHtml(m[2]), o)}</h${n}>\n`; |
| 217 |
i++; |
| 218 |
} else if (/^ {0,3}([-*_])(?:[ \t]*\1){2,}[ \t]*$/.test(line)) { |
| 219 |
flush(); |
| 220 |
out += "<hr>\n"; |
| 221 |
i++; |
| 222 |
} else if (/^ {0,3}>/.test(line)) { |
| 223 |
flush(); |
| 224 |
const quoted = []; |
| 225 |
while (i < lines.length && /^ {0,3}>/.test(lines[i])) quoted.push(lines[i++].replace(/^ {0,3}> ?/, "")); |
| 226 |
const callout = /^\[!(\w+)\]([+-]?)[ \t]*(.*)$/.exec(quoted[0] ?? ""); |
| 227 |
if (callout) { |
| 228 |
const kind = callout[1].toLowerCase(); |
| 229 |
const cls = CALLOUTS.has(kind) ? kind : "note"; |
| 230 |
const heading = callout[3] || kind.charAt(0).toUpperCase() + kind.slice(1); |
| 231 |
out += `<div class="callout callout-${cls}"><p class="callout-title">${inline(escapeHtml(heading), o)}</p>${blocks(quoted.slice(1), o).trimEnd()}</div>\n`; |
| 232 |
} else out += `<blockquote>${blocks(quoted, o).trimEnd()}</blockquote>\n`; |
| 233 |
} else if (/^\s*([-*+]|\d+[.)])\s/.test(line)) { |
| 234 |
flush(); |
| 235 |
const items = []; |
| 236 |
while (i < lines.length && (/^\s*([-*+]|\d+[.)])\s/.test(lines[i]) || (/^\s{2,}\S/.test(lines[i]) && items.length > 0))) items.push(lines[i++]); |
| 237 |
out += list(items, o); |
| 238 |
} else if (/^\s*\|.*\|\s*$/.test(line) && /^\s*\|?[\s:|-]*-[\s:|-]*\|?\s*$/.test(lines[i + 1] ?? "")) { |
| 239 |
flush(); |
| 240 |
const cells = (l) => l.trim().replace(/^\||\|$/g, "").split("|").map((c) => c.trim()); |
| 241 |
const head = cells(line); |
| 242 |
const align = cells(lines[i + 1]).map((c) => (c.startsWith(":") && c.endsWith(":") ? "center" : c.endsWith(":") ? "right" : "")); |
| 243 |
const style = (k) => (align[k] ? ` style="text-align:${align[k]}"` : ""); |
| 244 |
i += 2; |
| 245 |
let body = ""; |
| 246 |
while (i < lines.length && /^\s*\|.*\|\s*$/.test(lines[i])) { |
| 247 |
body += `<tr>${cells(lines[i++]).map((c, k) => `<td${style(k)}>${inline(escapeHtml(c), o)}</td>`).join("")}</tr>`; |
| 248 |
} |
| 249 |
out += `<div class="table"><table><thead><tr>${head.map((c, k) => `<th${style(k)}>${inline(escapeHtml(c), o)}</th>`).join("")}</tr></thead><tbody>${body}</tbody></table></div>\n`; |
| 250 |
} else { |
| 251 |
para.push(line); |
| 252 |
i++; |
| 253 |
} |
| 254 |
} |
| 255 |
flush(); |
| 256 |
return out; |
| 257 |
} |
| 258 |
|
| 259 |
|
| 260 |
function list(lines, o) { |
| 261 |
const indent = (l) => l.match(/^\s*/)[0].replace(/\t/g, " ").length; |
| 262 |
const base = indent(lines[0]); |
| 263 |
const ordered = /^\s*\d+[.)]\s/.test(lines[0]); |
| 264 |
let html = ""; |
| 265 |
let i = 0; |
| 266 |
while (i < lines.length) { |
| 267 |
const m = /^\s*(?:[-*+]|\d+[.)])\s+(.*)$/.exec(lines[i]); |
| 268 |
const text = m ? m[1] : lines[i].trim(); |
| 269 |
i++; |
| 270 |
const child = []; |
| 271 |
while (i < lines.length && indent(lines[i]) > base) child.push(lines[i++]); |
| 272 |
const task = /^\[([ xX])\]\s+(.*)$/.exec(text); |
| 273 |
const body = task |
| 274 |
? `<button type="button" class="check" role="checkbox" aria-checked="${task[1] !== " "}" aria-label="Done"></button><span${task[1] !== " " ? ' class="done"' : ""}>${inline(escapeHtml(task[2]), o)}</span>` |
| 275 |
: inline(escapeHtml(text), o); |
| 276 |
html += `<li${task ? ' class="task"' : ""}>${body}${child.length > 0 ? list(child, o).trimEnd() : ""}</li>`; |
| 277 |
} |
| 278 |
return ordered ? `<ol>${html}</ol>\n` : `<ul>${html}</ul>\n`; |
| 279 |
} |