/** Review sections and single-revision comparisons for artifact documents. */ import { existsSync, readFileSync, writeFileSync } from 'node:fs'; import { join } from 'node:path'; import { BASE_CSS } from './styles.js'; import { artifactDir, artifactPath, isSafeSlug, sourcePath } from './utils.js'; export interface ReviewDecision { id: string; question: string; options: { value: string; label: string }[]; multiple?: boolean; } export interface ReviewEvidence { id: string; title: string; url?: string; source?: string; quote?: string; } interface RevisionSnapshot { html: string; source?: string; } const SAFE_ID = /^[a-zA-Z0-9_-]+$/; const MAX_ITEMS = 50; const MAX_TEXT = 4_000; const MAX_HTML = 5_000_000; function escapeHtml(value: string): string { return value.replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); } function assertText(value: string, name: string, allowEmpty = false): void { if (typeof value !== 'string' || (!allowEmpty && !value) || value.length > MAX_TEXT) { throw new Error(`invalid review ${name}`); } } function assertId(id: string, kind: string): void { if (typeof id !== 'string' || !SAFE_ID.test(id)) throw new Error(`invalid ${kind} id`); } function safeUrl(url: string): string | null { try { const parsed = new URL(url); return parsed.protocol === 'http:' || parsed.protocol === 'https:' ? parsed.href : null; } catch { return null; } } function validateReview(decisions: ReviewDecision[], evidence: ReviewEvidence[]): void { if ( !Array.isArray(decisions) || !Array.isArray(evidence) || decisions.length > MAX_ITEMS || evidence.length > MAX_ITEMS ) { throw new Error('too many review items'); } const decisionIds = new Set(); for (const decision of decisions) { assertId(decision.id, 'decision'); if (decisionIds.has(decision.id)) throw new Error('duplicate decision id'); decisionIds.add(decision.id); assertText(decision.question, 'question'); if (!Array.isArray(decision.options) || decision.options.length < 2 || decision.options.length > MAX_ITEMS) { throw new Error('a decision needs at least two choices'); } const values = new Set(); for (const option of decision.options) { assertText(option.value, 'choice value'); assertText(option.label, 'choice label'); if (values.has(option.value)) throw new Error('duplicate choice value'); values.add(option.value); } } const evidenceIds = new Set(); for (const item of evidence) { assertId(item.id, 'evidence'); if (evidenceIds.has(item.id)) throw new Error('duplicate evidence id'); evidenceIds.add(item.id); assertText(item.title, 'evidence title'); if (!item.source && !item.quote && !item.url) throw new Error('evidence needs source, quote, or url'); if (item.source != null) assertText(item.source, 'evidence source'); if (item.quote != null) assertText(item.quote, 'evidence quote'); if (item.url != null && (typeof item.url !== 'string' || item.url.length > MAX_TEXT || !safeUrl(item.url))) { throw new Error('evidence URL must use http or https'); } } } /** Add native decision and evidence controls without modifying the supplied content. */ export function renderReviewContent(html: string, decisions: ReviewDecision[], evidence: ReviewEvidence[]): string { if (typeof html !== 'string' || html.length > MAX_HTML) throw new Error('invalid artifact html'); validateReview(decisions, evidence); if (!decisions.length && !evidence.length) return html; const decisionMarkup = decisions.length ? `

Decisions

${decisions .map((decision) => { const type = decision.multiple ? 'checkbox' : 'radio'; const name = `artifact-decision-${decision.id}`; return `
${escapeHtml(decision.question)}${decision.options.map((option) => ``).join('')}
`; }) .join('')}
` : ''; const evidenceMarkup = evidence.length ? `

Evidence

${evidence .map((item) => { const url = item.url ? safeUrl(item.url)! : null; return `
${escapeHtml(item.title)}${item.source ? `

${escapeHtml(item.source)}

` : ''}${item.quote ? `
${escapeHtml(item.quote)}
` : ''}${url ? `

${escapeHtml(url)}

` : ''}
`; }) .join('')}
` : ''; const style = ``; const revealEvidence = evidence.length ? `` : ''; const addition = `${style}${decisionMarkup}${evidenceMarkup}${revealEvidence}`; const footer = html.search(/]*\bclass=(['"])\s*[^'"]*\bartifact-footer\b[^'"]*\1[^>]*>/i); const body = html.search(/<\/body\s*>/i); const at = footer >= 0 ? footer : body >= 0 ? body : html.length; return html.slice(0, at) + addition + html.slice(at); } function previousPath(slug: string): string { return join(artifactDir(), `${slug}.previous.json`); } /** Save the current artifact and its optional markdown source as the sole previous revision. */ export function savePreviousRevision(slug: string): void { if (!isSafeSlug(slug)) throw new Error('unsafe artifact slug'); const htmlPath = artifactPath(slug); if (!existsSync(htmlPath)) return; const snapshot: RevisionSnapshot = { html: readFileSync(htmlPath, 'utf-8') }; const markdownPath = sourcePath(slug); if (existsSync(markdownPath)) snapshot.source = readFileSync(markdownPath, 'utf-8'); writeFileSync(previousPath(slug), JSON.stringify(snapshot), 'utf-8'); } function readRevision(slug: string): RevisionSnapshot | null { if (!isSafeSlug(slug)) throw new Error('unsafe artifact slug'); const path = previousPath(slug); if (!existsSync(path)) return null; try { const value: unknown = JSON.parse(readFileSync(path, 'utf-8')); if (!value || typeof value !== 'object' || typeof (value as RevisionSnapshot).html !== 'string') return null; const snapshot = value as RevisionSnapshot; return typeof snapshot.source === 'string' ? snapshot : { html: snapshot.html }; } catch { return null; } } function markedLines(text: string, prefix: number, suffix: number): string { const lines = text.split('\n'); const changedEnd = Math.max(prefix, lines.length - suffix); return lines .map((line, index) => index >= prefix && index < changedEnd ? `${escapeHtml(line)}` : escapeHtml(line), ) .join('\n'); } /** Remove active features. The iframe sandbox and CSP are the security boundary. */ function staticSnapshot(html: string): string { return html .replace(/]*>[\s\S]*?<\/script\s*>/gi, '') .replace(/]*\/?\s*>/gi, '') .replace(/]*\/?\s*>/gi, '') .replace(/]*\/?\s*>/gi, '') .replace(/]*>[\s\S]*?<\/iframe\s*>/gi, '') .replace(/]*\/?\s*>/gi, '') .replace(/]*>/gi, '') .replace(/<(input|select|textarea|button|fieldset)\b/gi, '<$1 disabled') .replace(/]*>/gi, (tag) => tag.replace(/\s(?:href|tabindex)\s*=\s*(?:"[^"]*"|'[^']*'|[^\s>]+)/gi, '')); } /** Preserve readable text and document attributes without running scripts or loading remote assets. */ function previewDocument(html: string): string { return `${staticSnapshot(html)}`; } function artifactTitle(html: string, slug: string): string { const title = html.match(/]*>([\s\S]*?)<\/title\s*>/i)?.[1]; return title ? title.replace(/<[^>]*>/g, '').trim() || slug : slug; } /** Render a safe, static comparison between the saved revision and the current artifact. */ export function renderRevisionComparison(slug: string): string | null { if (!isSafeSlug(slug)) throw new Error('unsafe artifact slug'); const previous = readRevision(slug); const currentHtmlPath = artifactPath(slug); if (!previous || !existsSync(currentHtmlPath)) return null; const currentHtml = readFileSync(currentHtmlPath, 'utf-8'); const currentSourcePath = sourcePath(slug); const currentSource = existsSync(currentSourcePath) ? readFileSync(currentSourcePath, 'utf-8') : undefined; const reviewSections = (html: string) => (html.match(/
/g) ?? []).join('\n'); const useSource = previous.source !== undefined && currentSource !== undefined && reviewSections(previous.html) === reviewSections(currentHtml); const oldText = useSource ? previous.source! : previous.html; const newText = useSource ? currentSource! : currentHtml; const oldLines = oldText.split('\n'); const newLines = newText.split('\n'); // ponytail: mark the changed middle in O(n), use a line-diff library if granular hunks become necessary. let prefix = 0; while (prefix < oldLines.length && prefix < newLines.length && oldLines[prefix] === newLines[prefix]) prefix++; let suffix = 0; while ( suffix < oldLines.length - prefix && suffix < newLines.length - prefix && oldLines[oldLines.length - 1 - suffix] === newLines[newLines.length - 1 - suffix] ) suffix++; const safeSlug = escapeHtml(slug); const title = escapeHtml(artifactTitle(currentHtml, slug)); const sourceLabel = useSource ? 'Markdown source' : 'HTML source'; const previousPreview = escapeHtml(previewDocument(previous.html)); const currentPreview = escapeHtml(previewDocument(currentHtml)); return `Changes

Back to artifact

Changes

${title}

Previous

Static, noninteractive preview

Current

Static, noninteractive preview

Source changes

${sourceLabel}. Highlighted lines are the changed region.

Previous

${markedLines(oldText, prefix, suffix)}

Current

${markedLines(newText, prefix, suffix)}
`; }