// @ts-check import { Marked } from "../vendor/marked/marked.esm.js"; import DOMPurify from "../vendor/dompurify/purify.es.mjs"; import { escHtml } from "./dom.js"; const markdown = new Marked({ gfm: true, breaks: true, renderer: { // Treat embedded HTML as text. Replies can format Markdown, not UI controls. html: ({ text }) => escHtml(text), checkbox: ({ checked }) => checked ? "☑ " : "☐ ", }, }); /** Parse assistant text into passive, sanitized message content. @param {string} text */ export function renderMarkdown(text) { if (!DOMPurify.isSupported) return document.createTextNode(text); try { const html = markdown.parse(text, { async: false }); const fragment = DOMPurify.sanitize(html, { ALLOWED_TAGS: [ "p", "br", "strong", "em", "del", "a", "img", "ul", "ol", "li", "h1", "h2", "h3", "h4", "h5", "h6", "blockquote", "pre", "code", "hr", "table", "thead", "tbody", "tr", "th", "td", ], ALLOWED_ATTR: ["href", "title", "src", "alt", "start", "align"], ALLOW_DATA_ATTR: false, ALLOW_ARIA_ATTR: false, RETURN_DOM_FRAGMENT: true, }); for (const link of fragment.querySelectorAll("a")) { if (!link.hasAttribute("href")) { link.replaceWith(...link.childNodes); } else { link.target = "_blank"; link.rel = "noopener noreferrer"; } } for (const image of fragment.querySelectorAll("img")) image.loading = "lazy"; for (const table of fragment.querySelectorAll("table")) { const wrapper = document.createElement("div"); wrapper.className = "markdown-table"; wrapper.tabIndex = 0; wrapper.setAttribute("role", "region"); wrapper.setAttribute("aria-label", "Tabla de la respuesta"); table.replaceWith(wrapper); wrapper.appendChild(table); } for (const code of fragment.querySelectorAll("pre")) code.tabIndex = 0; return fragment; } catch (err) { console.warn("[ui] Markdown rendering failed", err); return document.createTextNode(text); } }