2118 lines
72 KiB
TypeScript
2118 lines
72 KiB
TypeScript
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<ContentRendererProps> = ({
|
|
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 (
|
|
<div className={cn(
|
|
'prose prose-lg max-w-none',
|
|
'prose-headings:font-bold prose-headings:tracking-tight',
|
|
'prose-h1:text-3xl prose-h1:md:text-4xl prose-h1:mb-4',
|
|
'prose-h2:text-2xl prose-h2:md:text-3xl prose-h2:mb-3',
|
|
'prose-h3:text-xl prose-h3:md:text-2xl prose-h3:mb-2',
|
|
'prose-p:text-gray-700 prose-p:leading-relaxed prose-p:mb-4',
|
|
'prose-a:text-primary prose-a:hover:text-primary-dark prose-a:underline',
|
|
'prose-ul:list-disc prose-ul:pl-6 prose-ul:mb-4',
|
|
'prose-ol:list-decimal prose-ol:pl-6 prose-ol:mb-4',
|
|
'prose-li:mb-2 prose-li:marker:text-primary',
|
|
'prose-strong:font-bold prose-strong:text-gray-900',
|
|
'prose-em:italic prose-em:text-gray-700',
|
|
'prose-table:w-full prose-table:border-collapse prose-table:my-4',
|
|
'prose-th:bg-gray-100 prose-th:font-bold prose-th:p-2 prose-th:text-left',
|
|
'prose-td:p-2 prose-td:border prose-td:border-gray-200',
|
|
'prose-img:rounded-lg prose-img:shadow-md prose-img:my-4',
|
|
'prose-blockquote:border-l-4 prose-blockquote:border-primary prose-blockquote:pl-4 prose-blockquote:italic prose-blockquote:bg-gray-50 prose-blockquote:py-2 prose-blockquote:my-4',
|
|
className
|
|
)}>
|
|
{renderContent()}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
/**
|
|
* 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(
|
|
<Hero
|
|
key={`hero-${i}`}
|
|
title={title}
|
|
subtitle={subtitle || undefined}
|
|
backgroundImage={heroBg && !hasVideoBg ? replaceUrlInAttribute(heroBg) : undefined}
|
|
height={heroHeight}
|
|
overlay={!!heroBg}
|
|
backgroundColor={bgColor}
|
|
colorOverlay={colorOverlay}
|
|
overlayOpacity={overlayOpacityValue}
|
|
enableGradient={enableGradient}
|
|
gradientDirection={gradientDirection as any}
|
|
colorOverlay2={colorOverlay2}
|
|
parallaxBg={parallaxBg}
|
|
parallaxBgSpeed={parallaxBgSpeed as any}
|
|
bgImageAnimation={bgImageAnimation as any}
|
|
topPadding={topPadding}
|
|
bottomPadding={bottomPadding}
|
|
textAlignment={textAlignment as any}
|
|
textColor={textColor as any}
|
|
shapeType={shapeType}
|
|
scenePosition={scenePosition as any}
|
|
fullScreenRowPosition={fullScreen as any}
|
|
videoMp4={videoMp4 ? replaceUrlInAttribute(videoMp4) : undefined}
|
|
videoWebm={videoWebm ? replaceUrlInAttribute(videoWebm) : undefined}
|
|
/>
|
|
);
|
|
|
|
// Add any remaining content from the hero column
|
|
if (remainingContent) {
|
|
elements.push(
|
|
<Section key={`hero-content-${i}`} padding="lg">
|
|
<ContentRenderer content={remainingContent} parsePatterns={false} />
|
|
</Section>
|
|
);
|
|
}
|
|
|
|
$row.remove();
|
|
return;
|
|
}
|
|
|
|
// PATTERN 2: Contact Form
|
|
if ($row.find('.frm_forms').length || $row.find('form').length) {
|
|
elements.push(
|
|
<Section key={`form-${i}`} padding="lg">
|
|
<ContactForm />
|
|
</Section>
|
|
);
|
|
|
|
$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 (
|
|
<div key={`feature-${j}`} className="flex gap-4 items-start">
|
|
<div className="text-4xl font-bold text-primary">{number}</div>
|
|
<div className="flex-1">
|
|
<h4 className="text-xl font-bold mb-2">{title}</h4>
|
|
<ContentRenderer content={desc} parsePatterns={false} />
|
|
</div>
|
|
</div>
|
|
);
|
|
}).get();
|
|
|
|
elements.push(
|
|
<Section key={`features-${i}`} padding="lg">
|
|
<div className="space-y-6">
|
|
{features}
|
|
</div>
|
|
</Section>
|
|
);
|
|
|
|
$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 (
|
|
<div key={`feature-${j}`} className="mb-6">
|
|
<div className="flex items-start gap-4">
|
|
<div className="text-3xl font-bold text-primary">{number}</div>
|
|
<div className="flex-1">
|
|
<h3 className="text-2xl font-bold mb-2">{title}</h3>
|
|
</div>
|
|
</div>
|
|
{paragraphs.map((p, idx) => (
|
|
<div key={`p-${idx}`} className="ml-11 mt-2">
|
|
<ContentRenderer content={p} parsePatterns={false} />
|
|
</div>
|
|
))}
|
|
</div>
|
|
);
|
|
}).get();
|
|
|
|
elements.push(
|
|
<Section key={`numbered-terms-${i}`} padding="lg">
|
|
{features}
|
|
</Section>
|
|
);
|
|
|
|
$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(
|
|
<Section key={`testimonial-${i}`} padding="lg">
|
|
<div className="bg-gray-50 p-6 rounded-lg border-l-4 border-primary">
|
|
{title && <h3 className="text-xl font-bold mb-2">{title}</h3>}
|
|
<blockquote className="text-lg italic text-gray-700">
|
|
{quote}
|
|
</blockquote>
|
|
</div>
|
|
</Section>
|
|
);
|
|
|
|
$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(
|
|
<Section key={`pdf-${i}`} padding="lg">
|
|
<div className="bg-blue-50 p-4 rounded-lg">
|
|
<a
|
|
href={replaceUrlInAttribute(href)}
|
|
className="text-primary hover:underline font-semibold"
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
>
|
|
📄 {text || 'Download PDF'}
|
|
</a>
|
|
</div>
|
|
</Section>
|
|
);
|
|
|
|
$row.remove();
|
|
return;
|
|
}
|
|
|
|
// PATTERN 7: Contact Info Block
|
|
if (rowText.includes('@') || rowText.includes('Raiffeisenstraße') || rowText.includes('KLZ Cables')) {
|
|
elements.push(
|
|
<Section key={`contact-info-${i}`} padding="lg">
|
|
<div className="bg-gray-100 p-6 rounded-lg">
|
|
<ContentRenderer content={$row.html()} parsePatterns={false} />
|
|
</div>
|
|
</Section>
|
|
);
|
|
|
|
$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 (
|
|
<Card key={`card-${j}`} variant="elevated" padding="md" hoverable>
|
|
{imgSrc && (
|
|
<div className="mb-4">
|
|
<FeaturedImage
|
|
src={replaceUrlInAttribute(imgSrc)}
|
|
alt={title || 'Card image'}
|
|
size="full"
|
|
aspectRatio="16:9"
|
|
/>
|
|
</div>
|
|
)}
|
|
{title && <h4 className="text-xl font-bold mb-2">{title}</h4>}
|
|
{desc && <ContentRenderer content={desc} parsePatterns={false} />}
|
|
{remainingContent && (
|
|
<div className="mt-4">
|
|
<ContentRenderer content={remainingContent} parsePatterns={false} />
|
|
</div>
|
|
)}
|
|
</Card>
|
|
);
|
|
}).get();
|
|
|
|
elements.push(
|
|
<Section key={`grid-${i}`} padding="lg">
|
|
<Grid cols={colCount as 2 | 3 | 4} gap="lg">
|
|
{cards}
|
|
</Grid>
|
|
</Section>
|
|
);
|
|
|
|
$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(
|
|
<Section key={`nested-${i}`} padding="lg">
|
|
<ContentRenderer content={innerHtml} parsePatterns={true} />
|
|
</Section>
|
|
);
|
|
}
|
|
|
|
$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('<br/>');
|
|
|
|
// Calculate overlay opacity
|
|
const overlayOpacityValue = overlayStrength ? parseFloat(overlayStrength) : 0.5;
|
|
|
|
elements.push(
|
|
<Section
|
|
key={`content-${i}`}
|
|
padding="lg"
|
|
backgroundImage={bgImage && !hasVideoBg ? replaceUrlInAttribute(bgImage) : undefined}
|
|
backgroundColor={bgColor}
|
|
colorOverlay={colorOverlay}
|
|
overlayOpacity={overlayOpacityValue}
|
|
enableGradient={enableGradient}
|
|
gradientDirection={gradientDirection as any}
|
|
colorOverlay2={colorOverlay2}
|
|
parallaxBg={parallaxBg}
|
|
parallaxBgSpeed={parallaxBgSpeed as any}
|
|
bgImageAnimation={bgImageAnimation as any}
|
|
topPadding={topPadding}
|
|
bottomPadding={bottomPadding}
|
|
textAlignment={textAlignment as any}
|
|
textColor={textColor as any}
|
|
shapeType={shapeType}
|
|
scenePosition={scenePosition as any}
|
|
fullScreenRowPosition={fullScreen as any}
|
|
videoMp4={videoMp4 ? replaceUrlInAttribute(videoMp4) : undefined}
|
|
videoWebm={videoWebm ? replaceUrlInAttribute(videoWebm) : undefined}
|
|
>
|
|
<h3 className="text-2xl font-bold mb-4">{title}</h3>
|
|
<ContentRenderer content={content} parsePatterns={false} />
|
|
</Section>
|
|
);
|
|
|
|
$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(
|
|
<Section
|
|
key={`bg-section-${i}`}
|
|
padding="lg"
|
|
backgroundImage={bgImage && !hasVideoBg ? replaceUrlInAttribute(bgImage) : undefined}
|
|
backgroundColor={bgColor}
|
|
colorOverlay={colorOverlay}
|
|
overlayOpacity={overlayOpacityValue}
|
|
enableGradient={enableGradient}
|
|
gradientDirection={gradientDirection as any}
|
|
colorOverlay2={colorOverlay2}
|
|
parallaxBg={parallaxBg}
|
|
parallaxBgSpeed={parallaxBgSpeed as any}
|
|
bgImageAnimation={bgImageAnimation as any}
|
|
topPadding={topPadding}
|
|
bottomPadding={bottomPadding}
|
|
textAlignment={textAlignment as any}
|
|
textColor={textColor as any}
|
|
shapeType={shapeType}
|
|
scenePosition={scenePosition as any}
|
|
fullScreenRowPosition={fullScreen as any}
|
|
videoMp4={videoMp4 ? replaceUrlInAttribute(videoMp4) : undefined}
|
|
videoWebm={videoWebm ? replaceUrlInAttribute(videoWebm) : undefined}
|
|
>
|
|
<ContentRenderer content={innerHtml} parsePatterns={true} />
|
|
</Section>
|
|
);
|
|
|
|
$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 (
|
|
<Button
|
|
key={`btn-${btnIdx}`}
|
|
variant={variant}
|
|
size={size}
|
|
onClick={() => href && (window.location.href = replaceUrlInAttribute(href))}
|
|
className={classes.includes('btn-full') ? 'w-full' : ''}
|
|
>
|
|
{text || 'Click Here'}
|
|
</Button>
|
|
);
|
|
}).get();
|
|
|
|
if (buttons.length > 0) {
|
|
const overlayOpacityValue = overlayStrength ? parseFloat(overlayStrength) : 0.5;
|
|
|
|
elements.push(
|
|
<Section
|
|
key={`buttons-${i}`}
|
|
padding="lg"
|
|
backgroundImage={bgImage && !hasVideoBg ? replaceUrlInAttribute(bgImage) : undefined}
|
|
backgroundColor={bgColor}
|
|
colorOverlay={colorOverlay}
|
|
overlayOpacity={overlayOpacityValue}
|
|
enableGradient={enableGradient}
|
|
gradientDirection={gradientDirection as any}
|
|
colorOverlay2={colorOverlay2}
|
|
parallaxBg={parallaxBg}
|
|
parallaxBgSpeed={parallaxBgSpeed as any}
|
|
bgImageAnimation={bgImageAnimation as any}
|
|
topPadding={topPadding}
|
|
bottomPadding={bottomPadding}
|
|
textAlignment={textAlignment as any}
|
|
textColor={textColor as any}
|
|
shapeType={shapeType}
|
|
scenePosition={scenePosition as any}
|
|
fullScreenRowPosition={fullScreen as any}
|
|
videoMp4={videoMp4 ? replaceUrlInAttribute(videoMp4) : undefined}
|
|
videoWebm={videoWebm ? replaceUrlInAttribute(videoWebm) : undefined}
|
|
>
|
|
<div className={cn(
|
|
'flex flex-wrap gap-3',
|
|
buttons.length > 1 ? 'justify-center' : 'justify-start'
|
|
)}>
|
|
{buttons}
|
|
</div>
|
|
</Section>
|
|
);
|
|
|
|
$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(
|
|
<Section key={`slider-${i}`} padding="none">
|
|
<Slider
|
|
slides={slides}
|
|
autoplay={true}
|
|
autoplayInterval={6000}
|
|
variant={colCount === 1 ? 'fullscreen' : 'default'}
|
|
/>
|
|
</Section>
|
|
);
|
|
|
|
$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(
|
|
<TestimonialCard
|
|
key={`testimonial-${tIdx}`}
|
|
quote={quote}
|
|
author={author}
|
|
role={role}
|
|
company={company}
|
|
avatar={avatar ? replaceUrlInAttribute(avatar) : undefined}
|
|
rating={rating}
|
|
variant={colCount === 1 ? 'highlight' : 'default'}
|
|
/>
|
|
);
|
|
}
|
|
});
|
|
|
|
// 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(
|
|
<TestimonialCard
|
|
key={`testimonial-${i}`}
|
|
quote={quote}
|
|
author={title}
|
|
variant="default"
|
|
/>
|
|
);
|
|
}
|
|
}
|
|
|
|
if (testimonialElements.length > 0) {
|
|
elements.push(
|
|
<Section key={`testimonials-${i}`} padding="lg">
|
|
{testimonialElements.length === 1 ? (
|
|
testimonialElements[0]
|
|
) : (
|
|
<div className={cn(
|
|
'grid gap-6',
|
|
testimonialElements.length === 2 ? 'md:grid-cols-2' : 'md:grid-cols-2 lg:grid-cols-3'
|
|
)}>
|
|
{testimonialElements}
|
|
</div>
|
|
)}
|
|
</Section>
|
|
);
|
|
|
|
$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(
|
|
<IconFeature
|
|
key={`icon-feature-${colIdx}`}
|
|
icon={iconProps.name}
|
|
title={title || 'Feature'}
|
|
description={description}
|
|
iconPosition="left"
|
|
/>
|
|
);
|
|
}
|
|
});
|
|
}
|
|
|
|
// 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(
|
|
<div key={`inline-icon-${iconIdx}`} className="flex items-center gap-3 mb-3">
|
|
<Icon {...iconProps} />
|
|
{text && <span>{text}</span>}
|
|
</div>
|
|
);
|
|
}
|
|
});
|
|
}
|
|
|
|
if (iconFeatures.length > 0) {
|
|
elements.push(
|
|
<Section key={`icons-${i}`} padding="lg">
|
|
<div className={cn(
|
|
'space-y-4',
|
|
colCount >= 3 ? 'md:grid md:grid-cols-3 md:gap-6 md:space-y-0' : ''
|
|
)}>
|
|
{iconFeatures}
|
|
</div>
|
|
</Section>
|
|
);
|
|
|
|
$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(
|
|
<Card key={`vlp-${linkIdx}`} variant="bordered" padding="md" hoverable>
|
|
<a
|
|
href={replaceUrlInAttribute(href)}
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
className="block hover:no-underline"
|
|
>
|
|
{imgSrc && (
|
|
<div className="mb-3">
|
|
<FeaturedImage
|
|
src={replaceUrlInAttribute(imgSrc)}
|
|
alt={title}
|
|
size="sm"
|
|
aspectRatio="1:1"
|
|
className="rounded-md"
|
|
/>
|
|
</div>
|
|
)}
|
|
<h4 className="text-lg font-bold mb-2 text-primary">{title}</h4>
|
|
{summary && <p className="text-sm text-gray-600">{summary}</p>}
|
|
<div className="mt-2 text-xs text-gray-500 flex items-center gap-1">
|
|
<span>🔗</span>
|
|
<span className="truncate">{new URL(href).hostname}</span>
|
|
</div>
|
|
</a>
|
|
</Card>
|
|
);
|
|
}
|
|
});
|
|
|
|
if (linkCards.length > 0) {
|
|
elements.push(
|
|
<Section key={`vlp-${i}`} padding="lg">
|
|
<h3 className="text-2xl font-bold mb-4">Related Resources</h3>
|
|
<Grid cols={Math.min(linkCards.length, 3) as 2 | 3 | 4} gap="md">
|
|
{linkCards}
|
|
</Grid>
|
|
</Section>
|
|
);
|
|
|
|
$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(
|
|
<div key={`spec-table-${tableIdx}`} className="bg-gray-50 rounded-lg p-6 border border-gray-200">
|
|
<h4 className="text-xl font-bold mb-4">Technical Specifications</h4>
|
|
<dl className="space-y-3">
|
|
{specs.map((spec, idx) => (
|
|
<div key={idx} className="flex flex-col sm:flex-row sm:gap-4 border-b border-gray-200 pb-2 last:border-0">
|
|
<dt className="font-semibold text-gray-700 sm:w-1/3">{spec.property}</dt>
|
|
<dd className="text-gray-600 sm:w-2/3">{spec.value}</dd>
|
|
</div>
|
|
))}
|
|
</dl>
|
|
</div>
|
|
);
|
|
}
|
|
} else {
|
|
// Regular table - use default HTML rendering
|
|
const tableHtml = $table.prop('outerHTML');
|
|
if (tableHtml) {
|
|
tableElements.push(
|
|
<div key={`table-${tableIdx}`} className="my-4 overflow-x-auto">
|
|
<ContentRenderer content={tableHtml} parsePatterns={false} />
|
|
</div>
|
|
);
|
|
}
|
|
}
|
|
});
|
|
|
|
if (tableElements.length > 0) {
|
|
elements.push(
|
|
<Section key={`tables-${i}`} padding="lg">
|
|
{tableElements}
|
|
</Section>
|
|
);
|
|
|
|
$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(
|
|
<details key={`faq-${qIdx}`} className="bg-white border border-gray-200 rounded-lg p-4 my-2">
|
|
<summary className="font-bold cursor-pointer text-primary hover:text-primary-dark">
|
|
{question}
|
|
</summary>
|
|
<p className="mt-2 text-gray-700">{answer}</p>
|
|
</details>
|
|
);
|
|
}
|
|
});
|
|
|
|
if (faqItems.length > 0) {
|
|
elements.push(
|
|
<Section key={`faq-${i}`} padding="lg">
|
|
<h3 className="text-2xl font-bold mb-4">Frequently Asked Questions</h3>
|
|
{faqItems}
|
|
</Section>
|
|
);
|
|
|
|
$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(
|
|
<Section
|
|
key={`cta-${i}`}
|
|
padding="xl"
|
|
backgroundImage={bgImage && !hasVideoBg ? replaceUrlInAttribute(bgImage) : undefined}
|
|
backgroundColor={bgColor || '#1a1a1a'}
|
|
colorOverlay={colorOverlay || '#000000'}
|
|
overlayOpacity={overlayOpacityValue}
|
|
enableGradient={enableGradient}
|
|
gradientDirection={gradientDirection as any}
|
|
colorOverlay2={colorOverlay2}
|
|
parallaxBg={parallaxBg}
|
|
parallaxBgSpeed={parallaxBgSpeed as any}
|
|
bgImageAnimation={bgImageAnimation as any}
|
|
textAlignment="center"
|
|
textColor="light"
|
|
videoMp4={videoMp4 ? replaceUrlInAttribute(videoMp4) : undefined}
|
|
videoWebm={videoWebm ? replaceUrlInAttribute(videoWebm) : undefined}
|
|
>
|
|
<div className="max-w-3xl mx-auto">
|
|
<h2 className="text-3xl md:text-4xl font-bold mb-4">{title}</h2>
|
|
{description && <p className="text-xl mb-6 opacity-90">{description}</p>}
|
|
<Button
|
|
variant="primary"
|
|
size="lg"
|
|
onClick={() => window.location.href = replaceUrlInAttribute(buttonHref)}
|
|
>
|
|
{buttonText}
|
|
</Button>
|
|
</div>
|
|
</Section>
|
|
);
|
|
|
|
$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(
|
|
<Section
|
|
key={`quote-${i}`}
|
|
padding="lg"
|
|
backgroundImage={bgImage && !hasVideoBg ? replaceUrlInAttribute(bgImage) : undefined}
|
|
backgroundColor={bgColor}
|
|
colorOverlay={colorOverlay}
|
|
overlayOpacity={overlayOpacityValue}
|
|
enableGradient={enableGradient}
|
|
gradientDirection={gradientDirection as any}
|
|
colorOverlay2={colorOverlay2}
|
|
textAlignment="center"
|
|
textColor={textColor as any}
|
|
videoMp4={videoMp4 ? replaceUrlInAttribute(videoMp4) : undefined}
|
|
videoWebm={videoWebm ? replaceUrlInAttribute(videoWebm) : undefined}
|
|
>
|
|
<div className="max-w-2xl mx-auto">
|
|
<div className="text-4xl font-serif text-primary mb-4">"</div>
|
|
<blockquote className="text-2xl md:text-3xl font-serif italic mb-4">
|
|
{quote}
|
|
</blockquote>
|
|
{author && (
|
|
<cite className="text-lg font-semibold not-italic text-gray-300">
|
|
— {author}
|
|
</cite>
|
|
)}
|
|
</div>
|
|
</Section>
|
|
);
|
|
|
|
$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(
|
|
<div key={`list-item-${liIdx}`} className="flex items-start gap-3">
|
|
<div className="flex-shrink-0 mt-1">
|
|
<Icon {...iconProps} className="text-primary" />
|
|
</div>
|
|
<div className="flex-1">
|
|
<p className="text-gray-700">{text}</p>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
});
|
|
|
|
if (items.length > 0) {
|
|
elements.push(
|
|
<Section key={`icon-list-${i}`} padding="lg">
|
|
<div className="space-y-3">
|
|
{items}
|
|
</div>
|
|
</Section>
|
|
);
|
|
|
|
$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(
|
|
<Section
|
|
key={`video-bg-${i}`}
|
|
padding="lg"
|
|
backgroundImage={bgImage && !hasVideoBg ? replaceUrlInAttribute(bgImage) : undefined}
|
|
backgroundColor={bgColor}
|
|
colorOverlay={colorOverlay}
|
|
overlayOpacity={overlayOpacityValue}
|
|
enableGradient={enableGradient}
|
|
gradientDirection={gradientDirection as any}
|
|
colorOverlay2={colorOverlay2}
|
|
parallaxBg={parallaxBg}
|
|
parallaxBgSpeed={parallaxBgSpeed as any}
|
|
bgImageAnimation={bgImageAnimation as any}
|
|
topPadding={topPadding}
|
|
bottomPadding={bottomPadding}
|
|
textAlignment={textAlignment as any}
|
|
textColor={textColor as any}
|
|
shapeType={shapeType}
|
|
scenePosition={scenePosition as any}
|
|
fullScreenRowPosition={fullScreen as any}
|
|
videoMp4={videoMp4 ? replaceUrlInAttribute(videoMp4) : undefined}
|
|
videoWebm={videoWebm ? replaceUrlInAttribute(videoWebm) : undefined}
|
|
>
|
|
<ContentRenderer content={innerHtml} parsePatterns={true} />
|
|
</Section>
|
|
);
|
|
|
|
$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(
|
|
<Section key={`video-embed-${i}`} padding="lg">
|
|
<div className="grid md:grid-cols-2 gap-6 items-center">
|
|
<div>
|
|
<video
|
|
controls
|
|
className="w-full rounded-lg shadow-lg"
|
|
style={{ opacity: 1 }}
|
|
poster={replaceUrlInAttribute($video.attr('poster') || '')}
|
|
>
|
|
<source src={replaceUrlInAttribute(videoSrc)} type="video/mp4" />
|
|
</video>
|
|
</div>
|
|
<div>
|
|
{$title.length > 0 && <h3 className="text-2xl font-bold mb-3">{$title.text().trim()}</h3>}
|
|
{$desc.length > 0 && <ContentRenderer content={$desc.html() || ''} parsePatterns={false} />}
|
|
</div>
|
|
</div>
|
|
</Section>
|
|
);
|
|
|
|
$row.remove();
|
|
return;
|
|
}
|
|
}
|
|
|
|
// FALLBACK: Generic section with nested content
|
|
const innerHtml = $row.html();
|
|
if (innerHtml) {
|
|
elements.push(
|
|
<Section key={`generic-${i}`} padding="lg">
|
|
<ContentRenderer content={innerHtml} parsePatterns={false} />
|
|
</Section>
|
|
);
|
|
}
|
|
|
|
// Remove processed row to avoid duplication
|
|
$row.remove();
|
|
});
|
|
|
|
// Handle any remaining loose content
|
|
const remainingHtml = $.html().trim();
|
|
if (remainingHtml) {
|
|
elements.push(
|
|
<div key="remaining">
|
|
<ContentRenderer content={remainingHtml} parsePatterns={false} />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
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 <div dangerouslySetInnerHTML={{ __html: html }} />;
|
|
}
|
|
|
|
// 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 (
|
|
<Link
|
|
href={href}
|
|
key={index}
|
|
className={element.className}
|
|
style={parseStyleString(element.style.cssText)}
|
|
>
|
|
{children}
|
|
</Link>
|
|
);
|
|
}
|
|
}
|
|
|
|
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 (
|
|
<Image
|
|
key={index}
|
|
src={media.localPath}
|
|
alt={alt || media.alt || ''}
|
|
width={width}
|
|
height={height}
|
|
className={element.className || ''}
|
|
style={parseStyleString(element.style.cssText)}
|
|
priority={false}
|
|
sizes={sizes}
|
|
/>
|
|
);
|
|
}
|
|
}
|
|
|
|
// 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 (
|
|
<img
|
|
key={index}
|
|
src={imageProps.src}
|
|
srcSet={srcset ? replaceSrcset(srcset) : undefined}
|
|
sizes={sizes}
|
|
alt={alt}
|
|
width={width}
|
|
height={height}
|
|
className={element.className || ''}
|
|
style={parseStyleString(element.style.cssText)}
|
|
/>
|
|
);
|
|
}
|
|
|
|
// For local images, use Next.js Image component
|
|
// Note: Next.js Image doesn't support srcSet prop, it handles optimization automatically
|
|
return (
|
|
<Image
|
|
key={index}
|
|
src={imageProps.src}
|
|
alt={alt || imageProps.alt || ''}
|
|
width={width || 800}
|
|
height={height || 600}
|
|
className={element.className || ''}
|
|
style={parseStyleString(element.style.cssText)}
|
|
priority={false}
|
|
sizes={sizes}
|
|
/>
|
|
);
|
|
}
|
|
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(
|
|
<source key={i} src={src} type={sourceEl.type} />
|
|
);
|
|
}
|
|
});
|
|
|
|
// 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 (
|
|
<video {...videoProps}>
|
|
{sources}
|
|
</video>
|
|
);
|
|
}
|
|
|
|
// 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 (
|
|
<div key={index} className={className} style={style}>
|
|
<div
|
|
className="absolute inset-0"
|
|
style={{ backgroundColor: colorOverlay, opacity: overlayOpacity }}
|
|
/>
|
|
<div className="relative">
|
|
{children}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// 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 (
|
|
<div key={index} className={className} style={style}>
|
|
{mp4 || webm ? (
|
|
<video
|
|
className="absolute inset-0 w-full h-full object-cover"
|
|
autoPlay
|
|
loop
|
|
muted
|
|
playsInline
|
|
style={{ opacity: 1 }}
|
|
>
|
|
{mp4 && <source src={replaceUrlInAttribute(mp4)} type="video/mp4" />}
|
|
{webm && <source src={replaceUrlInAttribute(webm)} type="video/webm" />}
|
|
</video>
|
|
) : null}
|
|
<div className="relative z-10">
|
|
{children}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// 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<string, string> = {
|
|
// 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 = '' }) => (
|
|
<div className={cn('mb-8', className)}>
|
|
{title && <h3 className="text-2xl font-bold mb-4">{title}</h3>}
|
|
<ContentRenderer content={content} />
|
|
</div>
|
|
);
|
|
|
|
export const RichText: React.FC<{
|
|
html: string;
|
|
className?: string;
|
|
}> = ({ html, className = '' }) => (
|
|
<ContentRenderer content={html} className={className} />
|
|
);
|
|
|
|
export default ContentRenderer; |