| 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 |
export function tags(md) { |
| 54 |
const out = new Set(); |
| 55 |
for (const m of prose(md).matchAll(TAG)) out.add(m[2].toLowerCase()); |
| 56 |
return [...out]; |
| 57 |
} |
| 58 |
|
| 59 |
|
| 60 |
export function headings(md) { |
| 61 |
const out = []; |
| 62 |
for (const m of prose(md).matchAll(/^(#{1,6})[ \t]+(.*?)[ \t]*#*[ \t]*$/gm)) { |
| 63 |
const level = m[1].length; |
| 64 |
const text = m[2].trim(); |
| 65 |
if (text === "") continue; |
| 66 |
out.push({ level, text, id: slug(text) }); |
| 67 |
} |
| 68 |
return out; |
| 69 |
} |
| 70 |
|
| 71 |
const SAFE_URL = /^(?:https?:\/\/|mailto:|\/(?!\/)|#)/i; |
| 72 |
|
| 73 |
|
| 74 |
|
| 75 |
|
| 76 |
|
| 77 |
|
| 78 |
function inline(text, o) { |
| 79 |
|
| 80 |
const parts = text.split(/(`+)([^`]|[^`][\s\S]*?[^`])\1(?!`)/); |
| 81 |
let out = ""; |
| 82 |
for (let i = 0; i < parts.length; i++) { |
| 83 |
if (i % 3 === 0) out += marks(parts[i], o); |
| 84 |
else if (i % 3 === 2) out += `<code>${parts[i]}</code>`; |
| 85 |
} |
| 86 |
return out; |
| 87 |
} |
| 88 |
|
| 89 |
|
| 90 |
function marks(s, o) { |
| 91 |
const held = []; |
| 92 |
const hold = (html) => `\u0000${held.push(html) - 1}\u0000`; |
| 93 |
s = s |
| 94 |
.replace(WIKI, (_, embed, target, alias) => { |
| 95 |
const [title, heading] = unescapeHtml(target).split("#"); |
| 96 |
const name = title.trim(); |
| 97 |
const shown = alias ?? target; |
| 98 |
const href = o.resolve(name); |
| 99 |
const anchor = heading ? `#${slug(heading)}` : ""; |
| 100 |
return hold( |
| 101 |
href === null |
| 102 |
? `<a class="wikilink unresolved" href="/open?title=${encodeURIComponent(name)}" data-title="${escapeHtml(name)}">${shown}</a>` |
| 103 |
: `<a class="wikilink${embed ? " embed" : ""}" href="${escapeHtml(href)}${anchor}" data-title="${escapeHtml(name)}">${shown}</a>`, |
| 104 |
); |
| 105 |
}) |
| 106 |
.replace(/\[([^\]\n]+)\]\(([^)\s]+)\)/g, (whole, label, url) => |
| 107 |
SAFE_URL.test(unescapeHtml(url)) |
| 108 |
? hold(`<a href="${url}"${/^https?:/i.test(url) ? ' rel="noopener noreferrer" target="_blank"' : ""}>${label}</a>`) |
| 109 |
: whole, |
| 110 |
) |
| 111 |
.replace(/(^|[\s(])(https?:\/\/(?:(?!&(?:quot|#39|lt|gt);)[^\s<])*[^\s<.,;:!?)'"&])/g, (_, lead, url) => |
| 112 |
lead + hold(`<a href="${url}" rel="noopener noreferrer" target="_blank">${url}</a>`), |
| 113 |
) |
| 114 |
.replace(TAG, (_, lead, tag) => |
| 115 |
lead + hold(`<a class="tag" href="/search?q=${encodeURIComponent(`#${unescapeHtml(tag)}`)}">#${tag}</a>`), |
| 116 |
); |
| 117 |
s = s |
| 118 |
.replace(/\*\*(?=\S)([^*]*?\S)\*\*/g, "<strong>$1</strong>") |
| 119 |
.replace(/__(?=\S)([^_]*?\S)__/g, "<strong>$1</strong>") |
| 120 |
.replace(/(^|[^*\w])\*(?=\S)([^*]*?\S)\*(?!\w)/g, "$1<em>$2</em>") |
| 121 |
.replace(/(^|[^_\w])_(?=\S)([^_]*?\S)_(?!\w)/g, "$1<em>$2</em>") |
| 122 |
.replace(/~~(?=\S)([^~]*?\S)~~/g, "<del>$1</del>") |
| 123 |
.replace(/==(?=\S)([^=]*?\S)==/g, "<mark>$1</mark>"); |
| 124 |
return s.replace(/\u0000(\d+)\u0000/g, (_, i) => held[Number(i)]); |
| 125 |
} |
| 126 |
|
| 127 |
|
| 128 |
|
| 129 |
|
| 130 |
|
| 131 |
|
| 132 |
|
| 133 |
const CALLOUTS = new Set(["note", "info", "tip", "success", "question", "warning", "danger", "bug", "example", "quote", "abstract", "todo", "failure"]); |
| 134 |
|
| 135 |
|
| 136 |
|
| 137 |
|
| 138 |
|
| 139 |
|
| 140 |
export function render(md, options = {}) { |
| 141 |
const o = { resolve: options.resolve ?? (() => null) }; |
| 142 |
return blocks(md.replace(/\r\n?/g, "\n").split("\n"), o); |
| 143 |
} |
| 144 |
|
| 145 |
|
| 146 |
function blocks(lines, o) { |
| 147 |
let out = ""; |
| 148 |
let i = 0; |
| 149 |
const para = []; |
| 150 |
const flush = () => { |
| 151 |
if (para.length > 0) out += `<p>${para.map((l) => inline(escapeHtml(l), o)).join("<br>")}</p>\n`; |
| 152 |
para.length = 0; |
| 153 |
}; |
| 154 |
while (i < lines.length) { |
| 155 |
const line = lines[i]; |
| 156 |
let m; |
| 157 |
if (/^\s*$/.test(line)) { |
| 158 |
flush(); |
| 159 |
i++; |
| 160 |
} else if ((m = /^(```|~~~)\s*([\w+-]*)/.exec(line))) { |
| 161 |
flush(); |
| 162 |
const fence = m[1]; |
| 163 |
const code = []; |
| 164 |
i++; |
| 165 |
while (i < lines.length && !lines[i].startsWith(fence)) code.push(lines[i++]); |
| 166 |
i++; |
| 167 |
out += `<pre><code${m[2] ? ` class="lang-${escapeHtml(m[2])}"` : ""}>${escapeHtml(code.join("\n"))}</code></pre>\n`; |
| 168 |
} else if ((m = /^(#{1,6})[ \t]+(.*?)[ \t]*#*[ \t]*$/.exec(line))) { |
| 169 |
flush(); |
| 170 |
const n = m[1].length; |
| 171 |
out += `<h${n} id="${escapeHtml(slug(m[2]))}">${inline(escapeHtml(m[2]), o)}</h${n}>\n`; |
| 172 |
i++; |
| 173 |
} else if (/^ {0,3}([-*_])(?:[ \t]*\1){2,}[ \t]*$/.test(line)) { |
| 174 |
flush(); |
| 175 |
out += "<hr>\n"; |
| 176 |
i++; |
| 177 |
} else if (/^ {0,3}>/.test(line)) { |
| 178 |
flush(); |
| 179 |
const quoted = []; |
| 180 |
while (i < lines.length && /^ {0,3}>/.test(lines[i])) quoted.push(lines[i++].replace(/^ {0,3}> ?/, "")); |
| 181 |
const callout = /^\[!(\w+)\]([+-]?)[ \t]*(.*)$/.exec(quoted[0] ?? ""); |
| 182 |
if (callout) { |
| 183 |
const kind = callout[1].toLowerCase(); |
| 184 |
const cls = CALLOUTS.has(kind) ? kind : "note"; |
| 185 |
const heading = callout[3] || kind.charAt(0).toUpperCase() + kind.slice(1); |
| 186 |
out += `<div class="callout callout-${cls}"><p class="callout-title">${inline(escapeHtml(heading), o)}</p>${blocks(quoted.slice(1), o).trimEnd()}</div>\n`; |
| 187 |
} else out += `<blockquote>${blocks(quoted, o).trimEnd()}</blockquote>\n`; |
| 188 |
} else if (/^\s*([-*+]|\d+[.)])\s/.test(line)) { |
| 189 |
flush(); |
| 190 |
const items = []; |
| 191 |
while (i < lines.length && (/^\s*([-*+]|\d+[.)])\s/.test(lines[i]) || (/^\s{2,}\S/.test(lines[i]) && items.length > 0))) items.push(lines[i++]); |
| 192 |
out += list(items, o); |
| 193 |
} else if (/^\s*\|.*\|\s*$/.test(line) && /^\s*\|?[\s:|-]*-[\s:|-]*\|?\s*$/.test(lines[i + 1] ?? "")) { |
| 194 |
flush(); |
| 195 |
const cells = (l) => l.trim().replace(/^\||\|$/g, "").split("|").map((c) => c.trim()); |
| 196 |
const head = cells(line); |
| 197 |
const align = cells(lines[i + 1]).map((c) => (c.startsWith(":") && c.endsWith(":") ? "center" : c.endsWith(":") ? "right" : "")); |
| 198 |
const style = (k) => (align[k] ? ` style="text-align:${align[k]}"` : ""); |
| 199 |
i += 2; |
| 200 |
let body = ""; |
| 201 |
while (i < lines.length && /^\s*\|.*\|\s*$/.test(lines[i])) { |
| 202 |
body += `<tr>${cells(lines[i++]).map((c, k) => `<td${style(k)}>${inline(escapeHtml(c), o)}</td>`).join("")}</tr>`; |
| 203 |
} |
| 204 |
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`; |
| 205 |
} else { |
| 206 |
para.push(line); |
| 207 |
i++; |
| 208 |
} |
| 209 |
} |
| 210 |
flush(); |
| 211 |
return out; |
| 212 |
} |
| 213 |
|
| 214 |
|
| 215 |
function list(lines, o) { |
| 216 |
const indent = (l) => l.match(/^\s*/)[0].replace(/\t/g, " ").length; |
| 217 |
const base = indent(lines[0]); |
| 218 |
const ordered = /^\s*\d+[.)]\s/.test(lines[0]); |
| 219 |
let html = ""; |
| 220 |
let i = 0; |
| 221 |
while (i < lines.length) { |
| 222 |
const m = /^\s*(?:[-*+]|\d+[.)])\s+(.*)$/.exec(lines[i]); |
| 223 |
const text = m ? m[1] : lines[i].trim(); |
| 224 |
i++; |
| 225 |
const child = []; |
| 226 |
while (i < lines.length && indent(lines[i]) > base) child.push(lines[i++]); |
| 227 |
const task = /^\[([ xX])\]\s+(.*)$/.exec(text); |
| 228 |
const body = task |
| 229 |
? `<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>` |
| 230 |
: inline(escapeHtml(text), o); |
| 231 |
html += `<li${task ? ' class="task"' : ""}>${body}${child.length > 0 ? list(child, o).trimEnd() : ""}</li>`; |
| 232 |
} |
| 233 |
return ordered ? `<ol>${html}</ol>\n` : `<ul>${html}</ul>\n`; |
| 234 |
} |