/** Optional Markdown display — escaped first, then a small safe subset. * Feature list (prompt injection): skills/.../markdown-safe-subset/catalog.yaml */ 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, ">"); } function isTableSeparator(line) { const t = String(line ?? "").trim(); if (!t.includes("-")) return false; // GFM: | --- | :---: | ---: | return /^\|?(\s*:?-{3,}:?\s*\|)+\s*:?-{3,}:?\s*\|?\s*$/.test(t); } function splitTableRow(line) { let s = String(line ?? "").trim(); if (s.startsWith("|")) s = s.slice(1); if (s.endsWith("|")) s = s.slice(0, -1); return s.split("|").map((c) => c.trim()); } /** Safe subset: fences, details, tables, headings, emphasis, links, lists, quotes, hr. */ 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, "