new functions
This commit is contained in:
+156
-23
@@ -4,48 +4,181 @@ function escapeHtml(value) {
|
||||
|
||||
function safeUrl(value) {
|
||||
const url = String(value).trim();
|
||||
if (/^(https?:\/\/|\/|\.\/|\.\.\/|#)/i.test(url)) return escapeHtml(url);
|
||||
if (/^(https?:\/\/|mailto:|\/|\.\/|\.\.\/|#)/i.test(url)) return escapeHtml(url);
|
||||
return "#";
|
||||
}
|
||||
|
||||
const emoji = {
|
||||
smile:"😄", joy:"😂", heart:"❤️", thumbs_up:"👍", thumbsup:"👍", thumbs_down:"👎",
|
||||
fire:"🔥", rocket:"🚀", tada:"🎉", warning:"⚠️", white_check_mark:"✅", x:"❌",
|
||||
eyes:"👀", bulb:"💡", memo:"📝", pushpin:"📌", bug:"🐛", sparkles:"✨",
|
||||
thinking:"🤔", clap:"👏", ok_hand:"👌", pray:"🙏", muscle:"💪", coffee:"☕",
|
||||
tent:"⛺", star:"⭐", checkered_flag:"🏁"
|
||||
};
|
||||
|
||||
function inline(value) {
|
||||
const tokens = [];
|
||||
let html = escapeHtml(value);
|
||||
html = html.replace(/!\[([^\]]*)\]\(([^\s)]+)(?:\s+["']([^"']*)["'])?\)/g, (_, alt, url, title) => {
|
||||
const token = `\u0000IMG${tokens.length}\u0000`;
|
||||
const titleAttr = title ? ` title="${escapeHtml(title)}"` : "";
|
||||
tokens.push(`<img src="${safeUrl(url)}" alt="${alt}" loading="lazy" decoding="async"${titleAttr}>`);
|
||||
const stash = html => {
|
||||
const token = `\u0000T${tokens.length}\u0000`;
|
||||
tokens.push(html);
|
||||
return token;
|
||||
};
|
||||
let html = escapeHtml(value);
|
||||
|
||||
html = html.replace(/`([^`]+)`/g, (_, code) => stash(`<code>${code}</code>`));
|
||||
html = html.replace(/!\[([^\]]*)\]\(([^\s)]+)(?:\s+["']([^"']*)["'])?\)/g, (_, alt, url, title) => {
|
||||
const titleAttr = title ? ` title="${escapeHtml(title)}"` : "";
|
||||
return stash(`<img src="${safeUrl(url)}" alt="${alt}" loading="lazy" decoding="async"${titleAttr}>`);
|
||||
});
|
||||
html = html.replace(/\[([^\]]+)\]\(([^\s)]+)(?:\s+["']([^"']*)["'])?\)/g, (_, label, url, title) => {
|
||||
const titleAttr = title ? ` title="${escapeHtml(title)}"` : "";
|
||||
return stash(`<a href="${safeUrl(url)}" target="_blank" rel="noopener noreferrer"${titleAttr}>${label}</a>`);
|
||||
});
|
||||
html = html.replace(/\[\^([^\]\s]+)\]/g, (_, id) => stash(`<sup class="footnote-ref"><a href="#fn-${escapeHtml(id)}" id="fnref-${escapeHtml(id)}">?</a></sup>`));
|
||||
|
||||
html = html
|
||||
.replace(/`([^`]+)`/g, "<code>$1</code>")
|
||||
.replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>")
|
||||
.replace(/~~([^~]+)~~/g, "<s>$1</s>")
|
||||
.replace(/==([^=]+)==/g, "<mark>$1</mark>")
|
||||
.replace(/\*([^*]+)\*/g, "<em>$1</em>")
|
||||
.replace(/\[([^\]]+)\]\(([^\s)]+)(?:\s+["']([^"']*)["'])?\)/g, (_, label, url, title) => {
|
||||
const titleAttr = title ? ` title="${escapeHtml(title)}"` : "";
|
||||
return `<a href="${safeUrl(url)}" target="_blank" rel="noopener noreferrer"${titleAttr}>${label}</a>`;
|
||||
});
|
||||
return html.replace(/\u0000IMG(\d+)\u0000/g, (_, index) => tokens[Number(index)] || "");
|
||||
.replace(/(?<!~)~([^~\n]+)~(?!~)/g, "<sub>$1</sub>")
|
||||
.replace(/\^([^^\n]+)\^/g, "<sup>$1</sup>")
|
||||
.replace(/:([a-z0-9_+-]+):/gi, (match, name) => emoji[name] || match);
|
||||
|
||||
html = html.replace(/(^|[\s(])((?:https?:\/\/|mailto:)[^\s<]+)/gi, (match, prefix, url) => {
|
||||
const clean = url.replace(/[.,!?;:]+$/, "");
|
||||
const suffix = url.slice(clean.length);
|
||||
return `${prefix}${stash(`<a href="${safeUrl(clean)}" target="_blank" rel="noopener noreferrer">${clean}</a>`)}${suffix}`;
|
||||
});
|
||||
|
||||
return html.replace(/\u0000T(\d+)\u0000/g, (_, index) => tokens[Number(index)] || "");
|
||||
}
|
||||
|
||||
const attrs = (line, editable = false, prefix = "", suffix = "") => ` class="preview-source-line${editable ? " preview-editable" : ""}" data-source-line="${line + 1}"${editable ? ` contenteditable="true" spellcheck="true" data-source-prefix="${escapeHtml(prefix)}" data-source-suffix="${escapeHtml(suffix)}"` : ""}`;
|
||||
const isPlainText = line => !/[`*_~^=\[\]<>|:#]/.test(line) && !/^\s*(?:[-+*>]|\d+\.)\s/.test(line);
|
||||
|
||||
function splitTableRow(line) {
|
||||
let value = line.trim();
|
||||
if (value.startsWith("|")) value = value.slice(1);
|
||||
if (value.endsWith("|")) value = value.slice(0, -1);
|
||||
return value.split("|").map(cell => cell.trim().replace(/|/g, "|"));
|
||||
}
|
||||
|
||||
function tableDelimiter(line) {
|
||||
const cells = splitTableRow(line);
|
||||
if (!cells.length || !cells.every(cell => /^:?-{3,}:?$/.test(cell))) return null;
|
||||
return cells.map(cell => cell.startsWith(":") && cell.endsWith(":") ? "center" : cell.endsWith(":") ? "right" : "left");
|
||||
}
|
||||
|
||||
export function renderMarkdown(source) {
|
||||
let html = "", inCode = false, language = "", code = [], list = null;
|
||||
let html = "", inCode = false, fence = "", language = "", code = [], codeStart = 0, list = null;
|
||||
const lines = String(source).split("\n");
|
||||
const footnotes = new Map();
|
||||
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const match = lines[i].match(/^\[\^([^\]\s]+)\]:\s*(.*)$/);
|
||||
if (!match) continue;
|
||||
const body = [match[2]];
|
||||
let j = i + 1;
|
||||
while (j < lines.length && /^(?: {4}|\t)/.test(lines[j])) {
|
||||
body.push(lines[j].replace(/^(?: {4}|\t)/, ""));
|
||||
lines[j] = "";
|
||||
j++;
|
||||
}
|
||||
footnotes.set(match[1], body.join("\n"));
|
||||
lines[i] = "";
|
||||
}
|
||||
|
||||
const closeList = () => { if (list) { html += `</${list}>`; list = null; } };
|
||||
const closeCode = () => {
|
||||
const body = escapeHtml(code.join("\n"));
|
||||
html += language.toLowerCase() === "mermaid"
|
||||
? `<div class="mermaid">${body}</div>`
|
||||
: `<pre><code class="language-${escapeHtml(language)}">${body}</code></pre>`;
|
||||
code = []; language = "";
|
||||
? `<div class="mermaid preview-source-line" data-source-line="${codeStart + 1}">${body}</div>`
|
||||
: `<pre${attrs(codeStart)}><code class="language-${escapeHtml(language)}">${body}</code></pre>`;
|
||||
code = []; language = ""; fence = "";
|
||||
};
|
||||
for (const line of String(source).split("\n")) {
|
||||
if (line.startsWith("```")) { closeList(); if (inCode) closeCode(); else language = line.slice(3).trim(); inCode = !inCode; continue; }
|
||||
|
||||
for (let index = 0; index < lines.length; index++) {
|
||||
const line = lines[index];
|
||||
const fenceMatch = line.match(/^(```+|~~~+)\s*([^\s]*)\s*$/);
|
||||
if (fenceMatch) {
|
||||
closeList();
|
||||
if (inCode && fenceMatch[1][0] === fence[0] && fenceMatch[1].length >= fence.length) closeCode();
|
||||
else if (!inCode) { fence = fenceMatch[1]; language = fenceMatch[2] || ""; codeStart = index; }
|
||||
inCode = !inCode;
|
||||
continue;
|
||||
}
|
||||
if (inCode) { code.push(line); continue; }
|
||||
const heading = line.match(/^(#{1,6})\s+(.+)$/), ul = line.match(/^\s*[-*+]\s+(.+)$/), ol = line.match(/^\s*\d+\.\s+(.+)$/);
|
||||
if (heading) { closeList(); const n = heading[1].length; html += `<h${n}>${inline(heading[2])}</h${n}>`; }
|
||||
else if (ul || ol) { const type = ul ? "ul" : "ol"; if (list !== type) { closeList(); html += `<${type}>`; list = type; } html += `<li>${inline((ul || ol)[1])}</li>`; }
|
||||
else { closeList(); if (/^---+$/.test(line)) html += "<hr>"; else if (line.startsWith("> ")) html += `<blockquote>${inline(line.slice(2))}</blockquote>`; else if (line.trim()) html += `<p>${inline(line)}</p>`; else html += "<br>"; }
|
||||
|
||||
const delimiter = index + 1 < lines.length ? tableDelimiter(lines[index + 1]) : null;
|
||||
if (line.includes("|") && delimiter) {
|
||||
closeList();
|
||||
const headers = splitTableRow(line);
|
||||
html += `<div class="table-wrap preview-source-line" data-source-line="${index + 1}"><table><thead><tr>`;
|
||||
headers.forEach((cell, i) => html += `<th style="text-align:${delimiter[i] || "left"}">${inline(cell)}</th>`);
|
||||
html += `</tr></thead><tbody>`;
|
||||
index += 2;
|
||||
while (index < lines.length && lines[index].includes("|") && lines[index].trim()) {
|
||||
const cells = splitTableRow(lines[index]);
|
||||
html += `<tr>`;
|
||||
headers.forEach((_, i) => html += `<td style="text-align:${delimiter[i] || "left"}">${inline(cells[i] || "")}</td>`);
|
||||
html += `</tr>`;
|
||||
index++;
|
||||
}
|
||||
html += `</tbody></table></div>`;
|
||||
index--;
|
||||
continue;
|
||||
}
|
||||
|
||||
const heading = line.match(/^(#{1,6})\s+(.+?)(?:\s+\{#([A-Za-z][\w:.-]*)\})?\s*$/);
|
||||
const task = line.match(/^(\s*)[-*+]\s+\[([ xX])\]\s+(.+)$/);
|
||||
const ul = line.match(/^\s*[-*+]\s+(.+)$/);
|
||||
const ol = line.match(/^\s*\d+\.\s+(.+)$/);
|
||||
|
||||
if (heading) {
|
||||
closeList();
|
||||
const n = heading[1].length;
|
||||
const id = heading[3] ? ` id="${escapeHtml(heading[3])}"` : "";
|
||||
const suffix = heading[3] ? ` {#${heading[3]}}` : "";
|
||||
html += `<h${n}${id}${attrs(index, true, `${heading[1]} `, suffix)}>${inline(heading[2])}</h${n}>`;
|
||||
} else if (task) {
|
||||
if (list !== "ul") { closeList(); html += `<ul class="task-list">`; list = "ul"; }
|
||||
const checked = task[2].toLowerCase() === "x";
|
||||
html += `<li class="preview-source-line task-list-item" data-source-line="${index + 1}"><input type="checkbox" class="task-checkbox" data-source-line="${index + 1}"${checked ? " checked" : ""}><span>${inline(task[3])}</span></li>`;
|
||||
} else if (ul || ol) {
|
||||
const type = ul ? "ul" : "ol";
|
||||
if (list !== type) { closeList(); html += `<${type}>`; list = type; }
|
||||
html += `<li${attrs(index)}>${inline((ul || ol)[1])}</li>`;
|
||||
} else {
|
||||
closeList();
|
||||
const definition = index + 1 < lines.length && /^:\s+/.test(lines[index + 1]);
|
||||
if (line.trim() && definition) {
|
||||
html += `<dl${attrs(index)}><dt>${inline(line)}</dt>`;
|
||||
while (index + 1 < lines.length && /^:\s+/.test(lines[index + 1])) {
|
||||
index++;
|
||||
html += `<dd data-source-line="${index + 1}">${inline(lines[index].replace(/^:\s+/, ""))}</dd>`;
|
||||
}
|
||||
html += `</dl>`;
|
||||
} else if (/^---+$/.test(line.trim())) html += `<hr${attrs(index)}>`;
|
||||
else if (line.startsWith("> ")) html += `<blockquote${attrs(index)}>${inline(line.slice(2))}</blockquote>`;
|
||||
else if (line.trim()) html += `<p${attrs(index, isPlainText(line))}>${inline(line)}</p>`;
|
||||
else html += `<div${attrs(index, true)}><br></div>`;
|
||||
}
|
||||
}
|
||||
closeList(); if (inCode) closeCode(); return html;
|
||||
|
||||
closeList();
|
||||
if (inCode) closeCode();
|
||||
|
||||
if (footnotes.size) {
|
||||
html = html.replace(/<sup class="footnote-ref"><a href="#fn-([^"]+)" id="fnref-\1">\?<\/a><\/sup>/g, (_, id) => {
|
||||
const order = [...footnotes.keys()].indexOf(id) + 1;
|
||||
return `<sup class="footnote-ref"><a href="#fn-${id}" id="fnref-${id}">${order || "?"}</a></sup>`;
|
||||
});
|
||||
html += `<section class="footnotes"><hr><ol>`;
|
||||
for (const [id, body] of footnotes) {
|
||||
html += `<li id="fn-${escapeHtml(id)}">${body.split("\n").map(part => inline(part)).join("<br>")} <a class="footnote-backref" href="#fnref-${escapeHtml(id)}" aria-label="Back to reference">↩</a></li>`;
|
||||
}
|
||||
html += `</ol></section>`;
|
||||
}
|
||||
return html;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user