import React from 'react'; import Image from 'next/image'; import Link from 'next/link'; import { cn } from '../../lib/utils'; import { processHTML, processShortcodes } from '../../lib/html-compat'; import { getMediaByUrl, getMediaById, getAssetMap } from '../../lib/data'; import * as cheerio from 'cheerio'; // Import components for WPBakery parsing import { Hero } from './Hero'; import { Section } from './Section'; import { Grid } from '../ui/Grid'; import { Card } from '../ui/Card'; import { FeaturedImage } from './FeaturedImage'; import { ContactForm } from '../ContactForm'; import { Slider, type Slide } from '../ui/Slider'; import { TestimonialCard, parseWpTestimonial } from '../cards/TestimonialCard'; import { Icon, parseWpIcon, IconButton, IconFeature } from '../ui/Icon'; import { Button } from '../ui/Button'; interface ContentRendererProps { content: string; className?: string; sanitize?: boolean; processAssets?: boolean; convertClasses?: boolean; parsePatterns?: boolean; // New prop for WPBakery parsing pageSlug?: string; // Optional page slug for page-specific parsing } 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, parsePatterns = true, // Enable WPBakery parsing by default }) => { // Process the HTML content const processedContent = React.useMemo(() => { let html = content; // Check for raw shortcodes and force processing if detected const shortcodeRegex = /\[[^\]]*\]/; if (shortcodeRegex.test(html)) { html = processShortcodes(html); } 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; // Check if WPBakery patterns should be parsed if (parsePatterns && containsWPBakeryPatterns(processedContent)) { return parseWPBakery(processedContent); } // 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()}
); }; /** * Check if content contains WPBakery patterns */ function containsWPBakeryPatterns(html: string): boolean { const $ = cheerio.load(html); return $('.vc-row').length > 0 || $('.vc-column').length > 0; } /** * Parse WPBakery/Salient HTML patterns to React components * Converts vc_row/vc_column structures to modern components * Enhanced with page-specific patterns based on detailed analysis */ function parseWPBakery(html: string): React.ReactNode[] { const $ = cheerio.load(html); const elements: React.ReactNode[] = []; // Process each vc-row $('.vc-row').each((i, rowEl) => { const $row = $(rowEl); const $cols = $row.find('> .vc-column'); const colCount = $cols.length; // Check for full-width background const isFullWidth = $row.hasClass('full-width-bg') || $row.hasClass('full-width') || $row.attr('data-full-width'); // Get background properties from data attributes const bgImage = $row.attr('data-bg-image') || $row.attr('style')?.match(/background-image:\s*url\(([^)]+)\)/)?.[1] || ''; const bgColor = $row.attr('bg_color') || $row.attr('data-bg-color'); const colorOverlay = $row.attr('color_overlay') || $row.attr('data-color-overlay'); const overlayStrength = $row.attr('overlay_strength') || $row.attr('data-overlay-strength'); const topPadding = $row.attr('top_padding'); const bottomPadding = $row.attr('bottom_padding'); const fullScreen = $row.attr('full_screen_row_position'); // Video background attributes - enhanced detection const videoMp4 = $row.attr('video_mp4') || $row.attr('data-video-mp4') || $row.find('[data-video-mp4]').attr('data-video-mp4'); const videoWebm = $row.attr('video_webm') || $row.attr('data-video-webm') || $row.find('[data-video-webm]').attr('data-video-webm'); // Check if row has video background indicators const hasVideoBg = $row.attr('data-video-bg') === 'true' || $row.hasClass('nectar-video-wrap') || !!(videoMp4?.trim()) || !!(videoWebm?.trim()); // Additional WordPress Salient props const enableGradient = $row.attr('enable_gradient') === 'true'; const gradientDirection = $row.attr('gradient_direction') || 'left_to_right'; const colorOverlay2 = $row.attr('color_overlay_2'); const parallaxBg = $row.attr('parallax_bg') === 'true'; const parallaxBgSpeed = $row.attr('parallax_bg_speed') || 'medium'; const bgImageAnimation = $row.attr('bg_image_animation') || 'none'; const textAlignment = $row.attr('text_align') || 'left'; const textColor = $row.attr('text_color') || 'dark'; const shapeType = $row.attr('shape_type'); const scenePosition = $row.attr('scene_position') || 'center'; // Get row text for pattern detection const rowText = $row.text(); // PATTERN 1: Hero sections (single column with h1/h2) const firstCol = $cols.eq(0); const $title = firstCol.find('h1, h2').first(); const hasHeroPattern = colCount === 1 && $title.length > 0; if (hasHeroPattern) { const title = $title.text().trim(); const subtitle = firstCol.find('p').first().text().trim(); const imgInCol = firstCol.find('img').first().attr('src'); const heroBg = bgImage || imgInCol || ''; // Clean up the title element from the column to avoid duplication const $clone = firstCol.clone(); $clone.find('h1, h2').remove(); $clone.find('p').first().remove(); const remainingContent = $clone.html()?.trim(); // Calculate overlay opacity const overlayOpacityValue = overlayStrength ? parseFloat(overlayStrength) : undefined; // Determine height based on full screen position let heroHeight: 'sm' | 'md' | 'lg' | 'xl' | 'full' | 'screen' = isFullWidth ? 'xl' : 'md'; if (fullScreen === 'middle' || fullScreen === 'top' || fullScreen === 'bottom') { heroHeight = 'screen'; } elements.push( ); // Add any remaining content from the hero column if (remainingContent) { elements.push(
); } $row.remove(); return; } // PATTERN 2: Contact Form if ($row.find('.frm_forms').length || $row.find('form').length) { elements.push(
); $row.remove(); return; } // PATTERN 3: Numbered Features with h6 + h4 (Home page style) if (colCount === 1 && $row.find('h6').length && $row.find('h4').length) { const $cols = $row.find('> .vc-column'); const features = $cols.map((j, colEl) => { const $col = $(colEl); const number = $col.find('h6').text().trim(); const title = $col.find('h4').text().trim(); const desc = $col.find('p').html() || ''; return (
{number}

{title}

); }).get(); elements.push(
{features}
); $row.remove(); return; } // PATTERN 4: Numbered Features with h6 + h3 (Terms page style) if (colCount === 1 && $row.find('h6').length && $row.find('h3').length) { const $cols = $row.find('> .vc-column'); const features = $cols.map((j, colEl) => { const $col = $(colEl); const number = $col.find('h6').text().trim(); const title = $col.find('h3').text().trim(); const paragraphs = $col.find('p').map((pIdx, pEl) => $(pEl).html() || '').get(); return (
{number}

{title}

{paragraphs.map((p, idx) => (
))}
); }).get(); elements.push(
{features}
); $row.remove(); return; } // PATTERN 5: Testimonials/Quotes (Team page style) const hasQuotes = rowText.includes('„') || rowText.includes('“') || rowText.includes('Expertise') || rowText.includes('Experience'); const hasTeamStructure = colCount === 1 && $row.find('h1, h2').length && rowText.includes('team'); if (hasQuotes || hasTeamStructure) { // Extract testimonial content const $h1 = $row.find('h1').first(); const $h2 = $row.find('h2').first(); const $p = $row.find('p').first(); const title = $h1.text().trim() || $h2.text().trim(); const quote = $p.text().trim(); if (quote && (quote.includes('„') || quote.includes('Expertise') || quote.includes('connect energy'))) { elements.push(
{title &&

{title}

}
{quote}
); $row.remove(); return; } } // PATTERN 6: PDF Download Link if ($row.find('a[href$=".pdf"]').length) { const $link = $row.find('a[href$=".pdf"]').first(); const href = $link.attr('href'); const text = $link.text().trim(); elements.push(
📄 {text || 'Download PDF'}
); $row.remove(); return; } // PATTERN 7: Contact Info Block if (rowText.includes('@') || rowText.includes('Raiffeisenstraße') || rowText.includes('KLZ Cables')) { elements.push(
); $row.remove(); return; } // PATTERN 8: Grid/Card Pattern (2-4 columns) if (colCount >= 2 && colCount <= 4) { // Check if this is a card grid (has titles and optional images) const hasCardContent = $cols.toArray().some(col => { const $col = $(col); return $col.find('h3, h4, h5').length > 0 || $col.find('img').length > 0; }); if (hasCardContent) { const cards = $cols.map((j, colEl) => { const $col = $(colEl); const imgSrc = $col.find('img').first().attr('src'); const titleEl = $col.find('h3, h4, h5').first(); const title = titleEl.text().trim(); const desc = $col.find('p').html() || ''; // Remove processed elements to avoid duplication const $clone = $col.clone(); $clone.find('img').remove(); $clone.find('h3, h4, h5').remove(); $clone.find('p').first().remove(); const remainingContent = $clone.html()?.trim(); return ( {imgSrc && (
)} {title &&

{title}

} {desc && } {remainingContent && (
)}
); }).get(); elements.push(
{cards}
); $row.remove(); return; } } // PATTERN 9: Nested Rows (Home page complex structure) if ($row.find('.vc-row').length > 0) { // This is a container row with nested content const innerHtml = $row.html(); if (innerHtml) { elements.push(
); } $row.remove(); return; } // PATTERN 10: Simple Content Row (h3 + p) const $h3 = $row.find('h3').first(); const $ps = $row.find('p'); if ($h3.length && $ps.length && colCount === 1) { const title = $h3.text().trim(); const content = $ps.map((pIdx, pEl) => $(pEl).html() || '').get().join('
'); // Calculate overlay opacity const overlayOpacityValue = overlayStrength ? parseFloat(overlayStrength) : 0.5; elements.push(

{title}

); $row.remove(); return; } // PATTERN 11: Empty or whitespace-only rows if (!rowText.trim() && colCount === 0) { $row.remove(); return; } // PATTERN 12: Generic content row with background (no specific pattern) // This handles rows with backgrounds that don't match other patterns if (bgImage || bgColor || colorOverlay || videoMp4 || videoWebm) { const overlayOpacityValue = overlayStrength ? parseFloat(overlayStrength) : 0.5; const innerHtml = $row.html(); if (innerHtml) { elements.push(
); $row.remove(); return; } } // PATTERN 13: Buttons (vc_btn, .btn classes) const $buttons = $row.find('a[class*="btn"], a.vc_btn, button.vc_btn'); if ($buttons.length > 0) { const buttons = $buttons.map((btnIdx, btnEl) => { const $btn = $(btnEl); const text = $btn.text().trim(); const href = $btn.attr('href'); const classes = $btn.attr('class') || ''; // Determine variant from classes let variant: 'primary' | 'secondary' | 'outline' | 'ghost' = 'primary'; if (classes.includes('btn-outline') || classes.includes('vc_btn-outline')) variant = 'outline'; if (classes.includes('btn-secondary') || classes.includes('vc_btn-secondary')) variant = 'secondary'; if (classes.includes('btn-ghost') || classes.includes('vc_btn-ghost')) variant = 'ghost'; // Determine size let size: 'sm' | 'md' | 'lg' = 'md'; if (classes.includes('btn-large') || classes.includes('vc_btn-lg')) size = 'lg'; if (classes.includes('btn-small') || classes.includes('vc_btn-sm')) size = 'sm'; return ( ); }).get(); if (buttons.length > 0) { const overlayOpacityValue = overlayStrength ? parseFloat(overlayStrength) : 0.5; elements.push(
1 ? 'justify-center' : 'justify-start' )}> {buttons}
); $row.remove(); return; } } // PATTERN 14: Slider/Carousel (nectar_slider, vc_row with slider class) if ($row.hasClass('nectar-slider') || $row.hasClass('vc_row-slider') || $row.find('.nectar-slider').length > 0) { const slides: Slide[] = []; // Look for slide items within the row $row.find('.nectar-slide, .vc-slide, .slide-item').each((slideIdx, slideEl) => { const $slide = $(slideEl); const title = $slide.find('h1, h2, h3, h4').first().text().trim(); const subtitle = $slide.find('h5, h6').first().text().trim(); const description = $slide.find('p').first().text().trim(); const imgSrc = $slide.find('img').first().attr('src') || $slide.attr('data-bg-image'); const ctaLink = $slide.find('a').first().attr('href'); const ctaText = $slide.find('a').first().text().trim(); if (title || description || imgSrc) { slides.push({ id: `slide-${i}-${slideIdx}`, title, subtitle, description, image: imgSrc ? replaceUrlInAttribute(imgSrc) : undefined, ctaLink: ctaLink ? replaceUrlInAttribute(ctaLink) : undefined, ctaText: ctaText || 'Learn More' }); } }); // Also check for simple content that could be slides if (slides.length === 0 && colCount >= 2) { $cols.each((colIdx, colEl) => { const $col = $(colEl); const title = $col.find('h2, h3, h4').first().text().trim(); const description = $col.find('p').first().text().trim(); const imgSrc = $col.find('img').first().attr('src'); if (title || description || imgSrc) { slides.push({ id: `slide-${i}-${colIdx}`, title, description, image: imgSrc ? replaceUrlInAttribute(imgSrc) : undefined }); } }); } if (slides.length > 0) { elements.push(
); $row.remove(); return; } } // PATTERN 14: Testimonials (quote blocks, testimonial divs) const hasTestimonialQuotes = rowText.includes('„') || rowText.includes('“') || rowText.includes('"') || rowText.includes('Expertise') || rowText.includes('Experience') || rowText.includes('recommend'); const hasTestimonialStructure = $row.find('blockquote, .testimonial, .wpb_testimonial').length > 0; if (hasTestimonialQuotes || hasTestimonialStructure) { const testimonialElements: React.ReactNode[] = []; // Look for individual testimonial items $row.find('.testimonial, .wpb_testimonial, blockquote').each((tIdx, tEl) => { const $t = $(tEl); const quote = $t.find('p, .quote').first().text().trim() || $t.text().trim(); const author = $t.find('.author, .name, h4, h5').first().text().trim(); const role = $t.find('.role, .position').first().text().trim(); const company = $t.find('.company').first().text().trim(); const avatar = $t.find('img').first().attr('src'); const ratingMatch = $t.text().match(/(\d+(\.\d+)?)\s*\/\s*5/); const rating = ratingMatch ? parseFloat(ratingMatch[1]) : 0; if (quote) { testimonialElements.push( ); } }); // If no structured testimonials found, create from quote content if (testimonialElements.length === 0 && hasTestimonialQuotes) { const quote = $row.find('p').first().text().trim(); const title = $row.find('h1, h2, h3, h4').first().text().trim(); if (quote) { testimonialElements.push( ); } } if (testimonialElements.length > 0) { elements.push(
{testimonialElements.length === 1 ? ( testimonialElements[0] ) : (
{testimonialElements}
)}
); $row.remove(); return; } } // PATTERN 15: Icons (vc_icon, .icon classes, font-awesome) const $icons = $row.find('[class*="vc_icon"], [class*="fa-"], .icon-item'); if ($icons.length > 0 && rowText.length < 200) { // Only if minimal text const iconFeatures: React.ReactNode[] = []; // Try to find icon features in columns if (colCount >= 2) { $cols.each((colIdx, colEl) => { const $col = $(colEl); const $iconEl = $col.find('[class*="vc_icon"], [class*="fa-"], i[class*="fa-"]').first(); const iconClass = $iconEl.attr('class') || ''; const title = $col.find('h3, h4, h5, h6').first().text().trim(); const description = $col.find('p').first().text().trim(); if (iconClass && (title || description)) { const iconProps = parseWpIcon(iconClass); iconFeatures.push( ); } }); } // Also check for inline icons if (iconFeatures.length === 0) { $icons.each((iconIdx, iconEl) => { const $icon = $(iconEl); const iconClass = $icon.attr('class') || ''; const text = $icon.text().trim() || $icon.next().text().trim(); if (iconClass.includes('fa-') || iconClass.includes('vc_icon')) { const iconProps = parseWpIcon(iconClass); iconFeatures.push(
{text && {text}}
); } }); } if (iconFeatures.length > 0) { elements.push(
= 3 ? 'md:grid md:grid-cols-3 md:gap-6 md:space-y-0' : '' )}> {iconFeatures}
); $row.remove(); return; } } // PATTERN 16: External Resource Links (vlp-link-container) const $vlpLinks = $row.find('.vlp-link-container'); if ($vlpLinks.length > 0) { const linkCards: React.ReactNode[] = []; $vlpLinks.each((linkIdx, linkEl) => { const $link = $(linkEl); const href = $link.find('a').first().attr('href') || ''; const title = $link.find('.vlp-link-title').first().text().trim() || $link.find('a').first().text().trim(); const summary = $link.find('.vlp-link-summary').first().text().trim(); const imgSrc = $link.find('img').first().attr('src'); if (href && title) { linkCards.push( {imgSrc && (
)}

{title}

{summary &&

{summary}

}
🔗 {new URL(href).hostname}
); } }); if (linkCards.length > 0) { elements.push(

Related Resources

{linkCards}
); $row.remove(); return; } } // PATTERN 17: Technical Specification Tables const $tables = $row.find('table'); if ($tables.length > 0) { const tableElements: React.ReactNode[] = []; $tables.each((tableIdx, tableEl) => { const $table = $(tableEl); const $rows = $table.find('tr'); // Check if this is a spec table (property/value format) const isSpecTable = $rows.length > 2 && $rows.eq(0).text().includes('Property'); if (isSpecTable) { const specs: { property: string; value: string }[] = []; $rows.each((rowIdx, rowEl) => { if (rowIdx === 0) return; // Skip header const $row = $(rowEl); const $cells = $row.find('td'); if ($cells.length >= 2) { specs.push({ property: $cells.eq(0).text().trim(), value: $cells.eq(1).text().trim() }); } }); if (specs.length > 0) { tableElements.push(

Technical Specifications

{specs.map((spec, idx) => (
{spec.property}
{spec.value}
))}
); } } else { // Regular table - use default HTML rendering const tableHtml = $table.prop('outerHTML'); if (tableHtml) { tableElements.push(
); } } }); if (tableElements.length > 0) { elements.push(
{tableElements}
); $row.remove(); return; } } // PATTERN 18: FAQ Sections const $questions = $row.find('h3, h4').filter((idx, el) => { const text = $(el).text().trim(); return text.endsWith('?') || text.toLowerCase().includes('faq') || text.toLowerCase().includes('question'); }); if ($questions.length > 0) { const faqItems: React.ReactNode[] = []; $questions.each((qIdx, qEl) => { const $q = $(qEl); const question = $q.text().trim(); const $nextP = $q.next('p'); const answer = $nextP.text().trim(); if (question && answer) { faqItems.push(
{question}

{answer}

); } }); if (faqItems.length > 0) { elements.push(

Frequently Asked Questions

{faqItems}
); $row.remove(); return; } } // PATTERN 19: Call-to-Action (CTA) Sections const $ctaText = $row.text(); const isCTA = $ctaText.includes('👉') || $ctaText.includes('Contact Us') || $ctaText.includes('Get in touch') || $ctaText.includes('Send your inquiry') || ($row.find('a[href*="contact"]').length > 0 && $row.find('h2, h3').length > 0); if (isCTA && colCount <= 2) { const $title = $row.find('h2, h3').first(); const $desc = $row.find('p').first(); const $button = $row.find('a').first(); const title = $title.text().trim(); const description = $desc.text().trim(); const buttonText = $button.text().trim() || 'Contact Us'; const buttonHref = $button.attr('href') || '/contact'; if (title) { const overlayOpacityValue = overlayStrength ? parseFloat(overlayStrength) : 0.5; elements.push(

{title}

{description &&

{description}

}
); $row.remove(); return; } } // PATTERN 20: Quote/Blockquote Sections const $blockquote = $row.find('blockquote').first(); if ($blockquote.length > 0 && colCount === 1) { const quote = $blockquote.text().trim(); const $cite = $blockquote.find('cite'); const author = $cite.text().trim(); if (quote) { const overlayOpacityValue = overlayStrength ? parseFloat(overlayStrength) : 0.5; elements.push(
"
{quote}
{author && ( — {author} )}
); $row.remove(); return; } } // PATTERN 21: Numbered List with Icons const $listItems = $row.find('li'); if ($listItems.length >= 3 && $row.find('i[class*="fa-"]').length > 0) { const items: React.ReactNode[] = []; $listItems.each((liIdx, liEl) => { const $li = $(liEl); const $icon = $li.find('i[class*="fa-"]').first(); const iconClass = $icon.attr('class') || ''; const text = $li.text().trim().replace(/\s+/g, ' '); if (text) { const iconProps = parseWpIcon(iconClass); items.push(

{text}

); } }); if (items.length > 0) { elements.push(
{items}
); $row.remove(); return; } } // PATTERN 22: Video Background Row (nectar-video-wrap or data-video-bg) if (hasVideoBg) { const overlayOpacityValue = overlayStrength ? parseFloat(overlayStrength) : 0.5; const innerHtml = $row.html(); if (innerHtml) { elements.push(
); $row.remove(); return; } } // PATTERN 23: Video Embed with Text const $video = $row.find('video').first(); if ($video.length > 0 && colCount === 1) { const videoSrc = $video.attr('src') || $video.find('source').first().attr('src'); const $title = $row.find('h2, h3').first(); const $desc = $row.find('p').first(); if (videoSrc) { elements.push(
{$title.length > 0 &&

{$title.text().trim()}

} {$desc.length > 0 && }
); $row.remove(); return; } } // FALLBACK: Generic section with nested content const innerHtml = $row.html(); if (innerHtml) { elements.push(
); } // Remove processed row to avoid duplication $row.remove(); }); // Handle any remaining loose content const remainingHtml = $.html().trim(); if (remainingHtml) { elements.push(
); } return elements; } /** * Parse HTML string to React elements * This is a safe parser that only allows specific tags and attributes * Works in both server and client environments */ function parseHTMLToReact(html: string): React.ReactNode { // For server-side rendering, use a simple approach with dangerouslySetInnerHTML // The HTML has already been sanitized by processHTML, so it's safe if (typeof window === 'undefined') { return
; } // Client-side: use DOMParser for proper parsing // 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 originalHref = element.getAttribute('href')!; const href = replaceUrlInAttribute(originalHref); 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'); const srcset = element.getAttribute('srcset'); const sizes = element.getAttribute('sizes') || '(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 33vw'; // 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 (not a local asset) const isExternal = src.startsWith('http') && !src.includes('wp-content') && !src.includes('klz-cables'); if (isExternal) { // For external images, use regular img tag with srcset if available return ( {alt} ); } // For local images, use Next.js Image component // Note: Next.js Image doesn't support srcSet prop, it handles optimization automatically return ( {alt ); } return null; } // Handle video elements if (tagName === 'video') { const videoProps: any = { key: index }; // Get sources const sources: React.ReactNode[] = []; Array.from(node.childNodes).forEach((child, i) => { if (child.nodeType === Node.ELEMENT_NODE && (child as HTMLElement).tagName.toLowerCase() === 'source') { const sourceEl = child as HTMLSourceElement; // Replace asset URLs in source src const src = replaceUrlInAttribute(sourceEl.src); sources.push( ); } }); // Set video props if (element.className) videoProps.className = element.className; if (element.style.cssText) { const processedStyle = replaceAssetUrlsInStyle(element.style.cssText); videoProps.style = parseStyleString(processedStyle); } 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 = replaceUrlInAttribute(element.getAttribute('poster')); } // Ensure video is always fully visible if (!videoProps.style) videoProps.style = {}; videoProps.style.opacity = 1; 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 originalStyle = element.style.cssText; const processedStyle = replaceAssetUrlsInStyle(originalStyle); const style = parseStyleString(processedStyle); // 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 originalStyle = element.style.cssText; const processedStyle = replaceAssetUrlsInStyle(originalStyle); const style = parseStyleString(processedStyle); 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 with asset URL replacement if (element.style.cssText) { const originalStyle = element.style.cssText; const processedStyle = replaceAssetUrlsInStyle(originalStyle); props.style = parseStyleString(processedStyle); } } 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 * Handles img src, srcset, data-src, and background-image URLs */ function replaceWordPressAssets(html: string): string { try { const assetMap = getAssetMap(); let processed = html; // Helper function to replace a single URL const replaceUrl = (url: string): string => { if (!url) return url; // Check exact match in asset map if (assetMap[url]) { return assetMap[url]; } // Check for URL variations (http vs https, with/without www) const variations = [ url, url.replace('https://', 'http://'), url.replace('http://', 'https://'), url.replace('https://www.', 'https://'), url.replace('http://www.', 'http://'), url.replace('www.', ''), ]; for (const variation of variations) { if (assetMap[variation]) { return assetMap[variation]; } } // Fallback: try to match by filename const filename = url.split('/').pop(); if (filename) { for (const [wpUrl, localPath] of Object.entries(assetMap)) { if (wpUrl.includes(filename)) { return localPath; } } } return url; }; // 1. Replace img src attributes processed = processed.replace(/src=["']([^"']+)["']/gi, (match, url) => { const replaced = replaceUrl(url); return match.replace(url, replaced); }); // 2. Replace srcset attributes processed = processed.replace(/srcset=["']([^"']+)["']/gi, (match, srcset) => { const replaced = srcset.split(',').map((src: string) => { const [url, descriptor] = src.trim().split(/\s+/); const replacedUrl = replaceUrl(url); return descriptor ? `${replacedUrl} ${descriptor}` : replacedUrl; }).join(', '); return match.replace(srcset, replaced); }); // 3. Replace data-src attributes (lazy loading) processed = processed.replace(/data-src=["']([^"']+)["']/gi, (match, url) => { const replaced = replaceUrl(url); return match.replace(url, replaced); }); // 4. Replace background-image URLs in style attributes processed = processed.replace(/style=["']([^"']*background-image:\s*url\([^)]+\)[^"']*)["']/gi, (match, styleContent) => { const replaced = styleContent.replace(/url\(([^)]+)\)/gi, (urlMatch, url) => { // Remove quotes from URL if present const cleanUrl = url.replace(/^["']|["']$/g, ''); const replacedUrl = replaceUrl(cleanUrl); return `url(${replacedUrl})`; }); return match.replace(styleContent, replaced); }); // 5. Replace URLs in inline style attributes for background-image processed = processed.replace(/background-image:\s*url\(([^)]+)\)/gi, (match, url) => { const cleanUrl = url.replace(/^["']|["']$/g, ''); const replacedUrl = replaceUrl(cleanUrl); return `background-image: url(${replacedUrl})`; }); // 6. Replace URLs in CSS url() functions within style tags processed = processed.replace(/url\(([^)]+)\)/gi, (match, url) => { const cleanUrl = url.replace(/^["']|["']$/g, ''); // Only replace if it's a WordPress URL if (cleanUrl.includes('wp-content') || cleanUrl.includes('klz-cables')) { const replacedUrl = replaceUrl(cleanUrl); return `url(${replacedUrl})`; } return match; }); // 7. Replace href attributes for links to media files (PDFs, etc.) processed = processed.replace(/href=["']([^"']+)["']/gi, (match, url) => { // Only replace if it's a media file URL if (url.includes('wp-content/uploads') && !url.match(/\.(html?|php)$/)) { const replaced = replaceUrl(url); return match.replace(url, replaced); } return match; }); return processed; } catch (error) { console.warn('Error replacing asset URLs:', error); return html; } } /** * Convert WordPress/Salient classes to Tailwind equivalents * Note: vc-row and vc-column classes are preserved for pattern parsing */ function convertWordPressClasses(html: string): string { const classMap: Record = { // Salient/Vc_row classes - PRESERVED for pattern parsing // 'vc-row': 'flex flex-wrap -mx-4', // REMOVED - handled by parseWPBakery // 'vc_row-fluid': 'w-full', // REMOVED - handled by parseWPBakery // 'vc_col-sm-12': 'w-full px-4', // REMOVED - handled by parseWPBakery // 'vc_col-md-6': 'w-full md:w-1/2 px-4', // REMOVED - handled by parseWPBakery // 'vc_col-md-4': 'w-full md:w-1/3 px-4', // REMOVED - handled by parseWPBakery // 'vc_col-md-3': 'w-full md:w-1/4 px-4', // REMOVED - handled by parseWPBakery // 'vc_col-lg-6': 'w-full lg:w-1/2 px-4', // REMOVED - handled by parseWPBakery // 'vc_col-lg-4': 'w-full lg:w-1/3 px-4', // REMOVED - handled by parseWPBakery // 'vc_col-lg-3': 'w-full lg:w-1/4 px-4', // REMOVED - handled by parseWPBakery // 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 * Enhanced to handle asset URL replacement and responsive images */ 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 using asset map const assetMap = getAssetMap(); // Check exact match if (assetMap[src]) { const media = getMediaByUrl(src); return { src: assetMap[src], width: media?.width || 800, height: media?.height || 600, alt: media?.alt || '' }; } // Check for URL variations const variations = [ src, src.replace('https://', 'http://'), src.replace('http://', 'https://'), src.replace('https://www.', 'https://'), src.replace('http://www.', 'http://'), src.replace('www.', ''), ]; for (const variation of variations) { if (assetMap[variation]) { const media = getMediaByUrl(variation); return { src: assetMap[variation], width: media?.width || 800, height: media?.height || 600, alt: media?.alt || '' }; } } // Try to find by filename const filename = src.split('/').pop(); if (filename) { for (const [wpUrl, localPath] of Object.entries(assetMap)) { if (wpUrl.includes(filename)) { const media = getMediaByUrl(wpUrl); return { src: 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 * Enhanced to handle asset URL replacement */ 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; } /** * Replace asset URLs in style strings */ function replaceAssetUrlsInStyle(style: string): string { const assetMap = getAssetMap(); return style.replace(/url\(([^)]+)\)/gi, (match, url) => { const cleanUrl = url.replace(/^["']|["']$/g, ''); // Check exact match if (assetMap[cleanUrl]) { return `url(${assetMap[cleanUrl]})`; } // Check variations const variations = [ cleanUrl, cleanUrl.replace('https://', 'http://'), cleanUrl.replace('http://', 'https://'), cleanUrl.replace('https://www.', 'https://'), cleanUrl.replace('http://www.', 'http://'), cleanUrl.replace('www.', ''), ]; for (const variation of variations) { if (assetMap[variation]) { return `url(${assetMap[variation]})`; } } // Check by filename const filename = cleanUrl.split('/').pop(); if (filename) { for (const [wpUrl, localPath] of Object.entries(assetMap)) { if (wpUrl.includes(filename)) { return `url(${localPath})`; } } } return match; }); } /** * Replace srcset URLs with local paths */ function replaceSrcset(srcset: string): string { const assetMap = getAssetMap(); return srcset.split(',').map((src) => { const [url, descriptor] = src.trim().split(/\s+/); // Check exact match if (assetMap[url]) { return descriptor ? `${assetMap[url]} ${descriptor}` : assetMap[url]; } // Check variations const variations = [ url, url.replace('https://', 'http://'), url.replace('http://', 'https://'), url.replace('https://www.', 'https://'), url.replace('http://www.', 'http://'), url.replace('www.', ''), ]; for (const variation of variations) { if (assetMap[variation]) { return descriptor ? `${assetMap[variation]} ${descriptor}` : assetMap[variation]; } } // Check by filename const filename = url.split('/').pop(); if (filename) { for (const [wpUrl, localPath] of Object.entries(assetMap)) { if (wpUrl.includes(filename)) { return descriptor ? `${localPath} ${descriptor}` : localPath; } } } return src; }).join(', '); } /** * Replace a single URL with local path */ function replaceUrlInAttribute(url: string | null): string { if (!url) return ''; const assetMap = getAssetMap(); // Check exact match if (assetMap[url]) { return assetMap[url]; } // Check variations const variations = [ url, url.replace('https://', 'http://'), url.replace('http://', 'https://'), url.replace('https://www.', 'https://'), url.replace('http://www.', 'http://'), url.replace('www.', ''), ]; for (const variation of variations) { if (assetMap[variation]) { return assetMap[variation]; } } // Check by filename const filename = url.split('/').pop(); if (filename) { for (const [wpUrl, localPath] of Object.entries(assetMap)) { if (wpUrl.includes(filename)) { return localPath; } } } return url; } // 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;