Static frontend with vendored marked/DOMPurify, bounded Markdown pipeline, same-origin mock server with scenario selection, unit, contract and CDP end-to-end tests under frontend/**.
465 lines
15 KiB
JavaScript
465 lines
15 KiB
JavaScript
/**
|
|
* 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_<slug>_<timestamp>.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);
|
|
});
|
|
}
|