/** * HTMLContentRenderer Component * Safely renders HTML content with proper formatting and sanitization */ import React, { useMemo } from 'react'; import { sanitizeHTML, isHTML } from '../../utils/htmlSanitizer'; interface HTMLContentRendererProps { content: string | null | undefined; className?: string; maxHeight?: string; } /** * Parse and format content outline (JSON structure) */ function formatContentOutline(content: any): string { if (!content) return ''; // If object contains a `content` field with HTML, use that directly if (typeof content === 'object' && content !== null && 'content' in content) { const mainContent = (content as any).content; if (typeof mainContent === 'string' && mainContent.trim().length > 0) { return sanitizeHTML(mainContent); } } let html = '
'; // NEW FORMAT: Handle overview + outline structure if (content.overview && content.outline) { // Display overview html += '
'; html += `
Overview: ${escapeHTML(content.overview)}
`; html += '
'; // Display intro focus if available if (content.outline.intro_focus) { html += '
'; html += `

Introduction Focus

`; html += `
${escapeHTML(content.outline.intro_focus)}
`; html += '
'; } // Display main sections if (content.outline.main_sections && Array.isArray(content.outline.main_sections)) { content.outline.main_sections.forEach((section: any) => { html += '
'; if (section.h2_topic) { html += `

${escapeHTML(section.h2_topic)}

`; } if (section.coverage) { html += `
${escapeHTML(section.coverage)}
`; } html += '
'; }); } html += '
'; return html; } // Handle introduction section - can be object or string if (content.introduction) { html += '
'; if (typeof content.introduction === 'string') { // Introduction is a simple string html += `
${escapeHTML(content.introduction)}
`; } else if (typeof content.introduction === 'object') { // Introduction is an object with hook and paragraphs if (content.introduction.hook) { html += `
Hook: ${escapeHTML(content.introduction.hook)}
`; } if (content.introduction.paragraphs && Array.isArray(content.introduction.paragraphs)) { content.introduction.paragraphs.forEach((para: any, index: number) => { if (para.details) { html += `
Intro Paragraph ${index + 1}: ${escapeHTML(para.details)}
`; } }); } } html += '
'; } // Handle sections array format (Format 3: nested structure) if (content.sections && Array.isArray(content.sections)) { content.sections.forEach((section: any) => { if (!section) return; html += '
'; // Handle section title (can be "H2: ..." or just text) if (section.title) { const titleText = section.title.replace(/^H2:\s*/i, '').trim(); if (titleText.toLowerCase() === 'conclusion') { html += `

${escapeHTML(titleText)}

`; } else { html += `

${escapeHTML(titleText)}

`; } } // Handle section content - can be array or string if (section.content) { if (Array.isArray(section.content)) { // Content is an array of objects with title (H3) and content section.content.forEach((item: any) => { if (item.title) { const subTitleText = item.title.replace(/^H3:\s*/i, '').trim(); html += `

${escapeHTML(subTitleText)}

`; } if (item.content) { html += `
${escapeHTML(String(item.content))}
`; } }); } else if (typeof section.content === 'string') { // Content is a simple string html += `
${escapeHTML(section.content)}
`; } } html += '
'; }); } // Handle H2 sections - can be array or simple key-value pairs if (content.H2) { if (Array.isArray(content.H2)) { // Structured format: array of section objects content.H2.forEach((section: any) => { if (section.heading || typeof section === 'string') { html += `
`; const heading = section.heading || section; html += `

${escapeHTML(heading)}

`; // Handle content type badge if (section.content_type) { html += `
${escapeHTML(section.content_type.replace('_', ' ').toUpperCase())}
`; } // Handle subsections (H3) if (section.subsections && Array.isArray(section.subsections)) { section.subsections.forEach((subsection: any) => { const subheading = subsection.subheading || subsection.heading || subsection; html += `

${escapeHTML(subheading)}

`; if (subsection.details) { html += `
${escapeHTML(subsection.details)}
`; } }); } // Handle details if (section.details) { html += `
${escapeHTML(section.details)}
`; } html += `
`; } }); } else if (typeof content.H2 === 'string') { // Simple format: just a string (GPT-4o mini sometimes returns this) html += `
`; html += `

${escapeHTML(content.H2)}

`; html += `
`; } else if (typeof content.H2 === 'object') { // Simple key-value format (GPT-4o mini format) Object.entries(content.H2).forEach(([key, value]: [string, any]) => { html += `
`; html += `

${escapeHTML(value)}

`; html += `
`; }); } } // Handle H3 as a direct property (for GPT-4o mini simple format) if (content.H3 && !content.H2) { html += `
`; if (typeof content.H3 === 'string') { html += `

${escapeHTML(content.H3)}

`; } else if (typeof content.H3 === 'object') { Object.entries(content.H3).forEach(([key, value]: [string, any]) => { html += `

${escapeHTML(value)}

`; }); } html += `
`; } html += ''; return html; } /** * Escape HTML to prevent XSS */ function escapeHTML(text: string): string { const div = document.createElement('div'); div.textContent = text; return div.innerHTML; } const HTMLContentRenderer: React.FC = ({ content, className = '', maxHeight, }) => { const renderedContent = useMemo(() => { if (!content) return '
No content available
'; // If content is already an object (dict), use it directly if (typeof content === 'object' && content !== null) { // Check for any known structure format if (content.overview || content.outline || content.H2 || content.H3 || content.introduction || content.sections) { return formatContentOutline(content); } // If it's an object but not structured, try to format it try { // Check if it has any keys that suggest it's a structured outline const keys = Object.keys(content); if (keys.length > 0) { // Try to format it as outline anyway return formatContentOutline(content); } return escapeHTML(JSON.stringify(content, null, 2)); } catch { return escapeHTML(JSON.stringify(content, null, 2)); } } // If content is a string, try to parse as JSON first if (typeof content === 'string') { // Check if it's a JSON string that contains the actual content if (content.trim().startsWith('{') || content.trim().startsWith('[')) { try { const parsed = JSON.parse(content); if (typeof parsed === 'object' && parsed !== null) { // If it's a full AI response JSON with a 'content' field, use that if (parsed.content && typeof parsed.content === 'string') { // Recursively process the extracted content const extractedContent = parsed.content; // Check if extracted content is HTML if (isHTML(extractedContent)) { const sanitized = sanitizeHTML(extractedContent); if (sanitized.trim().startsWith('${sanitized}`; } return `
${sanitized}
`; } // If extracted content is still JSON, try parsing again if (extractedContent.trim().startsWith('{')) { try { const nestedParsed = JSON.parse(extractedContent); if (nestedParsed.H2 || nestedParsed.H3 || nestedParsed.introduction || nestedParsed.sections) { return formatContentOutline(nestedParsed); } } catch { // Not nested JSON, continue } } // Use extracted content as-is (will be processed below) content = extractedContent; } else if (parsed.overview || parsed.outline || parsed.H2 || parsed.H3 || parsed.introduction || parsed.sections) { // It's a content outline structure return formatContentOutline(parsed); } } } catch { // Not valid JSON, continue with HTML/text processing } } // Try to parse as JSON (content outline from GPT-4o mini) - for non-brace-starting JSON try { const parsed = JSON.parse(content); if (typeof parsed === 'object' && (parsed.overview || parsed.outline || parsed.H2 || parsed.H3 || parsed.introduction || parsed.sections)) { return formatContentOutline(parsed); } } catch { // Not JSON, continue with HTML/text processing } // Check if it's HTML (normalized content from backend) if (isHTML(content)) { // Content is already normalized HTML - sanitize and return const sanitized = sanitizeHTML(content); // Add wrapper classes for better styling in toggle row // Check if content already has article or wrapper if (sanitized.trim().startsWith('${sanitized}`; } return `
${sanitized}
`; } // Plain text (from GPT-4o) - format bullet points and line breaks // Convert bullet points to HTML list const lines = content.split('\n'); let html = '
'; let inList = false; for (const line of lines) { const trimmed = line.trim(); if (!trimmed) { if (inList) { html += ''; inList = false; } html += '
'; continue; } // Check for bullet points (- or *) if (trimmed.match(/^[-*]\s+/)) { if (!inList) { html += '
    '; inList = true; } const text = trimmed.replace(/^[-*]\s+/, ''); // Check for nested bullets (indented) if (trimmed.startsWith(' ') || trimmed.startsWith('\t')) { html += `
  • ${escapeHTML(text)}
  • `; } else { html += `
  • ${escapeHTML(text)}
  • `; } } // Check for H2 headings (starting with - H2:) else if (trimmed.match(/^[-*]\s*H2[:]/i)) { if (inList) { html += '
'; inList = false; } const heading = trimmed.replace(/^[-*]\s*H2[:]\s*/i, ''); html += `

${escapeHTML(heading)}

`; } // Check for H3 headings (starting with - H3:) else if (trimmed.match(/^[-*]\s*H3[:]/i)) { if (inList) { html += ''; inList = false; } const heading = trimmed.replace(/^[-*]\s*H3[:]\s*/i, ''); html += `

${escapeHTML(heading)}

`; } // Regular paragraph else { if (inList) { html += ''; inList = false; } html += `

${escapeHTML(trimmed)}

`; } } if (inList) { html += ''; } html += '
'; return html; } // Fallback: convert to string return escapeHTML(String(content)); }, [content]); return (
); }; export default HTMLContentRenderer;