import React from 'react'; import Image from 'next/image'; import Link from 'next/link'; import { cn } from '../../lib/utils'; import { processHTML } from '../../lib/html-compat'; import { getMediaByUrl, getMediaById, getAssetMap } from '../../lib/data'; interface ContentRendererProps { content: string; className?: string; sanitize?: boolean; processAssets?: boolean; convertClasses?: boolean; } interface ProcessedImage { src: string; alt: string; width?: number; height?: number; } /** * ContentRenderer Component * Handles rendering of WordPress HTML content with proper sanitization * and conversion to modern React components */ export const ContentRenderer: React.FC = ({ content, className = '', sanitize = true, processAssets = true, convertClasses = true, }) => { // Process the HTML content const processedContent = React.useMemo(() => { let html = content; if (sanitize) { html = processHTML(html); } if (processAssets) { html = replaceWordPressAssets(html); } if (convertClasses) { html = convertWordPressClasses(html); } return html; }, [content, sanitize, processAssets, convertClasses]); // Parse and render the HTML const renderContent = () => { if (!processedContent) return null; // Use a parser to convert HTML to React elements // For security, we'll use a custom parser that only allows safe elements return parseHTMLToReact(processedContent); }; return (
{renderContent()}
); }; /** * Parse HTML string to React elements * This is a safe parser that only allows specific tags and attributes */ function parseHTMLToReact(html: string): React.ReactNode { // Define allowed tags and their properties const allowedTags = { div: ['className', 'id', 'style'], p: ['className', 'style'], h1: ['className', 'style'], h2: ['className', 'style'], h3: ['className', 'style'], h4: ['className', 'style'], h5: ['className', 'style'], h6: ['className', 'style'], span: ['className', 'style'], a: ['href', 'target', 'rel', 'className', 'title', 'style'], ul: ['className', 'style'], ol: ['className', 'style'], li: ['className', 'style'], strong: ['className', 'style'], b: ['className', 'style'], em: ['className', 'style'], i: ['className', 'style'], br: [], hr: ['className', 'style'], img: ['src', 'alt', 'width', 'height', 'className', 'style'], table: ['className', 'style'], thead: ['className', 'style'], tbody: ['className', 'style'], tr: ['className', 'style'], th: ['className', 'style'], td: ['className', 'style'], blockquote: ['className', 'style'], code: ['className', 'style'], pre: ['className', 'style'], small: ['className', 'style'], section: ['className', 'id', 'style'], article: ['className', 'id', 'style'], figure: ['className', 'style'], figcaption: ['className', 'style'], video: ['className', 'style', 'autoPlay', 'loop', 'muted', 'playsInline', 'poster'], source: ['src', 'type'], }; // Create a temporary DOM element to parse the HTML const parser = new DOMParser(); const doc = parser.parseFromString(html, 'text/html'); const body = doc.body; // Recursive function to convert DOM nodes to React elements function convertNode(node: Node, index: number): React.ReactNode { if (node.nodeType === Node.TEXT_NODE) { return node.textContent; } if (node.nodeType !== Node.ELEMENT_NODE) { return null; } const element = node as HTMLElement; const tagName = element.tagName.toLowerCase(); // Check if tag is allowed if (!allowedTags[tagName as keyof typeof allowedTags]) { // For unknown tags, just render their children return Array.from(node.childNodes).map((child, i) => convertNode(child, i)); } // Build props const props: any = { key: index }; const allowedProps = allowedTags[tagName as keyof typeof allowedTags]; // Helper function to convert style string to object const parseStyleString = (styleStr: string): React.CSSProperties => { const styles: React.CSSProperties = {}; if (!styleStr) return styles; styleStr.split(';').forEach(style => { const [key, value] = style.split(':').map(s => s.trim()); if (key && value) { // Convert camelCase for React const camelKey = key.replace(/-([a-z])/g, (g) => g[1].toUpperCase()); (styles as any)[camelKey] = value; } }); return styles; }; // Handle special cases for different element types if (tagName === 'a' && element.getAttribute('href')) { const href = element.getAttribute('href')!; const isExternal = href.startsWith('http') && !href.includes(window?.location?.hostname || ''); if (isExternal) { props.href = href; props.target = '_blank'; props.rel = 'noopener noreferrer'; } else { // For internal links, use Next.js Link const children = Array.from(node.childNodes).map((child, i) => convertNode(child, i)); return ( {children} ); } } if (tagName === 'img') { const src = element.getAttribute('src') || ''; const alt = element.getAttribute('alt') || ''; const widthAttr = element.getAttribute('width'); const heightAttr = element.getAttribute('height'); const dataWpImageId = element.getAttribute('data-wp-image-id'); // Handle WordPress image IDs if (dataWpImageId) { const media = getMediaById(parseInt(dataWpImageId)); if (media) { const width = widthAttr ? parseInt(widthAttr) : (media.width || 800); const height = heightAttr ? parseInt(heightAttr) : (media.height || 600); return ( {alt ); } } // Handle regular image URLs if (src) { const imageProps = getImageProps(src); const width = widthAttr ? parseInt(widthAttr) : imageProps.width; const height = heightAttr ? parseInt(heightAttr) : imageProps.height; // Check if it's an external URL if (src.startsWith('http')) { // For external images, use regular img tag return ( {alt} ); } return ( {alt ); } return null; } // Handle video elements if (tagName === 'video') { const videoProps: any = { key: index }; // Get sources const sources: React.ReactNode[] = []; Array.from(element.childNodes).forEach((child, i) => { if (child.nodeType === Node.ELEMENT_NODE && (child as HTMLElement).tagName.toLowerCase() === 'source') { const sourceEl = child as HTMLSourceElement; sources.push( ); } }); // Set video props if (element.className) videoProps.className = element.className; if (element.style.cssText) videoProps.style = parseStyleString(element.style.cssText); if (element.getAttribute('autoPlay')) videoProps.autoPlay = true; if (element.getAttribute('loop')) videoProps.loop = true; if (element.getAttribute('muted')) videoProps.muted = true; if (element.getAttribute('playsInline')) videoProps.playsInline = true; if (element.getAttribute('poster')) videoProps.poster = element.getAttribute('poster'); return ( ); } // Handle divs with special data attributes for backgrounds if (tagName === 'div' && element.getAttribute('data-color-overlay')) { const colorOverlay = element.getAttribute('data-color-overlay'); const overlayOpacity = parseFloat(element.getAttribute('data-overlay-opacity') || '0.5'); // Get the original classes and style const className = element.className; const style = parseStyleString(element.style.cssText); // Convert children const children = Array.from(node.childNodes).map((child, i) => convertNode(child, i)); return (
{children}
); } // Handle divs with video background data attributes if (tagName === 'div' && element.getAttribute('data-video-bg') === 'true') { const className = element.className; const style = parseStyleString(element.style.cssText); const mp4 = element.getAttribute('data-video-mp4'); const webm = element.getAttribute('data-video-webm'); const children = Array.from(node.childNodes).map((child, i) => convertNode(child, i)); return (
{mp4 || webm ? ( ) : null}
{children}
); } // Standard attribute mapping allowedProps.forEach(prop => { if (prop === 'style') { // Handle style separately if (element.style.cssText) { props.style = parseStyleString(element.style.cssText); } } else { const value = element.getAttribute(prop); if (value !== null) { props[prop] = value; } } }); // Handle className specifically if (element.className && allowedProps.includes('className')) { props.className = element.className; } // Convert children const children = Array.from(node.childNodes).map((child, i) => convertNode(child, i)); // Return React element return React.createElement(tagName, props, children); } // Convert all children of body return Array.from(body.childNodes).map((node, index) => convertNode(node, index)); } /** * Replace WordPress asset URLs with local paths */ function replaceWordPressAssets(html: string): string { try { // Use the data layer to replace URLs const assetMap = getAssetMap(); let processed = html; // Replace URLs in src attributes Object.entries(assetMap).forEach(([wpUrl, localPath]) => { // Handle both full URLs and relative paths const urlPattern = new RegExp(wpUrl.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'g'); processed = processed.replace(urlPattern, localPath as string); }); // Also handle any remaining WordPress URLs that might be in the format we expect processed = processed.replace(/https?:\/\/[^"'\s]+\/wp-content\/uploads\/\d{4}\/\d{2}\/([^"'\s]+)/g, (match, filename) => { // Try to find this file in our media const media = getMediaByUrl(match); if (media) { return media.localPath; } return match; }); return processed; } catch (error) { console.warn('Error replacing asset URLs:', error); return html; } } /** * Convert WordPress/Salient classes to Tailwind equivalents */ function convertWordPressClasses(html: string): string { const classMap: Record = { // Salient/Vc_row classes 'vc_row': 'flex flex-wrap -mx-4', 'vc_row-fluid': 'w-full', 'vc_col-sm-12': 'w-full px-4', 'vc_col-md-6': 'w-full md:w-1/2 px-4', 'vc_col-md-4': 'w-full md:w-1/3 px-4', 'vc_col-md-3': 'w-full md:w-1/4 px-4', 'vc_col-lg-6': 'w-full lg:w-1/2 px-4', 'vc_col-lg-4': 'w-full lg:w-1/3 px-4', 'vc_col-lg-3': 'w-full lg:w-1/4 px-4', // Typography 'wpb_wrapper': 'space-y-4', 'wpb_text_column': 'prose max-w-none', 'wpb_content_element': 'mb-8', 'wpb_single_image': 'my-4', 'wpb_heading': 'text-2xl font-bold mb-2', // Alignment 'text-left': 'text-left', 'text-center': 'text-center', 'text-right': 'text-right', 'alignleft': 'float-left mr-4 mb-4', 'alignright': 'float-right ml-4 mb-4', 'aligncenter': 'mx-auto', // Colors 'accent-color': 'text-primary', 'primary-color': 'text-primary', 'secondary-color': 'text-secondary', 'text-color': 'text-gray-800', 'light-text': 'text-gray-300', 'dark-text': 'text-gray-900', // Backgrounds 'bg-light': 'bg-gray-50', 'bg-light-gray': 'bg-gray-100', 'bg-dark': 'bg-gray-900', 'bg-dark-gray': 'bg-gray-800', 'bg-primary': 'bg-primary', 'bg-secondary': 'bg-secondary', 'bg-white': 'bg-white', 'bg-transparent': 'bg-transparent', // Buttons 'btn': 'inline-flex items-center justify-center px-4 py-2 rounded-lg font-semibold transition-colors duration-200', 'btn-primary': 'bg-primary text-white hover:bg-primary-dark', 'btn-secondary': 'bg-secondary text-white hover:bg-secondary-light', 'btn-outline': 'border-2 border-primary text-primary hover:bg-primary hover:text-white', 'btn-large': 'px-6 py-3 text-lg', 'btn-small': 'px-3 py-1 text-sm', // Containers 'container': 'container mx-auto px-4', 'container-fluid': 'w-full px-4', // Spacing 'mt-0': 'mt-0', 'mb-0': 'mb-0', 'mt-2': 'mt-2', 'mb-2': 'mb-2', 'mt-4': 'mt-4', 'mb-4': 'mb-4', 'mt-6': 'mt-6', 'mb-6': 'mb-6', 'mt-8': 'mt-8', 'mb-8': 'mb-8', 'mt-12': 'mt-12', 'mb-12': 'mb-12', // WordPress specific 'wp-caption': 'figure', 'wp-caption-text': 'figcaption text-sm text-gray-600 mt-2', 'alignnone': 'block', 'size-full': 'w-full', 'size-large': 'w-full max-w-3xl', 'size-medium': 'w-full max-w-xl', 'size-thumbnail': 'w-32 h-32', }; let processed = html; // Replace classes in HTML Object.entries(classMap).forEach(([wpClass, twClass]) => { // Handle class="..." with the class at the beginning const classRegex1 = new RegExp(`class=["']${wpClass}\\s+([^"']*)["']`, 'g'); processed = processed.replace(classRegex1, (match, rest) => { const newClasses = `${twClass} ${rest}`.trim().replace(/\s+/g, ' '); return `class="${newClasses}"`; }); // Handle class="..." with the class in the middle const classRegex2 = new RegExp(`class=["']([^"']*)\\s+${wpClass}\\s+([^"']*)["']`, 'g'); processed = processed.replace(classRegex2, (match, before, after) => { const newClasses = `${before} ${twClass} ${after}`.trim().replace(/\s+/g, ' '); return `class="${newClasses}"`; }); // Handle class="..." with the class at the end const classRegex3 = new RegExp(`class=["']([^"']*)\\s+${wpClass}["']`, 'g'); processed = processed.replace(classRegex3, (match, before) => { const newClasses = `${before} ${twClass}`.trim().replace(/\s+/g, ' '); return `class="${newClasses}"`; }); // Handle class="..." with only the class const classRegex4 = new RegExp(`class=["']${wpClass}["']`, 'g'); processed = processed.replace(classRegex4, `class="${twClass}"`); }); return processed; } /** * Get image props from source using the data layer */ function getImageProps(src: string): { src: string; width?: number; height?: number; alt?: string } { // Check if it's a data attribute for WordPress image ID if (src.startsWith('data-wp-image-id:')) { const imageId = src.replace('data-wp-image-id:', ''); const media = getMediaById(parseInt(imageId)); if (media) { return { src: media.localPath, width: media.width || 800, height: media.height || 600, alt: media.alt || '' }; } } // Try to find by URL const media = getMediaByUrl(src); if (media) { return { src: media.localPath, width: media.width || 800, height: media.height || 600, alt: media.alt || '' }; } // Check if it's already a local path if (src.startsWith('/media/')) { return { src, width: 800, height: 600 }; } // Return as-is for external URLs return { src, width: 800, height: 600 }; } /** * Process background attributes and convert to inline styles */ function processBackgroundAttributes(element: HTMLElement): { style?: string; className?: string } { const result: { style?: string; className?: string } = {}; const styles: string[] = []; const classes: string[] = []; // Check for data attributes from shortcodes const bgImage = element.getAttribute('data-bg-image'); const bgVideo = element.getAttribute('data-video-bg'); const videoMp4 = element.getAttribute('data-video-mp4'); const videoWebm = element.getAttribute('data-video-webm'); const parallax = element.getAttribute('data-parallax'); // Handle background image if (bgImage) { const media = getMediaById(parseInt(bgImage)); if (media) { styles.push(`background-image: url(${media.localPath})`); styles.push('background-size: cover'); styles.push('background-position: center'); classes.push('bg-cover', 'bg-center'); } } // Handle video background if (bgVideo === 'true' && (videoMp4 || videoWebm)) { // This will be handled by a separate video component // For now, we'll add a marker class classes.push('has-video-background'); if (videoMp4) element.setAttribute('data-video-mp4', videoMp4); if (videoWebm) element.setAttribute('data-video-webm', videoWebm); } // Handle parallax if (parallax === 'true') { classes.push('parallax-bg'); } // Handle inline styles from shortcode attributes const colorOverlay = element.getAttribute('color_overlay'); const overlayStrength = element.getAttribute('overlay_strength'); const topPadding = element.getAttribute('top_padding'); const bottomPadding = element.getAttribute('bottom_padding'); if (colorOverlay) { const opacity = overlayStrength ? parseFloat(overlayStrength) : 0.5; styles.push(`position: relative`); classes.push('relative'); // Add overlay as a child element marker element.setAttribute('data-color-overlay', colorOverlay); element.setAttribute('data-overlay-opacity', opacity.toString()); } if (topPadding) { styles.push(`padding-top: ${topPadding}`); } if (bottomPadding) { styles.push(`padding-bottom: ${bottomPadding}`); } if (styles.length > 0) { result.style = styles.join('; '); } if (classes.length > 0) { result.className = classes.join(' '); } return result; } // Sub-components for specific content types export const ContentBlock: React.FC<{ title?: string; content: string; className?: string; }> = ({ title, content, className = '' }) => (
{title &&

{title}

}
); export const RichText: React.FC<{ html: string; className?: string; }> = ({ html, className = '' }) => ( ); export default ContentRenderer;