// static/js/markdown.js /** * Markdown rendering and content processing utilities */ import uiModule from './ui.js'; import { splitTableRow } from './markdown/tableRow.js'; import { replaceEmojiShortcodes, hasEmojiShortcode } from './emojiShortcodes.js'; var escapeHtml = uiModule.esc; // Mermaid and KaTeX are vendored under /static/lib and fetched on first use. // Loading them from cost every session ~985 KB on the wire even though // most chats never contain a diagram or a formula. Both loaders memoise the // *promise* rather than the resolved library, so concurrent callers share one // fetch and a double trigger cannot start two loads. A failed load clears the // memo so the next diagram/formula retries instead of being poisoned forever. const MERMAID_SRC = '/static/lib/mermaid.min.js'; const KATEX_SRC = '/static/lib/katex/katex.min.js'; const KATEX_CSS = '/static/lib/katex/katex.min.css'; // Marks math emitted before KaTeX finished loading; renderMath() swaps these // for typeset output. The source stays as readable text inside the span, so a // load that never completes degrades to plain text rather than to nothing. const MATH_PENDING_CLASS = 'ody-math-pending'; // KaTeX has no entity syntax: it reads a bare "&" as an alignment marker and // errors out on anything that is not a valid column break, so "a < b" comes // back as a red .katex-error instead of a formula. mdToHtml escapes the whole // string before the math pass, which leaves two spellings of the same // character at the delimiters — a typed "<" arrives as "<", while a typed // "<" arrives as "&lt;" — and both have to reach KaTeX as "<". // // One alternation, longest form first, so nothing this writes is scanned // again. Chained .replace() calls cannot do it: unescaping "&" first lets // the next pass eat the "<" it just produced (the double-unescape CodeQL // flags), and unescaping it last leaves the entity spelling intact and breaks // the render. The code-block pass upstream keeps its chained order on purpose // — Markdown does not decode entities inside code, so "<" there is meant to // stay visible. const MATH_SOURCE_ENTITY_RE = /&(?:lt|gt|amp|quot|#39);|<|>|&/g; const MATH_SOURCE_ENTITIES = { '&lt;': '<', '&gt;': '>', '&amp;': '&', '&quot;': '"', '&#39;': "'", '<': '<', '>': '>', '&': '&', }; function decodeMathSource(text) { return String(text).replace(MATH_SOURCE_ENTITY_RE, (entity) => MATH_SOURCE_ENTITIES[entity]); } let _mermaidPromise = null; let _katexPromise = null; let _mathFlushScheduled = false; function _loadScript(src) { return new Promise((resolve, reject) => { const script = document.createElement('script'); script.src = src; script.addEventListener('load', () => resolve(), { once: true }); script.addEventListener('error', () => reject(new Error('Failed to load ' + src)), { once: true }); document.head.appendChild(script); }); } function _loadStylesheet(href) { // Resolves either way: without the stylesheet KaTeX still produces correct // markup, just unstyled, which beats failing the whole math render. return new Promise((resolve) => { const link = document.createElement('link'); link.rel = 'stylesheet'; link.href = href; link.addEventListener('load', () => resolve(), { once: true }); link.addEventListener('error', () => resolve(), { once: true }); document.head.appendChild(link); }); } /** * Load Mermaid on first use and initialize it once. */ export function ensureMermaid() { return (_mermaidPromise ??= _loadScript(MERMAID_SRC) .then(() => { if (!window.mermaid) throw new Error('mermaid global missing after load'); window.mermaid.initialize({ startOnLoad: false, theme: 'dark', securityLevel: 'loose' }); return window.mermaid; }) .catch((err) => { _mermaidPromise = null; throw err; })); } /** * Load KaTeX (script + stylesheet) on first use. */ export function ensureKatex() { return (_katexPromise ??= Promise.all([_loadScript(KATEX_SRC), _loadStylesheet(KATEX_CSS)]) .then(() => { if (!window.katex) throw new Error('katex global missing after load'); return window.katex; }) .catch((err) => { _katexPromise = null; throw err; })); } // mdToHtml() is synchronous and its callers insert the returned string into the // DOM themselves, so the placeholders are usually not attached yet when this // fires. Loading first and scanning afterwards covers that gap: by the time // KaTeX is in, the caller's innerHTML assignment has long since happened. // // setTimeout, not requestAnimationFrame: this has nothing to do with paint, and // rAF is throttled to a stop in a background tab (and never fires at all in a // headless browser), which would leave math untypeset until the tab is focused. function _scheduleMathFlush() { if (_mathFlushScheduled) return; _mathFlushScheduled = true; setTimeout(() => { _mathFlushScheduled = false; ensureKatex() .then(() => renderMath(document)) .catch((e) => console.warn('KaTeX load error:', e)); }, 0); } function safeLinkUrl(rawUrl) { const url = String(rawUrl || '').trim(); if (url.startsWith('#')) { return /^#[A-Za-z0-9_-]*$/.test(url) ? url : ''; } try { const parsed = new URL(url, window.location.origin); if (parsed.protocol === 'http:' || parsed.protocol === 'https:') { return parsed.href; } } catch (_) { return ''; } return ''; } function linkHtml(text, url) { const safeUrl = safeLinkUrl(url); const safeText = escapeHtml(text); if (!safeUrl) return safeText; if (safeUrl.startsWith('#')) { return `${safeText}`; } return `${safeText}`; } function imageHtml(alt, url, title) { const safeUrl = safeLinkUrl(url); if (!safeUrl || safeUrl.startsWith('#')) return escapeHtml(alt || ''); const safeAlt = escapeHtml(alt || ''); const safeTitle = title ? ` title="${escapeHtml(title)}"` : ''; return `${safeAlt}`; } function _isModelEndpointUrl(rawUrl) { try { const parsed = new URL(String(rawUrl || ''), window.location.origin); if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') return false; const path = parsed.pathname.replace(/\/+$/, ''); return path === '/v1'; } catch (_) { return false; } } /** * Sanitize the raw-HTML fragments that mdToHtml deliberately preserves from * the source text —
blocks (collapsible agent output) and tags * (emitted by the markdown link pass). Those fragments are later restored * verbatim into innerHTML, so without scrubbing them a model — or any content * routed through here — could smuggle in an ``, an * ``, an `onmouseover=` handler, etc. and execute * script in the authenticated page (DOM XSS). * * Parsing into a