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