/** 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("
"); out.push(`

${body}

`); para = []; }; const flushList = () => { if (!listType || !listItems.length) { listType = null; listItems = []; return; } const tag = listType; out.push( `<${tag} class="md-${tag}">${listItems .map((item) => `
  • ${inlineMarkdown(item)}
  • `) .join("")}`, ); 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; } const detailsStart = line.match(/^:::details(?:\s+(open))?\s+(.+?)\s*$/); if (detailsStart) { flushPara(); flushList(); const openAttr = detailsStart[1] ? " open" : ""; const summary = detailsStart[2]; const bodyLines = []; i += 1; while (i < lines.length && !/^:::\s*$/.test(lines[i])) { bodyLines.push(lines[i]); i += 1; } if (i < lines.length) i += 1; const inner = renderMarkdownToHtml(bodyLines.join("\n")); out.push( `
    ${inlineMarkdown( summary, )}
    ${inner || ""}
    `, ); continue; } if ( line.includes("|") && i + 1 < lines.length && isTableSeparator(lines[i + 1]) ) { flushPara(); flushList(); const headers = splitTableRow(line); i += 2; const rows = []; while (i < lines.length && lines[i].includes("|") && lines[i].trim()) { if (isTableSeparator(lines[i])) { i += 1; continue; } rows.push(splitTableRow(lines[i])); i += 1; } const thead = `${headers .map((h) => `${inlineMarkdown(h)}`) .join("")}`; const tbody = rows.length ? `${rows .map( (cells) => `${headers .map((_, idx) => `${inlineMarkdown(cells[idx] ?? "")}`) .join("")}`, ) .join("")}` : ""; out.push( `
    ${thead}${tbody}
    `, ); continue; } if (/^\s*([-*_])\1{2,}\s*$/.test(line)) { flushPara(); flushList(); out.push('
    '); i += 1; continue; } const heading = line.match(/^(#{1,6})\s+(.+)$/); if (heading) { flushPara(); flushList(); const level = heading[1].length; out.push(`${inlineMarkdown(heading[2])}`); 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( `
    ${quoteLines .map((q) => `

    ${inlineMarkdown(q)}

    `) .join("")}
    `, ); 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, "
    "); } function inlineMarkdown(line) { let s = esc(line); const codes = []; s = s.replace(/`([^`]+)`/g, (_, code) => { const i = codes.length; codes.push(`${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, "$1"); 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, "
    "); }