Artur Mukhamadiev a9908a533f frontend: web UI track handoff (contract revision 1)
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/**.
2026-09-14 21:57:54 +03:00

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);
});
}