feat: Page Editor v1.0 - canonical schema, SSR parity, and migration
Major improvements to WooNooW Page Editor system: Schema & Architecture: - Canonical section schema with unified sectionSchema.ts - Normalized feature-grid to use items (not features) - Standardized default values across all section types - Schema versioning with automatic migration on read Backend (PHP): - Enhanced PlaceholderRenderer with typed output contracts - Added fallback behavior for empty/invalid dynamic sources - Added caching support for post data resolution - New SchemaMigration class for backward compatibility - New Features class for feature flags - Enhanced PageSSR with full style support - Removed controller-level special-casing for related_posts Frontend (Admin SPA): - Updated CanvasRenderer with schema-aware transformation - Enhanced InspectorPanel with canonical schema metadata - Added new section renderers Frontend (Customer SPA): - New section components: BentoCategoryGrid, MarqueeBanner, ProductCarousel, ShoppableImage - Updated FeatureGridSection for items prop contract Testing: - Add PHP tests: SchemaMigrationTest, PlaceholderRendererTest, PageSSRTest - Add TypeScript tests: schema-integration, feature-grid-regression - Add parity tests for React vs SSR content matching - Add CI script: check-schema-drift.mjs - Add VERIFICATION_CHECKLIST.md Documentation: - RELEASE_NOTES-v1.0.md with full release notes - docs/PAGE_EDITOR_SECTION_SCHEMA_V1.md - docs/PAGE_EDITOR_SSR_COVERAGE_AUDIT.md
This commit is contained in:
@@ -65,14 +65,19 @@ class PageSSR
|
||||
|
||||
/**
|
||||
* Resolve props - replace dynamic placeholders with actual values
|
||||
*
|
||||
*
|
||||
* @param array $props Section props
|
||||
* @param array|null $post_data Post data
|
||||
* @param array $options Resolution options
|
||||
* @return array Resolved props with actual values
|
||||
*/
|
||||
public static function resolve_props($props, $post_data = null)
|
||||
public static function resolve_props($props, $post_data = null, $options = [])
|
||||
{
|
||||
$resolved = [];
|
||||
$options = wp_parse_args($options, [
|
||||
'validate_types' => true,
|
||||
'use_fallbacks' => true,
|
||||
]);
|
||||
|
||||
foreach ($props as $key => $prop) {
|
||||
if (!is_array($prop)) {
|
||||
@@ -86,7 +91,12 @@ class PageSSR
|
||||
$resolved[$key] = $prop['value'] ?? '';
|
||||
} elseif ($type === 'dynamic' && $post_data) {
|
||||
$source = $prop['source'] ?? '';
|
||||
$resolved[$key] = PlaceholderRenderer::get_value($source, $post_data);
|
||||
$resolved[$key] = PlaceholderRenderer::get_value($source, $post_data, [
|
||||
'validate_type' => $options['validate_types'],
|
||||
'use_fallback' => $options['use_fallbacks'],
|
||||
]);
|
||||
} elseif ($options['use_fallbacks']) {
|
||||
$resolved[$key] = $prop['value'] ?? PlaceholderRenderer::get_fallback($key);
|
||||
} else {
|
||||
$resolved[$key] = $prop['value'] ?? '';
|
||||
}
|
||||
@@ -281,73 +291,26 @@ class PageSSR
|
||||
$props['content']['value'] = $content;
|
||||
}
|
||||
|
||||
// Section Styles (Background)
|
||||
// Section Styles (Background & Spacing)
|
||||
$bg_type = $section_styles['backgroundType'] ?? 'solid';
|
||||
$bg_color = $section_styles['backgroundColor'] ?? '';
|
||||
$gradient_from = $section_styles['gradientFrom'] ?? '#9333ea';
|
||||
$gradient_to = $section_styles['gradientTo'] ?? '#3b82f6';
|
||||
$gradient_angle = $section_styles['gradientAngle'] ?? 135;
|
||||
$bg_image = $section_styles['backgroundImage'] ?? '';
|
||||
$overlay_opacity = $section_styles['backgroundOverlay'] ?? 0;
|
||||
$pt = $section_styles['paddingTop'] ?? '';
|
||||
$pb = $section_styles['paddingBottom'] ?? '';
|
||||
$content_width = $section_styles['contentWidth'] ?? 'contained';
|
||||
$height_preset = $section_styles['heightPreset'] ?? 'default';
|
||||
|
||||
$css = "";
|
||||
if ($bg_type === 'gradient') {
|
||||
$from = $section_styles['gradientFrom'] ?? '#9333ea';
|
||||
$to = $section_styles['gradientTo'] ?? '#3b82f6';
|
||||
$angle = $section_styles['gradientAngle'] ?? 135;
|
||||
$css .= "background: linear-gradient({$angle}deg, {$from}, {$to});";
|
||||
$css .= "background: linear-gradient({$gradient_angle}deg, {$gradient_from}, {$gradient_to});";
|
||||
} elseif ($bg_image) {
|
||||
$css .= "background-image: url('{$bg_image}'); background-size: cover; background-position: center;";
|
||||
} elseif ($bg_color) {
|
||||
$css .= "background-color:{$bg_color};";
|
||||
}
|
||||
|
||||
// Height Logic
|
||||
$preset_padding = '';
|
||||
if ($height_preset === 'screen') {
|
||||
$css .= "min-height: 100vh; display: flex; align-items: center;";
|
||||
$preset_padding = '5rem'; // Default padding for screen to avoid edge collision
|
||||
} elseif ($height_preset === 'small') {
|
||||
$preset_padding = '2rem';
|
||||
} elseif ($height_preset === 'large') {
|
||||
$preset_padding = '8rem';
|
||||
} elseif ($height_preset === 'medium') {
|
||||
$preset_padding = '4rem';
|
||||
}
|
||||
|
||||
if ($pt) {
|
||||
$css .= "padding-top:{$pt};";
|
||||
} elseif ($preset_padding) {
|
||||
$css .= "padding-top:{$preset_padding};";
|
||||
}
|
||||
|
||||
if ($pb) {
|
||||
$css .= "padding-bottom:{$pb};";
|
||||
} elseif ($preset_padding) {
|
||||
$css .= "padding-bottom:{$preset_padding};";
|
||||
}
|
||||
|
||||
$style_attr = $css ? "style=\"{$css}\"" : "";
|
||||
|
||||
$contentWidth = $section_styles['contentWidth'] ?? 'contained';
|
||||
$inner_html = self::render_universal_row($props, 'left', $color_scheme, $element_styles, ['contentWidth' => $contentWidth]);
|
||||
|
||||
return "<section id=\"{$id}\" class=\"wn-section wn-content wn-scheme--{$color_scheme}\" {$style_attr}>{$inner_html}</section>";
|
||||
}
|
||||
|
||||
/**
|
||||
* Render Image + Text section
|
||||
*/
|
||||
public static function render_image_text($props, $layout, $color_scheme, $id, $element_styles = [], $section_styles = [])
|
||||
{
|
||||
$bg_type = $section_styles['backgroundType'] ?? 'solid';
|
||||
$bg_color = $section_styles['backgroundColor'] ?? '';
|
||||
$pt = $section_styles['paddingTop'] ?? '';
|
||||
$pb = $section_styles['paddingBottom'] ?? '';
|
||||
|
||||
$css = "";
|
||||
if ($bg_type === 'gradient') {
|
||||
$from = $section_styles['gradientFrom'] ?? '#9333ea';
|
||||
$to = $section_styles['gradientTo'] ?? '#3b82f6';
|
||||
$angle = $section_styles['gradientAngle'] ?? 135;
|
||||
$css .= "background: linear-gradient({$angle}deg, {$from}, {$to});";
|
||||
} elseif ($bg_color) {
|
||||
$css .= "background-color:{$bg_color};";
|
||||
$css .= "background-color: {$bg_color};";
|
||||
}
|
||||
|
||||
// Height Logic
|
||||
@@ -361,76 +324,221 @@ class PageSSR
|
||||
$preset_padding = '8rem';
|
||||
} elseif ($height_preset === 'medium') {
|
||||
$preset_padding = '4rem';
|
||||
} else {
|
||||
$preset_padding = '4rem 1rem';
|
||||
}
|
||||
|
||||
if ($pt) {
|
||||
$css .= "padding-top:{$pt};";
|
||||
$css .= "padding-top: {$pt};";
|
||||
} elseif ($preset_padding) {
|
||||
$css .= "padding-top:{$preset_padding};";
|
||||
$css .= "padding-top: {$preset_padding};";
|
||||
}
|
||||
|
||||
if ($pb) {
|
||||
$css .= "padding-bottom:{$pb};";
|
||||
$css .= "padding-bottom: {$pb};";
|
||||
} elseif ($preset_padding) {
|
||||
$css .= "padding-bottom:{$preset_padding};";
|
||||
$css .= "padding-bottom: {$preset_padding};";
|
||||
}
|
||||
|
||||
$style_attr = $css ? "style=\"{$css}\"" : "";
|
||||
|
||||
// Overlay
|
||||
$overlay_html = '';
|
||||
if ($overlay_opacity > 0) {
|
||||
$opacity = $overlay_opacity / 100;
|
||||
$overlay_html = "<div class=\"wn-content__overlay\" style=\"background-color: rgba(0,0,0,{$opacity}); position: absolute; inset: 0;\"></div>";
|
||||
}
|
||||
|
||||
$inner_html = self::render_universal_row($props, 'left', $color_scheme, $element_styles, ['contentWidth' => $content_width]);
|
||||
|
||||
return "<section id=\"{$id}\" class=\"wn-section wn-content wn-scheme--{$color_scheme}\" {$style_attr}>{$overlay_html}<div style=\"position: relative; z-index: 10;\">{$inner_html}</div></section>";
|
||||
}
|
||||
|
||||
/**
|
||||
* Render Image + Text section
|
||||
*/
|
||||
public static function render_image_text($props, $layout, $color_scheme, $id, $element_styles = [], $section_styles = [])
|
||||
{
|
||||
$bg_type = $section_styles['backgroundType'] ?? 'solid';
|
||||
$bg_color = $section_styles['backgroundColor'] ?? '';
|
||||
$gradient_from = $section_styles['gradientFrom'] ?? '#9333ea';
|
||||
$gradient_to = $section_styles['gradientTo'] ?? '#3b82f6';
|
||||
$gradient_angle = $section_styles['gradientAngle'] ?? 135;
|
||||
$bg_image = $section_styles['backgroundImage'] ?? '';
|
||||
$overlay_opacity = $section_styles['backgroundOverlay'] ?? 0;
|
||||
$pt = $section_styles['paddingTop'] ?? '';
|
||||
$pb = $section_styles['paddingBottom'] ?? '';
|
||||
$content_width = $section_styles['contentWidth'] ?? 'contained';
|
||||
$height_preset = $section_styles['heightPreset'] ?? 'default';
|
||||
|
||||
$css = "";
|
||||
if ($bg_type === 'gradient') {
|
||||
$css .= "background: linear-gradient({$gradient_angle}deg, {$gradient_from}, {$gradient_to});";
|
||||
} elseif ($bg_image) {
|
||||
$css .= "background-image: url('{$bg_image}'); background-size: cover; background-position: center;";
|
||||
} elseif ($bg_color) {
|
||||
$css .= "background-color: {$bg_color};";
|
||||
}
|
||||
|
||||
// Height Logic
|
||||
$preset_padding = '';
|
||||
if ($height_preset === 'screen') {
|
||||
$css .= "min-height: 100vh; display: flex; align-items: center;";
|
||||
$preset_padding = '5rem';
|
||||
} elseif ($height_preset === 'small') {
|
||||
$preset_padding = '2rem';
|
||||
} elseif ($height_preset === 'large') {
|
||||
$preset_padding = '8rem';
|
||||
} elseif ($height_preset === 'medium') {
|
||||
$preset_padding = '4rem';
|
||||
} else {
|
||||
$preset_padding = '4rem 1rem';
|
||||
}
|
||||
|
||||
if ($pt) {
|
||||
$css .= "padding-top: {$pt};";
|
||||
} elseif ($preset_padding) {
|
||||
$css .= "padding-top: {$preset_padding};";
|
||||
}
|
||||
|
||||
if ($pb) {
|
||||
$css .= "padding-bottom: {$pb};";
|
||||
} elseif ($preset_padding) {
|
||||
$css .= "padding-bottom: {$preset_padding};";
|
||||
}
|
||||
$style_attr = $css ? "style=\"{$css}\"" : "";
|
||||
|
||||
$contentWidth = $section_styles['contentWidth'] ?? 'contained';
|
||||
$inner_html = self::render_universal_row($props, $layout, $color_scheme, $element_styles, ['contentWidth' => $contentWidth]);
|
||||
// Overlay
|
||||
$overlay_html = '';
|
||||
if ($overlay_opacity > 0) {
|
||||
$opacity = $overlay_opacity / 100;
|
||||
$overlay_html = "<div class=\"wn-image-text__overlay\" style=\"background-color: rgba(0,0,0,{$opacity}); position: absolute; inset: 0;\"></div>";
|
||||
}
|
||||
|
||||
return "<section id=\"{$id}\" class=\"wn-section wn-image-text wn-scheme--{$color_scheme}\" {$style_attr}>{$inner_html}</section>";
|
||||
$inner_html = self::render_universal_row($props, $layout, $color_scheme, $element_styles, ['contentWidth' => $content_width]);
|
||||
|
||||
return "<section id=\"{$id}\" class=\"wn-section wn-image-text wn-scheme--{$color_scheme}\" {$style_attr}>{$overlay_html}<div style=\"position: relative; z-index: 10;\">{$inner_html}</div></section>";
|
||||
}
|
||||
|
||||
/**
|
||||
* Render Feature Grid section
|
||||
*/
|
||||
public static function render_feature_grid($props, $layout, $color_scheme, $id, $element_styles = [])
|
||||
public static function render_feature_grid($props, $layout, $color_scheme, $id, $element_styles = [], $section_styles = [])
|
||||
{
|
||||
$heading = esc_html($props['heading'] ?? '');
|
||||
$items = $props['items'] ?? [];
|
||||
$items = $props['items'] ?? ($props['features'] ?? []);
|
||||
if (!is_array($items)) {
|
||||
$items = [];
|
||||
}
|
||||
|
||||
$html = "<section id=\"{$id}\" class=\"wn-section wn-feature-grid wn-feature-grid--{$layout} wn-scheme--{$color_scheme}\">";
|
||||
// Section Styles (Background & Spacing)
|
||||
$bg_type = $section_styles['backgroundType'] ?? 'solid';
|
||||
$bg_color = $section_styles['backgroundColor'] ?? '';
|
||||
$gradient_from = $section_styles['gradientFrom'] ?? '#9333ea';
|
||||
$gradient_to = $section_styles['gradientTo'] ?? '#3b82f6';
|
||||
$gradient_angle = $section_styles['gradientAngle'] ?? 135;
|
||||
$bg_image = $section_styles['backgroundImage'] ?? '';
|
||||
$overlay_opacity = $section_styles['backgroundOverlay'] ?? 0;
|
||||
$pt = $section_styles['paddingTop'] ?? '';
|
||||
$pb = $section_styles['paddingBottom'] ?? '';
|
||||
$content_width = $section_styles['contentWidth'] ?? 'contained';
|
||||
$height_preset = $section_styles['heightPreset'] ?? 'default';
|
||||
|
||||
$section_css = '';
|
||||
if ($bg_type === 'gradient') {
|
||||
$section_css .= "background: linear-gradient({$gradient_angle}deg, {$gradient_from}, {$gradient_to});";
|
||||
} elseif ($bg_image) {
|
||||
$section_css .= "background-image: url('{$bg_image}'); background-size: cover; background-position: center;";
|
||||
} elseif ($bg_color) {
|
||||
$section_css .= "background-color: {$bg_color};";
|
||||
}
|
||||
|
||||
// Height presets
|
||||
$height_classes = '';
|
||||
switch ($height_preset) {
|
||||
case 'small':
|
||||
$height_classes = 'py-8 md:py-12';
|
||||
break;
|
||||
case 'medium':
|
||||
$height_classes = 'py-16 md:py-24';
|
||||
break;
|
||||
case 'large':
|
||||
$height_classes = 'py-24 md:py-36';
|
||||
break;
|
||||
case 'fullscreen':
|
||||
$section_css .= "min-height: 100vh; display: flex; align-items: center;";
|
||||
break;
|
||||
default:
|
||||
$height_classes = 'py-12 md:py-20';
|
||||
}
|
||||
|
||||
// Padding overrides
|
||||
if ($pt) $section_css .= "padding-top: {$pt};";
|
||||
if ($pb) $section_css .= "padding-bottom: {$pb};";
|
||||
|
||||
$section_attr = $section_css ? "style=\"{$section_css}\"" : "";
|
||||
$wrapper_class = $content_width === 'full' ? 'w-full' : 'container max-w-7xl mx-auto px-4';
|
||||
|
||||
$html = "<section id=\"{$id}\" class=\"wn-section wn-feature-grid wn-feature-grid--{$layout} wn-scheme--{$color_scheme} {$height_classes}\" {$section_attr}>";
|
||||
|
||||
// Overlay
|
||||
if ($overlay_opacity > 0) {
|
||||
$opacity = $overlay_opacity / 100;
|
||||
$html .= "<div class=\"wn-feature-grid__overlay\" style=\"background-color: rgba(0,0,0,{$opacity}); position: absolute; inset: 0;\"></div>";
|
||||
}
|
||||
|
||||
$html .= '<div class="wn-feature-grid__inner ' . esc_attr($wrapper_class) . '" style="position: relative; z-index: 10;">';
|
||||
|
||||
if ($heading) {
|
||||
$html .= "<h2 class=\"wn-feature-grid__heading\">{$heading}</h2>";
|
||||
$heading_style = self::generate_style_attr($element_styles['heading'] ?? []);
|
||||
$html .= "<h2 class=\"wn-feature-grid__heading text-center mb-10\" {$heading_style}>{$heading}</h2>";
|
||||
}
|
||||
|
||||
// Feature Item Styles (Card)
|
||||
$item_style_attr = self::generate_style_attr($element_styles['feature_item'] ?? []); // BG, Border, Shadow handled by CSS classes mostly, but colors here
|
||||
$item_style_attr = self::generate_style_attr($element_styles['feature_item'] ?? []);
|
||||
$item_bg = $element_styles['feature_item']['backgroundColor'] ?? '';
|
||||
|
||||
$html .= '<div class="wn-feature-grid__items">';
|
||||
// Grid columns
|
||||
$grid_cols = 'grid-cols-1 md:grid-cols-3';
|
||||
if ($layout === 'grid-2') {
|
||||
$grid_cols = 'grid-cols-1 md:grid-cols-2';
|
||||
} elseif ($layout === 'grid-4') {
|
||||
$grid_cols = 'grid-cols-2 lg:grid-cols-4';
|
||||
}
|
||||
|
||||
$html .= '<div class="wn-feature-grid__items grid gap-6 ' . esc_attr($grid_cols) . '">';
|
||||
foreach ($items as $item) {
|
||||
$item_title = esc_html($item['title'] ?? '');
|
||||
$item_desc = esc_html($item['description'] ?? '');
|
||||
$item_icon = esc_html($item['icon'] ?? '');
|
||||
|
||||
// Allow overriding item specific style if needed, but for now global
|
||||
$html .= "<div class=\"wn-feature-grid__item\" {$item_style_attr}>";
|
||||
// Background color for card
|
||||
$card_style = '';
|
||||
if ($item_bg) {
|
||||
$card_style = "background-color: {$item_bg};";
|
||||
}
|
||||
|
||||
$html .= "<div class=\"wn-feature-grid__item p-6 rounded-xl shadow-lg\" " . ($card_style ? "style=\"{$card_style}\"" : "") . ">";
|
||||
|
||||
// Render Icon SVG
|
||||
if ($item_icon) {
|
||||
$icon_svg = self::get_icon_svg($item_icon);
|
||||
if ($icon_svg) {
|
||||
$html .= "<div class=\"wn-feature-grid__icon\">{$icon_svg}</div>";
|
||||
$html .= "<div class=\"wn-feature-grid__icon mb-4 inline-block p-3 rounded-full bg-primary/10 text-primary\">{$icon_svg}</div>";
|
||||
}
|
||||
}
|
||||
|
||||
if ($item_title) {
|
||||
// Feature title style
|
||||
$f_title_style = self::generate_style_attr($element_styles['feature_title'] ?? []);
|
||||
$html .= "<h3 class=\"wn-feature-grid__item-title\" {$f_title_style}>{$item_title}</h3>";
|
||||
$html .= "<h3 class=\"wn-feature-grid__item-title text-xl font-semibold mb-3\" {$f_title_style}>{$item_title}</h3>";
|
||||
}
|
||||
if ($item_desc) {
|
||||
// Feature description style
|
||||
$f_desc_style = self::generate_style_attr($element_styles['feature_description'] ?? []);
|
||||
$html .= "<p class=\"wn-feature-grid__item-desc\" {$f_desc_style}>{$item_desc}</p>";
|
||||
$html .= "<p class=\"wn-feature-grid__item-desc text-gray-600\" {$f_desc_style}>{$item_desc}</p>";
|
||||
}
|
||||
$html .= '</div>';
|
||||
}
|
||||
$html .= '</div>';
|
||||
$html .= '</section>';
|
||||
$html .= '</div></div></section>';
|
||||
|
||||
return $html;
|
||||
}
|
||||
@@ -438,24 +546,52 @@ class PageSSR
|
||||
/**
|
||||
* Render CTA Banner section
|
||||
*/
|
||||
public static function render_cta_banner($props, $layout, $color_scheme, $id, $element_styles = [])
|
||||
public static function render_cta_banner($props, $layout, $color_scheme, $id, $element_styles = [], $section_styles = [])
|
||||
{
|
||||
$title = esc_html($props['title'] ?? '');
|
||||
$text = esc_html($props['text'] ?? '');
|
||||
$button_text = esc_html($props['button_text'] ?? '');
|
||||
$button_url = esc_url($props['button_url'] ?? '');
|
||||
|
||||
$html = "<section id=\"{$id}\" class=\"wn-section wn-cta-banner wn-cta-banner--{$layout} wn-scheme--{$color_scheme}\">";
|
||||
$html .= '<div class="wn-cta-banner__content">';
|
||||
// Section Styles (Background & Spacing)
|
||||
$bg_type = $section_styles['backgroundType'] ?? 'solid';
|
||||
$bg_color = $section_styles['backgroundColor'] ?? '';
|
||||
$gradient_from = $section_styles['gradientFrom'] ?? '#9333ea';
|
||||
$gradient_to = $section_styles['gradientTo'] ?? '#3b82f6';
|
||||
$gradient_angle = $section_styles['gradientAngle'] ?? 135;
|
||||
$bg_image = $section_styles['backgroundImage'] ?? '';
|
||||
$pt = $section_styles['paddingTop'] ?? '';
|
||||
$pb = $section_styles['paddingBottom'] ?? '';
|
||||
$content_width = $section_styles['contentWidth'] ?? 'contained';
|
||||
|
||||
$section_css = '';
|
||||
if ($bg_type === 'gradient') {
|
||||
$section_css .= "background: linear-gradient({$gradient_angle}deg, {$gradient_from}, {$gradient_to});";
|
||||
} elseif ($bg_image) {
|
||||
$section_css .= "background-image: url('{$bg_image}'); background-size: cover; background-position: center;";
|
||||
} elseif ($bg_color) {
|
||||
$section_css .= "background-color: {$bg_color};";
|
||||
}
|
||||
if ($pt) $section_css .= "padding-top: {$pt};";
|
||||
if ($pb) $section_css .= "padding-bottom: {$pb};";
|
||||
|
||||
$section_attr = $section_css ? "style=\"{$section_css}\"" : "";
|
||||
$wrapper_class = $content_width === 'full' ? 'w-full' : 'container max-w-5xl mx-auto px-4';
|
||||
|
||||
$html = "<section id=\"{$id}\" class=\"wn-section wn-cta-banner wn-cta-banner--{$layout} wn-scheme--{$color_scheme}\" {$section_attr}>";
|
||||
$html .= '<div class="wn-cta-banner__content ' . esc_attr($wrapper_class) . '">';
|
||||
|
||||
if ($title) {
|
||||
$html .= "<h2 class=\"wn-cta-banner__title\">{$title}</h2>";
|
||||
$title_style = self::generate_style_attr($element_styles['title'] ?? []);
|
||||
$html .= "<h2 class=\"wn-cta-banner__title\" {$title_style}>{$title}</h2>";
|
||||
}
|
||||
if ($text) {
|
||||
$html .= "<p class=\"wn-cta-banner__text\">{$text}</p>";
|
||||
$text_style = self::generate_style_attr($element_styles['text'] ?? []);
|
||||
$html .= "<p class=\"wn-cta-banner__text\" {$text_style}>{$text}</p>";
|
||||
}
|
||||
if ($button_text && $button_url) {
|
||||
$html .= "<a href=\"{$button_url}\" class=\"wn-cta-banner__button\">{$button_text}</a>";
|
||||
$btn_style = self::generate_style_attr($element_styles['button_text'] ?? []);
|
||||
$html .= "<a href=\"{$button_url}\" class=\"wn-cta-banner__button\" {$btn_style}>{$button_text}</a>";
|
||||
}
|
||||
|
||||
$html .= '</div>';
|
||||
@@ -467,12 +603,37 @@ class PageSSR
|
||||
/**
|
||||
* Render Contact Form section
|
||||
*/
|
||||
public static function render_contact_form($props, $layout, $color_scheme, $id, $element_styles = [])
|
||||
public static function render_contact_form($props, $layout, $color_scheme, $id, $element_styles = [], $section_styles = [])
|
||||
{
|
||||
$title = esc_html($props['title'] ?? '');
|
||||
$webhook_url = esc_url($props['webhook_url'] ?? '');
|
||||
$redirect_url = esc_url($props['redirect_url'] ?? '');
|
||||
$fields = $props['fields'] ?? ['name', 'email', 'message'];
|
||||
$fields = is_array($props['fields'] ?? null) ? $props['fields'] : ['name', 'email', 'message'];
|
||||
|
||||
// Section Styles (Background & Spacing)
|
||||
$bg_type = $section_styles['backgroundType'] ?? 'solid';
|
||||
$bg_color = $section_styles['backgroundColor'] ?? '';
|
||||
$gradient_from = $section_styles['gradientFrom'] ?? '#9333ea';
|
||||
$gradient_to = $section_styles['gradientTo'] ?? '#3b82f6';
|
||||
$gradient_angle = $section_styles['gradientAngle'] ?? 135;
|
||||
$bg_image = $section_styles['backgroundImage'] ?? '';
|
||||
$pt = $section_styles['paddingTop'] ?? '';
|
||||
$pb = $section_styles['paddingBottom'] ?? '';
|
||||
$content_width = $section_styles['contentWidth'] ?? 'contained';
|
||||
|
||||
$section_css = '';
|
||||
if ($bg_type === 'gradient') {
|
||||
$section_css .= "background: linear-gradient({$gradient_angle}deg, {$gradient_from}, {$gradient_to});";
|
||||
} elseif ($bg_image) {
|
||||
$section_css .= "background-image: url('{$bg_image}'); background-size: cover; background-position: center;";
|
||||
} elseif ($bg_color) {
|
||||
$section_css .= "background-color: {$bg_color};";
|
||||
}
|
||||
if ($pt) $section_css .= "padding-top: {$pt};";
|
||||
if ($pb) $section_css .= "padding-bottom: {$pb};";
|
||||
|
||||
$section_attr = $section_css ? "style=\"{$section_css}\"" : "";
|
||||
$wrapper_class = $content_width === 'full' ? 'w-full' : 'container max-w-2xl mx-auto px-4';
|
||||
|
||||
// Extract styles
|
||||
$btn_bg = $element_styles['button']['backgroundColor'] ?? '';
|
||||
@@ -490,14 +651,15 @@ class PageSSR
|
||||
if ($field_color) $field_style .= "color: {$field_color};";
|
||||
$field_attr = $field_style ? "style=\"{$field_style}\"" : "";
|
||||
|
||||
$html = "<section id=\"{$id}\" class=\"wn-section wn-contact-form wn-scheme--{$color_scheme}\">";
|
||||
$html = "<section id=\"{$id}\" class=\"wn-section wn-contact-form wn-scheme--{$color_scheme}\" {$section_attr}>";
|
||||
|
||||
if ($title) {
|
||||
$html .= "<h2 class=\"wn-contact-form__title\">{$title}</h2>";
|
||||
$title_style = self::generate_style_attr($element_styles['title'] ?? []);
|
||||
$html .= "<h2 class=\"wn-contact-form__title\" {$title_style}>{$title}</h2>";
|
||||
}
|
||||
|
||||
// Form is rendered but won't work for bots (they just see the structure)
|
||||
$html .= '<form class="wn-contact-form__form" method="post">';
|
||||
$html .= '<form class="wn-contact-form__form ' . esc_attr($wrapper_class) . '" method="post">';
|
||||
|
||||
foreach ($fields as $field) {
|
||||
$field_label = ucfirst(str_replace('_', ' ', $field));
|
||||
@@ -518,6 +680,211 @@ class PageSSR
|
||||
return $html;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render Bento Category Grid section
|
||||
*/
|
||||
public static function render_bento_category_grid($props, $layout, $color_scheme, $id, $element_styles = [], $section_styles = [])
|
||||
{
|
||||
$title = esc_html($props['title'] ?? '');
|
||||
$items = $props['items'] ?? [];
|
||||
if (!is_array($items) || empty($items)) {
|
||||
$items = [
|
||||
['label' => 'New Arrivals', 'size' => 'large'],
|
||||
['label' => 'Best Sellers', 'size' => 'medium'],
|
||||
['label' => 'On Sale', 'size' => 'small'],
|
||||
['label' => 'Accessories', 'size' => 'small'],
|
||||
['label' => 'Collections', 'size' => 'tall'],
|
||||
];
|
||||
}
|
||||
|
||||
$html = "<section id=\"{$id}\" class=\"wn-section wn-bento-grid wn-scheme--{$color_scheme}\">";
|
||||
$html .= '<div class="wn-bento-grid__inner">';
|
||||
|
||||
if ($title) {
|
||||
$title_style = self::generate_style_attr($element_styles['title'] ?? []);
|
||||
$html .= "<h2 class=\"wn-bento-grid__title\" {$title_style}>{$title}</h2>";
|
||||
}
|
||||
|
||||
$html .= '<div class="wn-bento-grid__items">';
|
||||
foreach ($items as $item) {
|
||||
if (!is_array($item)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$label = esc_html($item['label'] ?? '');
|
||||
$url = esc_url($item['url'] ?? '');
|
||||
$image = esc_url($item['image'] ?? '');
|
||||
$size = sanitize_html_class($item['size'] ?? 'small');
|
||||
$content = '';
|
||||
|
||||
if ($image) {
|
||||
$content .= "<img class=\"wn-bento-grid__image\" src=\"{$image}\" alt=\"{$label}\" loading=\"lazy\" />";
|
||||
}
|
||||
if ($label) {
|
||||
$content .= "<span class=\"wn-bento-grid__label\">{$label}</span>";
|
||||
}
|
||||
|
||||
$html .= "<div class=\"wn-bento-grid__item wn-bento-grid__item--{$size}\">";
|
||||
$html .= $url ? "<a href=\"{$url}\" class=\"wn-bento-grid__link\">{$content}</a>" : $content;
|
||||
$html .= '</div>';
|
||||
}
|
||||
$html .= '</div></div></section>';
|
||||
|
||||
return $html;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render Product Carousel section
|
||||
*/
|
||||
public static function render_product_carousel($props, $layout, $color_scheme, $id, $element_styles = [], $section_styles = [])
|
||||
{
|
||||
$title = esc_html($props['title'] ?? 'Trending Now');
|
||||
$subtitle = esc_html($props['subtitle'] ?? '');
|
||||
$cta_text = esc_html($props['cta_text'] ?? '');
|
||||
$cta_url = esc_url($props['cta_url'] ?? '');
|
||||
$products = $props['products'] ?? [];
|
||||
if (!is_array($products)) {
|
||||
$products = [];
|
||||
}
|
||||
|
||||
$html = "<section id=\"{$id}\" class=\"wn-section wn-product-carousel wn-scheme--{$color_scheme}\">";
|
||||
$html .= '<div class="wn-product-carousel__inner">';
|
||||
$html .= '<div class="wn-product-carousel__header">';
|
||||
|
||||
if ($title) {
|
||||
$title_style = self::generate_style_attr($element_styles['title'] ?? []);
|
||||
$html .= "<h2 class=\"wn-product-carousel__title\" {$title_style}>{$title}</h2>";
|
||||
}
|
||||
if ($subtitle) {
|
||||
$subtitle_style = self::generate_style_attr($element_styles['subtitle'] ?? []);
|
||||
$html .= "<p class=\"wn-product-carousel__subtitle\" {$subtitle_style}>{$subtitle}</p>";
|
||||
}
|
||||
if ($cta_text && $cta_url) {
|
||||
$html .= "<a href=\"{$cta_url}\" class=\"wn-product-carousel__cta\">{$cta_text}</a>";
|
||||
}
|
||||
|
||||
$html .= '</div>';
|
||||
|
||||
if (!empty($products)) {
|
||||
$html .= '<div class="wn-product-carousel__items">';
|
||||
foreach ($products as $product) {
|
||||
if (!is_array($product)) {
|
||||
continue;
|
||||
}
|
||||
$name = esc_html($product['name'] ?? $product['title'] ?? '');
|
||||
$url = esc_url($product['url'] ?? $product['permalink'] ?? '#');
|
||||
$image = esc_url($product['image'] ?? $product['featured_image'] ?? '');
|
||||
$price = wp_kses_post($product['price_html'] ?? $product['price'] ?? '');
|
||||
|
||||
$html .= "<a href=\"{$url}\" class=\"wn-product-carousel__item\">";
|
||||
if ($image) {
|
||||
$html .= "<img class=\"wn-product-carousel__image\" src=\"{$image}\" alt=\"{$name}\" loading=\"lazy\" />";
|
||||
}
|
||||
if ($name) {
|
||||
$html .= "<h3 class=\"wn-product-carousel__product-title\">{$name}</h3>";
|
||||
}
|
||||
if ($price) {
|
||||
$html .= "<div class=\"wn-product-carousel__price\">{$price}</div>";
|
||||
}
|
||||
$html .= '</a>';
|
||||
}
|
||||
$html .= '</div>';
|
||||
}
|
||||
|
||||
$html .= '</div></section>';
|
||||
|
||||
return $html;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render Shoppable Image section
|
||||
*/
|
||||
public static function render_shoppable_image($props, $layout, $color_scheme, $id, $element_styles = [], $section_styles = [])
|
||||
{
|
||||
$title = esc_html($props['title'] ?? '');
|
||||
$subtitle = esc_html($props['subtitle'] ?? '');
|
||||
$image = esc_url($props['image'] ?? '');
|
||||
$alt = esc_attr($props['alt'] ?? 'Shoppable image');
|
||||
$hotspots = $props['hotspots'] ?? [];
|
||||
if (!is_array($hotspots)) {
|
||||
$hotspots = [];
|
||||
}
|
||||
|
||||
$html = "<section id=\"{$id}\" class=\"wn-section wn-shoppable-image wn-scheme--{$color_scheme}\">";
|
||||
$html .= '<div class="wn-shoppable-image__inner">';
|
||||
|
||||
if ($title || $subtitle) {
|
||||
$html .= '<div class="wn-shoppable-image__header">';
|
||||
if ($title) {
|
||||
$title_style = self::generate_style_attr($element_styles['title'] ?? []);
|
||||
$html .= "<h2 class=\"wn-shoppable-image__title\" {$title_style}>{$title}</h2>";
|
||||
}
|
||||
if ($subtitle) {
|
||||
$subtitle_style = self::generate_style_attr($element_styles['subtitle'] ?? []);
|
||||
$html .= "<p class=\"wn-shoppable-image__subtitle\" {$subtitle_style}>{$subtitle}</p>";
|
||||
}
|
||||
$html .= '</div>';
|
||||
}
|
||||
|
||||
$html .= '<div class="wn-shoppable-image__media">';
|
||||
if ($image) {
|
||||
$html .= "<img class=\"wn-shoppable-image__image\" src=\"{$image}\" alt=\"{$alt}\" loading=\"lazy\" />";
|
||||
}
|
||||
|
||||
if (!empty($hotspots)) {
|
||||
$html .= '<ul class="wn-shoppable-image__hotspots">';
|
||||
foreach ($hotspots as $hotspot) {
|
||||
if (!is_array($hotspot)) {
|
||||
continue;
|
||||
}
|
||||
$name = esc_html($hotspot['product_name'] ?? 'Product');
|
||||
$slug = esc_attr($hotspot['product_slug'] ?? '');
|
||||
$url = $slug ? esc_url(home_url('/product/' . $slug . '/')) : '';
|
||||
$price = esc_html($hotspot['product_price'] ?? '');
|
||||
$x = isset($hotspot['x']) ? esc_attr($hotspot['x']) : '50';
|
||||
$y = isset($hotspot['y']) ? esc_attr($hotspot['y']) : '50';
|
||||
|
||||
$html .= "<li class=\"wn-shoppable-image__hotspot\" style=\"left:{$x}%;top:{$y}%\">";
|
||||
$html .= $url ? "<a href=\"{$url}\">{$name}</a>" : $name;
|
||||
if ($price) {
|
||||
$html .= "<span class=\"wn-shoppable-image__price\">{$price}</span>";
|
||||
}
|
||||
$html .= '</li>';
|
||||
}
|
||||
$html .= '</ul>';
|
||||
}
|
||||
|
||||
$html .= '</div></div></section>';
|
||||
|
||||
return $html;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render Marquee Banner section
|
||||
*/
|
||||
public static function render_marquee_banner($props, $layout, $color_scheme, $id, $element_styles = [], $section_styles = [])
|
||||
{
|
||||
$text = (string) ($props['text'] ?? '');
|
||||
$separator = (string) ($props['separator'] ?? '*');
|
||||
$items = array_filter(array_map('trim', explode($separator, $text)));
|
||||
|
||||
if (empty($items) && $text) {
|
||||
$items = [$text];
|
||||
}
|
||||
|
||||
$html = "<section id=\"{$id}\" class=\"wn-section wn-marquee wn-scheme--{$color_scheme}\">";
|
||||
$html .= '<div class="wn-marquee__track">';
|
||||
foreach ($items as $item) {
|
||||
$label = esc_html($item);
|
||||
if ($label) {
|
||||
$html .= "<span class=\"wn-marquee__item\">{$label}</span>";
|
||||
}
|
||||
}
|
||||
$html .= '</div></section>';
|
||||
|
||||
return $html;
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to get SVG for known icons
|
||||
*/
|
||||
|
||||
@@ -7,83 +7,296 @@ namespace WooNooW\Frontend;
|
||||
*/
|
||||
class PlaceholderRenderer
|
||||
{
|
||||
/**
|
||||
* Cache timeout in seconds (5 minutes)
|
||||
*/
|
||||
const CACHE_TIMEOUT = 300;
|
||||
|
||||
/**
|
||||
* Dynamic source type definitions
|
||||
* Used for typed output contracts and validation
|
||||
*/
|
||||
const SOURCE_TYPES = [
|
||||
'post_title' => 'scalar',
|
||||
'title' => 'scalar',
|
||||
'post_content' => 'html',
|
||||
'content' => 'html',
|
||||
'post_excerpt' => 'scalar',
|
||||
'excerpt' => 'scalar',
|
||||
'post_featured_image' => 'url',
|
||||
'featured_image' => 'url',
|
||||
'post_author' => 'scalar',
|
||||
'author' => 'scalar',
|
||||
'post_date' => 'scalar',
|
||||
'date' => 'scalar',
|
||||
'post_categories' => 'array',
|
||||
'categories' => 'array',
|
||||
'post_tags' => 'array',
|
||||
'tags' => 'array',
|
||||
'post_url' => 'url',
|
||||
'url' => 'url',
|
||||
'permalink' => 'url',
|
||||
'related_posts' => 'array',
|
||||
];
|
||||
|
||||
/**
|
||||
* Fallback values for when a source resolves to empty
|
||||
*/
|
||||
const FALLBACK_VALUES = [
|
||||
'post_title' => '(Untitled)',
|
||||
'title' => '(Untitled)',
|
||||
'post_featured_image' => '',
|
||||
'featured_image' => '',
|
||||
'post_excerpt' => '',
|
||||
'excerpt' => '',
|
||||
'related_posts' => [],
|
||||
];
|
||||
|
||||
/**
|
||||
* Get expected type for a source
|
||||
*
|
||||
* @param string $source Placeholder source
|
||||
* @return string Type: scalar, html, url, array
|
||||
*/
|
||||
public static function get_source_type($source)
|
||||
{
|
||||
if (isset(self::SOURCE_TYPES[$source])) {
|
||||
return self::SOURCE_TYPES[$source];
|
||||
}
|
||||
|
||||
// Custom field types
|
||||
if (strpos($source, '_field_') !== false) {
|
||||
return 'scalar';
|
||||
}
|
||||
|
||||
// Direct key - guess based on naming
|
||||
if (strpos($source, 'image') !== false || strpos($source, 'thumbnail') !== false) {
|
||||
return 'url';
|
||||
}
|
||||
|
||||
if (strpos($source, 'ids') !== false || strpos($source, 'tags') !== false) {
|
||||
return 'array';
|
||||
}
|
||||
|
||||
return 'scalar';
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate resolved value matches expected type
|
||||
*
|
||||
* @param mixed $value Resolved value
|
||||
* @param string $expected_type Expected type
|
||||
* @return bool True if valid
|
||||
*/
|
||||
public static function validate_value_type($value, $expected_type)
|
||||
{
|
||||
switch ($expected_type) {
|
||||
case 'scalar':
|
||||
case 'html':
|
||||
return is_string($value) || is_numeric($value);
|
||||
|
||||
case 'url':
|
||||
return is_string($value) && !empty($value);
|
||||
|
||||
case 'array':
|
||||
return is_array($value);
|
||||
|
||||
default:
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get fallback value for a source
|
||||
*
|
||||
* @param string $source Placeholder source
|
||||
* @param string $expected_type Expected type
|
||||
* @return mixed Fallback value
|
||||
*/
|
||||
public static function get_fallback($source, $expected_type = null)
|
||||
{
|
||||
if ($expected_type === 'array') {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (isset(self::FALLBACK_VALUES[$source])) {
|
||||
return self::FALLBACK_VALUES[$source];
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a source is array-based (needs special handling)
|
||||
*
|
||||
* @param string $source Placeholder source
|
||||
* @return bool
|
||||
*/
|
||||
public static function is_array_source($source)
|
||||
{
|
||||
return self::get_source_type($source) === 'array';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get cached post data
|
||||
*
|
||||
* @param int $post_id Post ID
|
||||
* @return array|null Cached post data or null if not cached
|
||||
*/
|
||||
public static function get_cached_post_data($post_id)
|
||||
{
|
||||
$cache_key = "wn_post_data_{$post_id}";
|
||||
return get_transient($cache_key);
|
||||
}
|
||||
|
||||
/**
|
||||
* Cache post data
|
||||
*
|
||||
* @param int $post_id Post ID
|
||||
* @param array $data Post data
|
||||
*/
|
||||
public static function cache_post_data($post_id, $data)
|
||||
{
|
||||
$cache_key = "wn_post_data_{$post_id}";
|
||||
set_transient($cache_key, $data, self::CACHE_TIMEOUT);
|
||||
}
|
||||
|
||||
/**
|
||||
* Invalidate post data cache
|
||||
*
|
||||
* @param int $post_id Post ID
|
||||
*/
|
||||
public static function invalidate_post_data_cache($post_id)
|
||||
{
|
||||
$cache_key = "wn_post_data_{$post_id}";
|
||||
delete_transient($cache_key);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get value for a dynamic placeholder source
|
||||
*
|
||||
*
|
||||
* @param string $source Placeholder source (e.g., 'post_title', 'post_content')
|
||||
* @param array $post_data Post data array
|
||||
* @param array $options Resolution options
|
||||
* @return mixed Resolved value
|
||||
*/
|
||||
public static function get_value($source, $post_data)
|
||||
public static function get_value($source, $post_data, $options = [])
|
||||
{
|
||||
$options = wp_parse_args($options, [
|
||||
'use_fallback' => true,
|
||||
'validate_type' => true,
|
||||
]);
|
||||
|
||||
if (empty($source) || empty($post_data)) {
|
||||
return '';
|
||||
return $options['use_fallback'] ? self::get_fallback($source) : '';
|
||||
}
|
||||
|
||||
|
||||
$expected_type = $options['validate_type'] ? self::get_source_type($source) : 'scalar';
|
||||
$value = '';
|
||||
|
||||
// Standard post fields
|
||||
switch ($source) {
|
||||
case 'post_title':
|
||||
case 'title':
|
||||
return $post_data['title'] ?? $post_data['post_title'] ?? '';
|
||||
|
||||
$value = $post_data['title'] ?? $post_data['post_title'] ?? '';
|
||||
break;
|
||||
|
||||
case 'post_content':
|
||||
case 'content':
|
||||
return $post_data['content'] ?? $post_data['post_content'] ?? '';
|
||||
|
||||
$value = $post_data['content'] ?? $post_data['post_content'] ?? '';
|
||||
break;
|
||||
|
||||
case 'post_excerpt':
|
||||
case 'excerpt':
|
||||
return $post_data['excerpt'] ?? $post_data['post_excerpt'] ?? '';
|
||||
|
||||
$value = $post_data['excerpt'] ?? $post_data['post_excerpt'] ?? '';
|
||||
break;
|
||||
|
||||
case 'post_featured_image':
|
||||
case 'featured_image':
|
||||
return $post_data['featured_image'] ??
|
||||
$post_data['thumbnail'] ??
|
||||
$value = $post_data['featured_image'] ??
|
||||
$post_data['thumbnail'] ??
|
||||
$post_data['_thumbnail_url'] ?? '';
|
||||
|
||||
break;
|
||||
|
||||
case 'post_author':
|
||||
case 'author':
|
||||
return $post_data['author'] ?? $post_data['post_author'] ?? '';
|
||||
|
||||
$value = $post_data['author'] ?? $post_data['post_author'] ?? '';
|
||||
break;
|
||||
|
||||
case 'post_date':
|
||||
case 'date':
|
||||
return $post_data['date'] ?? $post_data['post_date'] ?? '';
|
||||
|
||||
$value = $post_data['date'] ?? $post_data['post_date'] ?? '';
|
||||
break;
|
||||
|
||||
case 'post_categories':
|
||||
case 'categories':
|
||||
return $post_data['categories'] ?? [];
|
||||
|
||||
$value = $post_data['categories'] ?? [];
|
||||
break;
|
||||
|
||||
case 'post_tags':
|
||||
case 'tags':
|
||||
return $post_data['tags'] ?? [];
|
||||
|
||||
$value = $post_data['tags'] ?? [];
|
||||
break;
|
||||
|
||||
case 'post_url':
|
||||
case 'url':
|
||||
case 'permalink':
|
||||
return $post_data['url'] ?? $post_data['permalink'] ?? '';
|
||||
$value = $post_data['url'] ?? $post_data['permalink'] ?? '';
|
||||
break;
|
||||
|
||||
default:
|
||||
// Check for custom meta fields (format: {cpt}_field_{name})
|
||||
if (strpos($source, '_field_') !== false) {
|
||||
$parts = explode('_field_', $source);
|
||||
$field_name = end($parts);
|
||||
|
||||
// Try to get from meta array
|
||||
if (isset($post_data['meta'][$field_name])) {
|
||||
$value = $post_data['meta'][$field_name];
|
||||
} elseif (isset($post_data[$field_name])) {
|
||||
// Try direct field access
|
||||
$value = $post_data[$field_name];
|
||||
}
|
||||
} elseif (strpos($source, 'related_posts') !== false) {
|
||||
// Handle related_posts through the dedicated method
|
||||
$value = self::get_related_posts_for_post_data($post_data);
|
||||
} elseif (isset($post_data[$source])) {
|
||||
// Check for direct key match
|
||||
$value = $post_data[$source];
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
// Check for custom meta fields (format: {cpt}_field_{name})
|
||||
if (strpos($source, '_field_') !== false) {
|
||||
$parts = explode('_field_', $source);
|
||||
$field_name = end($parts);
|
||||
|
||||
// Try to get from meta array
|
||||
if (isset($post_data['meta'][$field_name])) {
|
||||
return $post_data['meta'][$field_name];
|
||||
}
|
||||
|
||||
// Try direct field access
|
||||
if (isset($post_data[$field_name])) {
|
||||
return $post_data[$field_name];
|
||||
}
|
||||
|
||||
// Validate type if enabled
|
||||
if ($options['validate_type'] && !self::validate_value_type($value, $expected_type)) {
|
||||
$value = $options['use_fallback'] ? self::get_fallback($source, $expected_type) : '';
|
||||
}
|
||||
|
||||
// Check for direct key match
|
||||
if (isset($post_data[$source])) {
|
||||
return $post_data[$source];
|
||||
|
||||
// Apply fallback for empty values if enabled
|
||||
if ($options['use_fallback'] && $value === '') {
|
||||
$value = self::get_fallback($source, $expected_type);
|
||||
}
|
||||
|
||||
return '';
|
||||
|
||||
return $value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get related posts from post_data array
|
||||
*
|
||||
* @param array $post_data Post data array (must contain 'id' and 'type')
|
||||
* @param int $count Number of related posts
|
||||
* @return array Related posts data
|
||||
*/
|
||||
public static function get_related_posts_for_post_data($post_data, $count = 3)
|
||||
{
|
||||
$post_id = $post_data['id'] ?? 0;
|
||||
$post_type = $post_data['type'] ?? 'post';
|
||||
|
||||
if (!$post_id) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return self::get_related_posts($post_id, $count, $post_type);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
253
includes/Frontend/SchemaMigration.php
Normal file
253
includes/Frontend/SchemaMigration.php
Normal file
@@ -0,0 +1,253 @@
|
||||
<?php
|
||||
namespace WooNooW\Frontend;
|
||||
|
||||
/**
|
||||
* Schema Migration Handler
|
||||
* Handles backward compatibility for legacy saved structures
|
||||
*/
|
||||
class SchemaMigration
|
||||
{
|
||||
/**
|
||||
* Current schema version
|
||||
*/
|
||||
const CURRENT_VERSION = 1;
|
||||
|
||||
/**
|
||||
* Migration functions per version
|
||||
*/
|
||||
private static $migrations = [
|
||||
1 => 'migrate_to_v1',
|
||||
];
|
||||
|
||||
/**
|
||||
* Migrate a page/template structure to current schema
|
||||
*
|
||||
* @param array $structure Page or template structure
|
||||
* @return array Migrated structure
|
||||
*/
|
||||
public static function migrate($structure)
|
||||
{
|
||||
$version = $structure['schemaVersion'] ?? 0;
|
||||
|
||||
// Already at current version
|
||||
if ($version >= self::CURRENT_VERSION) {
|
||||
return $structure;
|
||||
}
|
||||
|
||||
// Apply migrations in order
|
||||
for ($v = $version + 1; $v <= self::CURRENT_VERSION; $v++) {
|
||||
if (isset(self::$migrations[$v])) {
|
||||
$method = self::$migrations[$v];
|
||||
$structure = self::$method($structure);
|
||||
}
|
||||
}
|
||||
|
||||
// Set current version
|
||||
$structure['schemaVersion'] = self::CURRENT_VERSION;
|
||||
|
||||
return $structure;
|
||||
}
|
||||
|
||||
/**
|
||||
* Migrate to version 1
|
||||
* - Normalize feature-grid items/features
|
||||
* - Normalize style keys
|
||||
* - Add missing defaults
|
||||
*
|
||||
* @param array $structure
|
||||
* @return array
|
||||
*/
|
||||
private static function migrate_to_v1($structure)
|
||||
{
|
||||
if (!isset($structure['sections']) || !is_array($structure['sections'])) {
|
||||
return $structure;
|
||||
}
|
||||
|
||||
foreach ($structure['sections'] as &$section) {
|
||||
// Migrate section type
|
||||
if (!isset($section['type'])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Feature grid: normalize items/features
|
||||
if ($section['type'] === 'feature-grid') {
|
||||
$section = self::migrate_feature_grid($section);
|
||||
}
|
||||
|
||||
// Normalize section styles
|
||||
$section = self::migrate_section_styles($section);
|
||||
|
||||
// Normalize element styles
|
||||
$section = self::migrate_element_styles($section);
|
||||
}
|
||||
|
||||
return $structure;
|
||||
}
|
||||
|
||||
/**
|
||||
* Migrate feature-grid section
|
||||
* - Ensure items array exists
|
||||
* - Copy features to items if needed
|
||||
*
|
||||
* @param array $section
|
||||
* @return array
|
||||
*/
|
||||
private static function migrate_feature_grid($section)
|
||||
{
|
||||
$props = $section['props'] ?? [];
|
||||
|
||||
// Handle items/features normalization
|
||||
if (!isset($props['items']) && isset($props['features'])) {
|
||||
// features exists but items doesn't - copy features to items
|
||||
$props['items'] = $props['features'];
|
||||
|
||||
// If features was an empty string, convert to empty array
|
||||
if ($props['items'] === '') {
|
||||
$props['items'] = [];
|
||||
}
|
||||
|
||||
// Keep features for backward compat but prefer items
|
||||
// Don't remove it yet - some old code may still reference it
|
||||
}
|
||||
|
||||
// Ensure items is always an array
|
||||
if (!isset($props['items'])) {
|
||||
$props['items'] = [];
|
||||
} elseif (!is_array($props['items'])) {
|
||||
$props['items'] = [];
|
||||
}
|
||||
|
||||
$section['props'] = $props;
|
||||
|
||||
return $section;
|
||||
}
|
||||
|
||||
/**
|
||||
* Migrate section styles
|
||||
* - Normalize key names
|
||||
* - Add missing defaults
|
||||
*
|
||||
* @param array $section
|
||||
* @return array
|
||||
*/
|
||||
private static function migrate_section_styles($section)
|
||||
{
|
||||
$styles = $section['styles'] ?? [];
|
||||
|
||||
// Normalize contentWidth if missing
|
||||
if (!isset($styles['contentWidth']) && isset($styles['container_width'])) {
|
||||
// Old key name
|
||||
$styles['contentWidth'] = $styles['container_width'];
|
||||
unset($styles['container_width']);
|
||||
}
|
||||
|
||||
// Normalize background type
|
||||
if (isset($styles['backgroundImage']) && !isset($styles['backgroundType'])) {
|
||||
// If there's a background image but no type, assume image type
|
||||
$styles['backgroundType'] = 'image';
|
||||
}
|
||||
|
||||
// Normalize height preset
|
||||
if (isset($styles['height']) && !isset($styles['heightPreset'])) {
|
||||
$height = $styles['height'];
|
||||
$map = [
|
||||
'small' => 'small',
|
||||
'medium' => 'medium',
|
||||
'large' => 'large',
|
||||
'fullscreen' => 'fullscreen',
|
||||
'screen' => 'fullscreen', // Old naming
|
||||
'default' => 'default',
|
||||
];
|
||||
$styles['heightPreset'] = $map[$height] ?? 'default';
|
||||
unset($styles['height']);
|
||||
}
|
||||
|
||||
// Add default background settings if missing
|
||||
if (!isset($styles['backgroundType'])) {
|
||||
$styles['backgroundType'] = 'solid';
|
||||
}
|
||||
|
||||
$section['styles'] = $styles;
|
||||
|
||||
return $section;
|
||||
}
|
||||
|
||||
/**
|
||||
* Migrate element styles
|
||||
* - Normalize key names
|
||||
* - Add missing defaults
|
||||
*
|
||||
* @param array $section
|
||||
* @return array
|
||||
*/
|
||||
private static function migrate_element_styles($section)
|
||||
{
|
||||
$elementStyles = $section['elementStyles'] ?? [];
|
||||
|
||||
// Normalize common element style keys
|
||||
$normalizations = [
|
||||
'cta' => 'cta_text', // Old key
|
||||
'button' => 'cta_text', // Alias for button
|
||||
'heading' => 'heading', // Standardize
|
||||
'text' => 'text', // Standardize
|
||||
'subtitle' => 'subtitle', // Standardize
|
||||
];
|
||||
|
||||
$normalized = [];
|
||||
foreach ($elementStyles as $key => $style) {
|
||||
$normalized_key = $normalizations[$key] ?? $key;
|
||||
$normalized[$normalized_key] = $style;
|
||||
}
|
||||
|
||||
// Add default styling keys if missing
|
||||
$defaults = [
|
||||
'title' => [],
|
||||
'subtitle' => [],
|
||||
'text' => [],
|
||||
'cta_text' => [],
|
||||
'feature_item' => [],
|
||||
];
|
||||
|
||||
foreach ($defaults as $key => $default) {
|
||||
if (!isset($normalized[$key])) {
|
||||
$normalized[$key] = $default;
|
||||
}
|
||||
}
|
||||
|
||||
$section['elementStyles'] = $normalized;
|
||||
|
||||
return $section;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a structure needs migration
|
||||
*
|
||||
* @param array $structure
|
||||
* @return bool
|
||||
*/
|
||||
public static function needs_migration($structure)
|
||||
{
|
||||
return ($structure['schemaVersion'] ?? 0) < self::CURRENT_VERSION;
|
||||
}
|
||||
|
||||
/**
|
||||
* Batch migrate multiple structures
|
||||
*
|
||||
* @param array $structures Array of structures
|
||||
* @return array Migrated structures
|
||||
*/
|
||||
public static function migrate_all($structures)
|
||||
{
|
||||
return array_map([self::class, 'migrate'], $structures);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current schema version
|
||||
*
|
||||
* @return int
|
||||
*/
|
||||
public static function get_current_version()
|
||||
{
|
||||
return self::CURRENT_VERSION;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user