diff --git a/web/markdown.js b/web/markdown.js new file mode 100644 index 0000000..b861cf8 --- /dev/null +++ b/web/markdown.js @@ -0,0 +1,199 @@ +/** 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, ">"); +} + +/** 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( + `
${esc(code.replace(/\n$/, ""))}`,
+ );
+ 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("${body}
`); + para = []; + }; + + const flushList = () => { + if (!listType || !listItems.length) { + listType = null; + listItems = []; + return; + } + const tag = listType; + out.push( + `<${tag} class="md-${tag}">${listItems + .map((item) => `${quoteLines + .map((q) => ``, + ); + 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, "${inlineMarkdown(q)}
`) + .join("")}
${code}`);
+ return `\u0000CODE${i}\u0000`;
+ });
+
+ s = s.replace(
+ /\[([^\]]+)\]\((https?:\/\/[^)\s]+)\)/g,
+ (_, label, url) =>
+ `${label}`,
+ );
+
+ s = s.replace(/\*\*([^*]+)\*\*/g, "$1");
+ s = s.replace(/__([^_]+)__/g, "$1");
+ s = s.replace(/\*([^*]+)\*/g, "$1");
+ s = s.replace(/_([^_]+)_/g, "$1");
+ s = s.replace(/~~([^~]+)~~/g, "${esc(p).replace(/\n/g, "
")}