115 lines
3.1 KiB
JavaScript
115 lines
3.1 KiB
JavaScript
function escapeHtml(value) {
|
|
const map = {
|
|
"&": "&",
|
|
"<": "<",
|
|
">": ">",
|
|
'"': """,
|
|
"'": "'"
|
|
};
|
|
return String(value || "").replace(/[&<>"']/g, (char) => map[char]);
|
|
}
|
|
|
|
function safeUrl(value) {
|
|
const raw = String(value || "").trim();
|
|
if (!raw) return "";
|
|
if (/^(https?:\/\/|\/(?!\/)|#)/i.test(raw)) return escapeHtml(raw);
|
|
return "";
|
|
}
|
|
|
|
function renderMarkdownInline(value) {
|
|
let output = escapeHtml(value);
|
|
output = output.replace(/`([^`]+)`/g, "<code>$1</code>");
|
|
output = output.replace(/\[([^\]]+)\]\(([^)]+)\)/g, (_match, text, url) => {
|
|
const href = safeUrl(url);
|
|
return href ? `<a href="${href}" target="_blank" rel="noopener noreferrer">${escapeHtml(text)}</a>` : escapeHtml(text);
|
|
});
|
|
output = output.replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>");
|
|
output = output.replace(/\*([^*]+)\*/g, "<em>$1</em>");
|
|
return output;
|
|
}
|
|
|
|
function renderMarkdown(value) {
|
|
const lines = String(value || "").replace(/\r\n?/g, "\n").split("\n");
|
|
let html = "";
|
|
let paragraph = [];
|
|
let listType = null;
|
|
let inCode = false;
|
|
let codeLang = "";
|
|
let codeLines = [];
|
|
|
|
const flushParagraph = () => {
|
|
if (!paragraph.length) return;
|
|
html += `<p>${renderMarkdownInline(paragraph.join(" "))}</p>`;
|
|
paragraph = [];
|
|
};
|
|
const closeList = () => {
|
|
if (!listType) return;
|
|
html += `</${listType}>`;
|
|
listType = null;
|
|
};
|
|
|
|
for (const line of lines) {
|
|
const trimmed = line.trim();
|
|
if (inCode) {
|
|
if (trimmed.startsWith("```")) {
|
|
const langClass = codeLang ? ` class="language-${escapeHtml(codeLang)}"` : "";
|
|
html += `<pre><code${langClass}>${escapeHtml(codeLines.join("\n"))}</code></pre>`;
|
|
inCode = false;
|
|
codeLang = "";
|
|
codeLines = [];
|
|
continue;
|
|
}
|
|
codeLines.push(line);
|
|
continue;
|
|
}
|
|
if (trimmed.startsWith("```")) {
|
|
flushParagraph();
|
|
closeList();
|
|
inCode = true;
|
|
codeLang = trimmed.slice(3).trim().replace(/[^a-z0-9_-]/gi, "").slice(0, 32);
|
|
continue;
|
|
}
|
|
if (!trimmed) {
|
|
flushParagraph();
|
|
closeList();
|
|
continue;
|
|
}
|
|
const heading = trimmed.match(/^(#{1,6})\s+(.*)$/);
|
|
if (heading) {
|
|
flushParagraph();
|
|
closeList();
|
|
const level = heading[1].length;
|
|
html += `<h${level}>${renderMarkdownInline(heading[2])}</h${level}>`;
|
|
continue;
|
|
}
|
|
const list = trimmed.match(/^([*-]|\d+\.)\s+(.*)$/);
|
|
if (list) {
|
|
flushParagraph();
|
|
const nextType = list[1].endsWith(".") ? "ol" : "ul";
|
|
if (listType && listType !== nextType) closeList();
|
|
if (!listType) {
|
|
listType = nextType;
|
|
html += `<${listType}>`;
|
|
}
|
|
html += `<li>${renderMarkdownInline(list[2])}</li>`;
|
|
continue;
|
|
}
|
|
paragraph.push(trimmed);
|
|
}
|
|
|
|
if (inCode) {
|
|
const langClass = codeLang ? ` class="language-${escapeHtml(codeLang)}"` : "";
|
|
html += `<pre><code${langClass}>${escapeHtml(codeLines.join("\n"))}</code></pre>`;
|
|
}
|
|
flushParagraph();
|
|
closeList();
|
|
return html || "<p></p>";
|
|
}
|
|
|
|
module.exports = {
|
|
escapeHtml,
|
|
renderMarkdown,
|
|
renderMarkdownInline,
|
|
safeUrl
|
|
};
|