200 lines
4.9 KiB
JavaScript
200 lines
4.9 KiB
JavaScript
/** Optional Markdown display — escaped first, then a small safe subset. */
|
|
|
|
const STORAGE_KEY = "wa-markdown-render";
|
|
|
|
export function getStoredMarkdownRender() {
|
|
try {
|
|
return localStorage.getItem(STORAGE_KEY) === "1";
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
export function isMarkdownRenderEnabled() {
|
|
return document.documentElement.getAttribute("data-markdown-render") === "1";
|
|
}
|
|
|
|
export function applyMarkdownRender(enabled) {
|
|
const on = Boolean(enabled);
|
|
document.documentElement.setAttribute("data-markdown-render", on ? "1" : "0");
|
|
try {
|
|
localStorage.setItem(STORAGE_KEY, on ? "1" : "0");
|
|
} catch {
|
|
/* ignore */
|
|
}
|
|
return on;
|
|
}
|
|
|
|
export function initMarkdownRender() {
|
|
return applyMarkdownRender(getStoredMarkdownRender());
|
|
}
|
|
|
|
function esc(s) {
|
|
return String(s)
|
|
.replace(/&/g, "&")
|
|
.replace(/</g, "<")
|
|
.replace(/>/g, ">");
|
|
}
|
|
|
|
/** Safe subset: fences, inline code, headings, emphasis, links, lists, quotes, hr, breaks. */
|
|
export function renderMarkdownToHtml(raw) {
|
|
const text = String(raw ?? "");
|
|
if (!text) return "";
|
|
|
|
const fences = [];
|
|
let src = text.replace(/```([a-zA-Z0-9_-]*)\n?([\s\S]*?)```/g, (_, lang, code) => {
|
|
const i = fences.length;
|
|
const langClass = lang ? ` class="language-${esc(lang)}"` : "";
|
|
fences.push(
|
|
`<pre class="md-pre"><code${langClass}>${esc(code.replace(/\n$/, ""))}</code></pre>`,
|
|
);
|
|
return `\u0000FENCE${i}\u0000`;
|
|
});
|
|
|
|
const lines = src.split(/\n/);
|
|
const out = [];
|
|
let i = 0;
|
|
let para = [];
|
|
let listType = null; // "ul" | "ol"
|
|
let listItems = [];
|
|
|
|
const flushPara = () => {
|
|
if (!para.length) return;
|
|
const body = para.map(inlineMarkdown).join("<br>");
|
|
out.push(`<p class="md-p">${body}</p>`);
|
|
para = [];
|
|
};
|
|
|
|
const flushList = () => {
|
|
if (!listType || !listItems.length) {
|
|
listType = null;
|
|
listItems = [];
|
|
return;
|
|
}
|
|
const tag = listType;
|
|
out.push(
|
|
`<${tag} class="md-${tag}">${listItems
|
|
.map((item) => `<li>${inlineMarkdown(item)}</li>`)
|
|
.join("")}</${tag}>`,
|
|
);
|
|
listType = null;
|
|
listItems = [];
|
|
};
|
|
|
|
while (i < lines.length) {
|
|
const line = lines[i];
|
|
const fenceMatch = line.match(/^\u0000FENCE(\d+)\u0000$/);
|
|
if (fenceMatch) {
|
|
flushPara();
|
|
flushList();
|
|
out.push(fences[Number(fenceMatch[1])] ?? "");
|
|
i += 1;
|
|
continue;
|
|
}
|
|
|
|
if (/^\s*([-*_])\1{2,}\s*$/.test(line)) {
|
|
flushPara();
|
|
flushList();
|
|
out.push('<hr class="md-hr">');
|
|
i += 1;
|
|
continue;
|
|
}
|
|
|
|
const heading = line.match(/^(#{1,6})\s+(.+)$/);
|
|
if (heading) {
|
|
flushPara();
|
|
flushList();
|
|
const level = heading[1].length;
|
|
out.push(`<h${level} class="md-h">${inlineMarkdown(heading[2])}</h${level}>`);
|
|
i += 1;
|
|
continue;
|
|
}
|
|
|
|
const bq = line.match(/^>\s?(.*)$/);
|
|
if (bq) {
|
|
flushPara();
|
|
flushList();
|
|
const quoteLines = [];
|
|
while (i < lines.length) {
|
|
const m = lines[i].match(/^>\s?(.*)$/);
|
|
if (!m) break;
|
|
quoteLines.push(m[1]);
|
|
i += 1;
|
|
}
|
|
out.push(
|
|
`<blockquote class="md-quote">${quoteLines
|
|
.map((q) => `<p class="md-p">${inlineMarkdown(q)}</p>`)
|
|
.join("")}</blockquote>`,
|
|
);
|
|
continue;
|
|
}
|
|
|
|
const ul = line.match(/^\s*[-*+]\s+(.+)$/);
|
|
if (ul) {
|
|
flushPara();
|
|
if (listType && listType !== "ul") flushList();
|
|
listType = "ul";
|
|
listItems.push(ul[1]);
|
|
i += 1;
|
|
continue;
|
|
}
|
|
|
|
const ol = line.match(/^\s*\d+\.\s+(.+)$/);
|
|
if (ol) {
|
|
flushPara();
|
|
if (listType && listType !== "ol") flushList();
|
|
listType = "ol";
|
|
listItems.push(ol[1]);
|
|
i += 1;
|
|
continue;
|
|
}
|
|
|
|
if (!line.trim()) {
|
|
flushPara();
|
|
flushList();
|
|
i += 1;
|
|
continue;
|
|
}
|
|
|
|
flushList();
|
|
para.push(line);
|
|
i += 1;
|
|
}
|
|
|
|
flushPara();
|
|
flushList();
|
|
return out.join("") || esc(text).replace(/\n/g, "<br>");
|
|
}
|
|
|
|
function inlineMarkdown(line) {
|
|
let s = esc(line);
|
|
|
|
const codes = [];
|
|
s = s.replace(/`([^`]+)`/g, (_, code) => {
|
|
const i = codes.length;
|
|
codes.push(`<code class="md-code">${code}</code>`);
|
|
return `\u0000CODE${i}\u0000`;
|
|
});
|
|
|
|
s = s.replace(
|
|
/\[([^\]]+)\]\((https?:\/\/[^)\s]+)\)/g,
|
|
(_, label, url) =>
|
|
`<a class="md-a" href="${esc(url)}" target="_blank" rel="noopener noreferrer">${label}</a>`,
|
|
);
|
|
|
|
s = s.replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>");
|
|
s = s.replace(/__([^_]+)__/g, "<strong>$1</strong>");
|
|
s = s.replace(/\*([^*]+)\*/g, "<em>$1</em>");
|
|
s = s.replace(/_([^_]+)_/g, "<em>$1</em>");
|
|
s = s.replace(/~~([^~]+)~~/g, "<del>$1</del>");
|
|
|
|
s = s.replace(/\u0000CODE(\d+)\u0000/g, (_, i) => codes[Number(i)] ?? "");
|
|
return s;
|
|
}
|
|
|
|
/** Plain body: markdown when enabled, else escaped newlines. */
|
|
export function formatBodyHtml(text) {
|
|
if (isMarkdownRenderEnabled()) return renderMarkdownToHtml(text);
|
|
return esc(text).replace(/\n/g, "<br>");
|
|
}
|