// static/js/markdown.js /** * Markdown rendering and content processing utilities */ import uiModule from './ui.js?v=20260908weekhoverfix1'; 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 "<" — 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 = { '<': '<', '>': '>', '&': '&', '"': '"', ''': "'", '<': '<', '>': '>', '&': '&', }; 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 linkifyPlainEmailUidLines(src) { return String(src || '').split('\n').map((line) => { if (!/\bUID:?\s*\d+\b/i.test(line)) return line; if (line.includes('#email-') || /\]\s*\(/.test(line) || line.includes('|')) return line; const match = line.match(/^(\s*(?:[-*]\s+|\d+[.)]\s+)?)(.{3,220}?)(\s+(?:--|—|-)\s+(?=(?:[Ff]rom\b|[A-Z][a-z]{2}\s+\d|20\d{2}|\b[Uu][Ii][Dd]\b|[A-Z][A-Za-z]+ [A-Z][A-Za-z]+[, ])).{0,320}?\b[Uu][Ii][Dd]:?\s*(\d+)\b.*)$/); if (!match) return line; const [, prefix, rawLabel, rest, uid] = match; const label = rawLabel.trim().replace(/^\*\*([\s\S]+)\*\*$/, '$1'); if (!label || /\bUID:?\s*\d+\b/i.test(label)) return line; return `${prefix}[${label}](#email-${uid})${rest}`; }).join('\n'); } function linkifyRawEmailToolBlocks(src) { const lines = String(src || '').split('\n'); for (let i = 0; i < lines.length; i += 1) { const match = lines[i].match(/^(\s*\d+\.\s+)\*\*([^\n*]+?)\*\*\s*$/); if (!match || lines[i].includes('#email-') || /\]\s*\(/.test(lines[i])) continue; let uid = ''; for (let j = i + 1; j < Math.min(lines.length, i + 10); j += 1) { if (/^\s*\d+\.\s+\*\*/.test(lines[j])) break; const uidMatch = lines[j].match(/^\s*UID:\s*(\d+)\b/i); if (uidMatch) { uid = uidMatch[1]; break; } } if (!uid) continue; const label = match[2].trim(); if (!label) continue; lines[i] = `${match[1]}[${label}](#email-${uid})`; } return lines.join('\n'); } // Read-email responses are often rendered as an unnumbered `Email: ...` // heading followed by UID metadata. Make that heading open the same inbox // message as list-email rows, including while the response is still live. function linkifyRawEmailReadBlocks(src) { const lines = String(src || '').split('\n'); for (let i = 0; i < lines.length; i += 1) { const match = lines[i].match(/^(\s*Email:\s+)(?!\[)([^\n]+?)\s*$/i); if (!match) continue; let uid = ''; for (let j = i + 1; j < Math.min(lines.length, i + 14); j += 1) { const uidMatch = lines[j].match(/^\s*(?:\*\*)?UID:?(?:\*\*)?\s*(\d+)\b/i); if (uidMatch) { uid = uidMatch[1]; break; } } if (uid) lines[i] = `${match[1]}[${match[2]}](#email-${uid})`; } return lines.join('\n'); } function linkifyRawCookbookLists(src) { return String(src || '').split('\n').map(line => { if (/\]\(#cookbook-/.test(line)) return line; const session = line.match(/^(\s*[-*]\s+)([^:\n]{2,160})(:\s+.*?\bsession:\s*)([A-Za-z0-9_.-]+)(\).*)$/i); if (session) { const [, prefix, label, middle, sessionId, suffix] = session; return `${prefix}[${label}](#cookbook-session-${sessionId})${middle}${sessionId}${suffix}`; } const model = line.match(/^(\s*[-*]\s+)([A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+)(\s+(?:—|-).*)$/); if (model) { const repo = model[2].replace('/', '~'); return `${model[1]}[${model[2]}](#cookbook-model-${repo})${model[3]}`; } return line; }).join('\n'); } function flattenLegacyNoteMoreDetails(src) { return String(src || '').replace( /