/** * Markdown rendering and bounded sectioning pipeline. * Uses pinned marked.js and DOMPurify with strict tag allowlist and link normalization. */ // Target maximum size for a single rendered Markdown section export const SECTION_TARGET_BYTES = 48 * 1024; // ~48 KiB soft target export const SECTION_HARD_LIMIT_BYTES = 64 * 1024; // ~64 KiB hard cap per section export const PATHOLOGICAL_BLOCK_LIMIT = 128 * 1024; // 128 KiB fallback threshold const textEncoder = typeof TextEncoder !== 'undefined' ? new TextEncoder() : null; /** * Calculates UTF-8 byte length for a string. * @param {string} str * @returns {number} */ export function getUtf8Bytes(str) { if (!str) return 0; if (textEncoder) { return textEncoder.encode(str).length; } let bytes = 0; for (let i = 0; i < str.length; i++) { const code = str.charCodeAt(i); if (code <= 0x7f) bytes += 1; else if (code <= 0x7ff) bytes += 2; else if (code >= 0xd800 && code <= 0xdbff) { bytes += 4; i++; } else bytes += 3; } return bytes; } /** * Configure marked options once. */ if (typeof window !== 'undefined' && window.marked) { window.marked.setOptions({ gfm: true, breaks: true }); } /** * Normalizes and sanitizes a single bounded markdown section into HTML. * Fails safe: never injects raw untrusted markdown if parser or sanitizer is absent. * @param {string} rawMd * @returns {DocumentFragment} */ export function renderMarkdownSectionToFragment(rawMd) { if (typeof rawMd !== 'string') { return document.createDocumentFragment(); } // Fail-safe render: if marked or DOMPurify are absent, fail safe with an empty fragment + notice. // Never inject raw untrusted markdown as innerHTML. if (typeof window === 'undefined' || typeof document === 'undefined' || !window.marked || !window.DOMPurify) { if (typeof document === 'undefined') { return { textContent: 'Markdown renderer or sanitizer is unavailable. Content cannot be displayed safely.', isFailSafe: true }; } const fragment = document.createDocumentFragment(); const notice = document.createElement('div'); notice.className = 'render-error-notice'; notice.textContent = 'Markdown renderer or sanitizer is unavailable. Content cannot be displayed safely.'; fragment.appendChild(notice); return fragment; } // Parse Markdown using locally vendored marked let rawHtml = ''; try { rawHtml = window.marked.parse(rawMd); } catch { const fragment = document.createDocumentFragment(); const notice = document.createElement('div'); notice.className = 'render-error-notice'; notice.textContent = 'Failed to parse Markdown section.'; fragment.appendChild(notice); return fragment; } // Sanitize with strict allowlist const cleanHtml = window.DOMPurify.sanitize(rawHtml, { ALLOWED_TAGS: [ 'p', 'br', 'hr', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'strong', 'em', 'del', 's', 'blockquote', 'pre', 'code', 'ul', 'ol', 'li', 'table', 'thead', 'tbody', 'tr', 'th', 'td', 'a' ], ALLOWED_ATTR: ['href', 'title', 'colspan', 'rowspan', 'start'], ALLOW_DATA_ATTR: false, ALLOW_ARIA_ATTR: false }); // Build template and normalize links const template = document.createElement('template'); template.innerHTML = cleanHtml; template.content.querySelectorAll('a').forEach((link) => { try { const href = link.getAttribute('href'); if (!href) throw new Error('Empty href'); const url = new URL(href, window.location.href); if (!['https:', 'http:'].includes(url.protocol)) { throw new Error('Disallowed protocol'); } link.href = url.href; link.target = '_blank'; link.rel = 'noopener noreferrer'; } catch { // Strip unsafe/invalid hrefs (e.g. javascript:, data:, relative file URLs) link.removeAttribute('href'); } }); return template.content; } /** * Partitions large markdown documents into bounded sections to prevent DOM exhaustion. * Preserves code fences and table boundaries where feasible. * Forces section splits even inside large code fences by safely closing and re-opening the fence. * Keeps table rows together and repeats table headers if forced to split. * Dense lists without blank lines split cleanly at item or line boundaries. * Falls back to bounded plain-text preview for single pathological blocks. * @param {string} rawMd * @returns {Array<{ text: string, isPathologicalFallback?: boolean }>} */ export function partitionMarkdown(rawMd) { if (!rawMd) return []; const totalBytes = getUtf8Bytes(rawMd); if (totalBytes <= SECTION_TARGET_BYTES) { if (totalBytes > PATHOLOGICAL_BLOCK_LIMIT) { return [{ text: rawMd.slice(0, 32 * 1024) + '\n\n... [Block truncated for performance; click Export to MD for full document]', isPathologicalFallback: true }]; } return [{ text: rawMd }]; } const sections = []; const lines = rawMd.split('\n'); let currentChunk = []; let currentSize = 0; let inCodeFence = false; let fenceIndent = ''; let fenceChar = '`'; let fenceLen = 3; let fenceInfo = ''; let inTable = false; let tableHeader = null; for (let i = 0; i < lines.length; i++) { const line = lines[i]; const lineBytes = getUtf8Bytes(line) + 1; // +1 for newline // Check for single pathological line/block exceeding safety limit if (lineBytes > PATHOLOGICAL_BLOCK_LIMIT) { if (currentChunk.length > 0) { if (inCodeFence) { currentChunk.push(`${fenceIndent}${fenceChar.repeat(fenceLen)}`); } const chunkText = currentChunk.join('\n'); if (chunkText.trim().length > 0) { sections.push({ text: chunkText }); } currentChunk = []; currentSize = 0; } // Pathological chunk fallback: bounded slice sections.push({ text: line.slice(0, 32 * 1024) + '\n\n... [Block truncated for performance; click Export to MD for full document]', isPathologicalFallback: true }); if (inCodeFence) { const reopen = `${fenceIndent}${fenceChar.repeat(fenceLen)}${fenceInfo || ''}`; currentChunk.push(reopen); currentSize = getUtf8Bytes(reopen) + 1; } continue; } // Code fence detection const fenceMatch = line.match(/^(\s*)(`{3,}|~{3,})(.*)$/); if (!inCodeFence && fenceMatch) { inCodeFence = true; fenceIndent = fenceMatch[1]; fenceChar = fenceMatch[2][0]; fenceLen = fenceMatch[2].length; fenceInfo = fenceMatch[3].trim(); } else if (inCodeFence) { const closeRegex = new RegExp(`^\\s*${fenceChar === '`' ? '`' : '~'}{${fenceLen},}\\s*$`); if (closeRegex.test(line)) { inCodeFence = false; } } // Table detection (GFM tables) const isTableRow = /^\s*\|.*\|\s*$/.test(line); const isTableSep = /^\s*\|(?:\s*:?-+:?\s*\|)+\s*$/.test(line); if (!inCodeFence) { if (isTableRow && !inTable && i + 1 < lines.length && /^\s*\|(?:\s*:?-+:?\s*\|)+\s*$/.test(lines[i + 1])) { inTable = true; tableHeader = `${line}\n${lines[i + 1]}`; } else if (inTable && (line.trim() === '' || !isTableRow)) { inTable = false; tableHeader = null; } } // Split decision when inside code fence: // Force split if current chunk exceeds hard limit if (inCodeFence) { if (currentSize + lineBytes > SECTION_HARD_LIMIT_BYTES && currentChunk.length > 0) { // Safely close fence in current chunk const closeFence = `${fenceIndent}${fenceChar.repeat(fenceLen)}`; currentChunk.push(closeFence); sections.push({ text: currentChunk.join('\n') }); // Re-open fence in new chunk const reopenFence = `${fenceIndent}${fenceChar.repeat(fenceLen)}${fenceInfo || ''}`; currentChunk = [reopenFence]; currentSize = getUtf8Bytes(reopenFence) + 1; } currentChunk.push(line); currentSize += lineBytes; continue; } // Split decision when inside table: if (inTable) { if (currentSize + lineBytes > SECTION_HARD_LIMIT_BYTES && currentChunk.length > 0 && !isTableSep) { sections.push({ text: currentChunk.join('\n') }); currentChunk = []; currentSize = 0; if (tableHeader) { currentChunk.push(tableHeader); currentSize = getUtf8Bytes(tableHeader) + 1; } } currentChunk.push(line); currentSize += lineBytes; continue; } // Outside code fences and tables: const isHeading = /^\s*#{1,3}\s/.test(line); const isEmptyLine = line.trim() === ''; const isListItem = /^\s*([*+-]|\d+\.)\s/.test(line); const shouldSplit = ( (isHeading && currentSize >= 16 * 1024) || ((isHeading || isEmptyLine) && currentSize >= SECTION_TARGET_BYTES) || (isListItem && currentSize >= SECTION_TARGET_BYTES) || (currentSize + lineBytes > SECTION_HARD_LIMIT_BYTES) ); if (shouldSplit && currentChunk.length > 0) { const chunkText = currentChunk.join('\n'); if (chunkText.trim().length > 0) { sections.push({ text: chunkText }); } currentChunk = []; currentSize = 0; } currentChunk.push(line); currentSize += lineBytes; } if (currentChunk.length > 0) { if (inCodeFence) { currentChunk.push(`${fenceIndent}${fenceChar.repeat(fenceLen)}`); } const chunkText = currentChunk.join('\n'); if (chunkText.trim().length > 0) { sections.push({ text: chunkText }); } } return sections.length > 0 ? sections : [{ text: rawMd }]; } /** * State container for bounded section rendering in the UI. */ export class MarkdownRenderer { /** * @param {HTMLElement} containerElement * @param {HTMLElement} navElement */ constructor(containerElement, navElement) { this.container = containerElement; this.nav = navElement; this.sections = []; this.currentIndex = 0; } /** * Loads and partitions new markdown text. * @param {string} rawMd */ load(rawMd) { this.sections = partitionMarkdown(rawMd); this.currentIndex = 0; this.renderCurrentSection(); } /** * Renders the current active section, limiting retained rendered DOM nodes. */ renderCurrentSection() { this.container.replaceChildren(); if (this.sections.length === 0) { this.nav.classList.add('hidden'); return; } const current = this.sections[this.currentIndex]; // If pathological fallback was triggered, render plain-text preview (never raw to marked) if (current.isPathologicalFallback) { const banner = document.createElement('div'); banner.className = 'pathological-banner'; banner.textContent = 'Pathological large block detected. Display bounded for performance; use "Export to MD" to download the full document.'; this.container.appendChild(banner); const pre = document.createElement('pre'); pre.className = 'pathological-preview'; pre.textContent = current.text; this.container.appendChild(pre); } else { // Normal section: parse and sanitize const fragment = renderMarkdownSectionToFragment(current.text); this.container.appendChild(fragment); } // Update section navigation controls (show for multiple sections OR single pathological section) if (this.sections.length > 1 || current.isPathologicalFallback) { this.nav.classList.remove('hidden'); this.updateNavUI(); } else { this.nav.classList.add('hidden'); } } /** * Updates navigation buttons and indicators. */ updateNavUI() { this.nav.replaceChildren(); const banner = document.createElement('div'); banner.className = 'section-nav-banner'; const info = document.createElement('span'); if (this.sections.length === 1 && this.sections[0].isPathologicalFallback) { info.textContent = 'Showing bounded plain-text preview (pathological input; click Export to MD for full document)'; } else { info.textContent = `Showing section ${this.currentIndex + 1} of ${this.sections.length} (bounded for responsiveness)`; } const controls = document.createElement('div'); controls.className = 'section-nav-controls'; if (this.sections.length > 1) { const prevBtn = document.createElement('button'); prevBtn.type = 'button'; prevBtn.className = 'section-btn'; prevBtn.textContent = '← Previous'; prevBtn.disabled = this.currentIndex === 0; prevBtn.addEventListener('click', () => { if (this.currentIndex > 0) { this.currentIndex--; this.renderCurrentSection(); this.container.scrollIntoView({ behavior: 'smooth', block: 'start' }); } }); const nextBtn = document.createElement('button'); nextBtn.type = 'button'; nextBtn.className = 'section-btn'; nextBtn.textContent = 'Next →'; nextBtn.disabled = this.currentIndex >= this.sections.length - 1; nextBtn.addEventListener('click', () => { if (this.currentIndex < this.sections.length - 1) { this.currentIndex++; this.renderCurrentSection(); this.container.scrollIntoView({ behavior: 'smooth', block: 'start' }); } }); controls.appendChild(prevBtn); controls.appendChild(nextBtn); } banner.appendChild(info); if (this.sections.length > 1) { banner.appendChild(controls); } this.nav.appendChild(banner); } } /** * Downloads raw Markdown content client-side via Blob. * Uses exact specification naming: confluence_summary__.md * Revokes object URL after dispatch. * @param {string} rawMarkdown * @param {string} queryText */ export function exportToMarkdown(rawMarkdown, queryText) { const slug = (queryText || '').slice(0, 30).replace(/[^a-zA-Z0-9]/g, '_').toLowerCase(); const filename = `confluence_summary_${slug || 'export'}_${Date.now()}.md`; const blob = new Blob([rawMarkdown], { type: 'text/markdown;charset=utf-8' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = filename; document.body.appendChild(a); a.click(); document.body.removeChild(a); URL.revokeObjectURL(url); } /** * Renders warning items safely into the warnings container. * @param {HTMLElement} container * @param {Array<{ code: string, message: string, tool_call_id?: string, name?: string }>} warnings */ export function renderWarnings(container, warnings) { container.replaceChildren(); if (!Array.isArray(warnings) || warnings.length === 0) { container.classList.add('hidden'); return; } container.classList.remove('hidden'); warnings.forEach((w) => { const item = document.createElement('div'); item.className = 'warning-item'; const codeSpan = document.createElement('span'); codeSpan.className = 'warning-code'; codeSpan.textContent = `[${w.code || 'warning'}]`; const msgSpan = document.createElement('span'); msgSpan.className = 'warning-msg'; msgSpan.textContent = w.message || 'An unknown warning occurred.'; item.appendChild(codeSpan); item.appendChild(msgSpan); container.appendChild(item); }); }