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:
Dwindi Ramadhana
2026-05-30 13:02:08 +07:00
parent e70aa1f554
commit 396ca25be4
118 changed files with 10162 additions and 3726 deletions

View File

@@ -6,6 +6,7 @@ use WP_REST_Request;
use WP_REST_Response;
use WP_Error;
use WooNooW\Frontend\PlaceholderRenderer;
use WooNooW\Frontend\SchemaMigration;
use WooNooW\Frontend\PageSSR;
use WooNooW\Templates\TemplateRegistry;
@@ -107,6 +108,20 @@ class PagesController
'permission_callback' => [__CLASS__, 'check_admin_permission'],
]);
// Resolve template sections for editor canvas preview.
register_rest_route($namespace, '/preview/resolve/(?P<cpt>[a-zA-Z0-9_-]+)', [
'methods' => 'POST',
'callback' => [__CLASS__, 'resolve_template_preview'],
'permission_callback' => [__CLASS__, 'check_admin_permission'],
]);
// List sample posts for template preview context selector.
register_rest_route($namespace, '/preview/samples/(?P<cpt>[a-zA-Z0-9_-]+)', [
'methods' => 'GET',
'callback' => [__CLASS__, 'get_template_preview_samples'],
'permission_callback' => [__CLASS__, 'check_admin_permission'],
]);
// Set page as SPA Landing (shown at SPA root route)
register_rest_route($namespace, '/pages/(?P<id>\d+)/set-as-spa-landing', [
'methods' => 'POST',
@@ -153,6 +168,7 @@ class PagesController
$pages = get_posts([
'post_type' => 'page',
'posts_per_page' => -1,
'post_status' => 'any',
'meta_query' => [
[
'key' => '_wn_page_structure',
@@ -206,6 +222,12 @@ class PagesController
$structure = get_post_meta($page->ID, '_wn_page_structure', true);
// Migrate structure if needed
if ($structure && SchemaMigration::needs_migration($structure)) {
$structure = SchemaMigration::migrate($structure);
update_post_meta($page->ID, '_wn_page_structure', $structure);
}
// Get SPA settings
$settings = get_option('woonoow_appearance_settings', []);
$spa_frontpage_id = $settings['general']['spa_frontpage'] ?? 0;
@@ -217,16 +239,13 @@ class PagesController
$container_width = get_post_meta($page->ID, '_wn_page_container_width', true);
return new WP_REST_Response([
'id' => $page->ID,
'type' => 'page',
'slug' => $page->post_name,
'id' => $page->ID,
'type' => 'page',
'slug' => $page->post_name,
'title' => $page->post_title,
'seo' => $seo,
'is_spa_frontpage' => (int)$page->ID === (int)$spa_frontpage_id,
'container_width' => $container_width ?: 'default', // local setting
'container_width' => $container_width ?: 'default',
'effective_container_width' => ($container_width && $container_width !== 'default')
? $container_width
: (($settings['general']['container_width'] ?? 'boxed') ?: 'boxed'),
@@ -254,10 +273,14 @@ class PagesController
return new WP_Error('invalid_data', 'Missing sections data', ['status' => 400]);
}
// Save structure
// Migrate structure before saving to ensure consistency
$migrated_structure = SchemaMigration::migrate(['sections' => $structure, 'type' => 'page']);
// Save structure with schema version
$save_data = [
'type' => 'page',
'sections' => $structure,
'sections' => $migrated_structure['sections'],
'schemaVersion' => SchemaMigration::get_current_version(),
'updated_at' => current_time('mysql'),
];
@@ -293,6 +316,13 @@ class PagesController
}
$template = get_option("wn_template_{$cpt}", null);
// Migrate template if needed
if ($template && SchemaMigration::needs_migration($template)) {
$template = SchemaMigration::migrate($template);
update_option("wn_template_{$cpt}", $template);
}
$cpt_obj = get_post_type_object($cpt);
return new WP_REST_Response([
@@ -324,11 +354,15 @@ class PagesController
return new WP_Error('invalid_data', 'Missing sections data', ['status' => 400]);
}
// Save template
// Migrate structure before saving
$migrated_structure = SchemaMigration::migrate(['sections' => $structure, 'type' => 'template']);
// Save template with schema version
$save_data = [
'type' => 'template',
'cpt' => $cpt,
'sections' => $structure,
'sections' => $migrated_structure['sections'],
'schemaVersion' => SchemaMigration::get_current_version(),
'updated_at' => current_time('mysql'),
];
@@ -409,41 +443,7 @@ class PagesController
// If template exists, resolve placeholders
$rendered_sections = [];
if ($template && !empty($template['sections'])) {
foreach ($template['sections'] as $section) {
$resolved_section = $section;
// Pre-resolve special dynamic sources that produce arrays before PageSSR::resolve_props
$props = $section['props'] ?? [];
foreach ($props as $key => $prop) {
if (is_array($prop) && ($prop['type'] ?? '') === 'dynamic' && ($prop['source'] ?? '') === 'related_posts') {
$props[$key] = [
'type' => 'static',
'value' => PlaceholderRenderer::get_related_posts($post->ID, 3, $type),
];
}
}
$resolved_section['props'] = PageSSR::resolve_props($props, $post_data);
// Resolve dynamicBackground in styles
// If styles.dynamicBackground === 'post_featured_image', set styles.backgroundImage from post data
$styles = $resolved_section['styles'] ?? [];
if (!empty($styles['dynamicBackground']) && (empty($styles['backgroundType']) || $styles['backgroundType'] === 'image')) {
$dyn_source = $styles['dynamicBackground'];
if ($dyn_source === 'post_featured_image' || $dyn_source === 'featured_image') {
$featured_url = $post_data['featured_image'] ?? '';
if (!empty($featured_url)) {
$styles['backgroundImage'] = $featured_url;
$styles['backgroundType'] = 'image';
}
}
// Remove the internal marker from the rendered output
unset($styles['dynamicBackground']);
$resolved_section['styles'] = $styles;
}
$rendered_sections[] = $resolved_section;
}
$rendered_sections = self::resolve_sections_for_post($template['sections'], $post, $type);
}
return new WP_REST_Response([
@@ -458,6 +458,64 @@ class PagesController
], 200);
}
/**
* Resolve template preview sections for editor canvas rendering.
*/
public static function resolve_template_preview(WP_REST_Request $request)
{
$cpt = $request->get_param('cpt');
$body = $request->get_json_params();
$sections = $body['sections'] ?? [];
$sample_post = self::get_preview_sample_post($cpt, $body['sample_post_id'] ?? null);
if (!$sample_post) {
return new WP_REST_Response([
'sections' => $sections,
'sample_post' => null,
'resolved' => false,
], 200);
}
return new WP_REST_Response([
'sections' => self::resolve_sections_for_post($sections, $sample_post, $cpt),
'sample_post' => [
'id' => $sample_post->ID,
'title' => $sample_post->post_title,
'type' => $sample_post->post_type,
],
'resolved' => true,
], 200);
}
/**
* Get sample posts for editor template preview context.
*/
public static function get_template_preview_samples(WP_REST_Request $request)
{
$cpt = $request->get_param('cpt');
if (!$cpt || $cpt === 'page') {
return new WP_REST_Response(['items' => []], 200);
}
$posts = get_posts([
'post_type' => $cpt,
'posts_per_page' => 20,
'post_status' => 'publish',
'orderby' => 'date',
'order' => 'DESC',
]);
$items = array_map(function ($post) {
return [
'id' => $post->ID,
'title' => $post->post_title,
'type' => $post->post_type,
];
}, $posts);
return new WP_REST_Response(['items' => $items], 200);
}
/**
* Set page as SPA Landing (the page shown at SPA root route)
* This does NOT affect WordPress page_on_front setting.
@@ -747,28 +805,12 @@ class PagesController
$sections = $body['sections'] ?? [];
// Get sample post for dynamic placeholders
$sample_post = null;
if ($cpt && $cpt !== 'page') {
$posts = get_posts([
'post_type' => $cpt,
'posts_per_page' => 1,
'post_status' => 'publish',
]);
if (!empty($posts)) {
$sample_post = $posts[0];
}
}
$sample_post = self::get_preview_sample_post($cpt, $body['sample_post_id'] ?? null);
// Resolve placeholders if sample post exists
$resolved_sections = $sections;
if ($sample_post) {
$post_data = PlaceholderRenderer::build_post_data($sample_post);
$resolved_sections = [];
foreach ($sections as $section) {
$resolved_section = $section;
$resolved_section['props'] = PageSSR::resolve_props($section['props'] ?? [], $post_data);
$resolved_sections[] = $resolved_section;
}
$resolved_sections = self::resolve_sections_for_post($sections, $sample_post, $cpt);
}
$cpt_obj = get_post_type_object($cpt);
@@ -786,6 +828,66 @@ class PagesController
], 200);
}
/**
* Resolve section props/styles against a concrete post context.
*/
private static function resolve_sections_for_post($sections, $post, $type)
{
$post_data = PlaceholderRenderer::build_post_data($post);
$resolved_sections = [];
foreach ($sections as $section) {
$resolved_section = $section;
$props = $section['props'] ?? [];
// Resolve all props using PlaceholderRenderer (handles related_posts via get_value)
$resolved_section['props'] = PageSSR::resolve_props($props, $post_data);
$styles = $resolved_section['styles'] ?? [];
if (!empty($styles['dynamicBackground']) && (empty($styles['backgroundType']) || $styles['backgroundType'] === 'image')) {
$dyn_source = $styles['dynamicBackground'];
if ($dyn_source === 'post_featured_image' || $dyn_source === 'featured_image') {
$featured_url = $post_data['featured_image'] ?? '';
if (!empty($featured_url)) {
$styles['backgroundImage'] = $featured_url;
$styles['backgroundType'] = 'image';
}
}
unset($styles['dynamicBackground']);
$resolved_section['styles'] = $styles;
}
$resolved_sections[] = $resolved_section;
}
return $resolved_sections;
}
/**
* Pick a preview sample post by explicit id, or fall back to the newest published item.
*/
private static function get_preview_sample_post($cpt, $sample_post_id = null)
{
if (!$cpt || $cpt === 'page') {
return null;
}
if ($sample_post_id) {
$post = get_post((int) $sample_post_id);
if ($post && $post->post_type === $cpt) {
return $post;
}
}
$posts = get_posts([
'post_type' => $cpt,
'posts_per_page' => 1,
'post_status' => 'publish',
]);
return !empty($posts) ? $posts[0] : null;
}
/**
* Helper: Render preview HTML document
*/

View File

@@ -527,9 +527,11 @@ class ProductsController
// Virtual and downloadable
if (array_key_exists('virtual', $data)) {
error_log("Setting virtual to: " . ($data['virtual'] ? 'true' : 'false') . " for product ID: " . $product->get_id());
$product->set_virtual((bool) $data['virtual']);
}
if (array_key_exists('downloadable', $data)) {
error_log("Setting downloadable to: " . ($data['downloadable'] ? 'true' : 'false') . " for product ID: " . $product->get_id());
$product->set_downloadable((bool) $data['downloadable']);
}
if (array_key_exists('featured', $data)) {
@@ -887,6 +889,62 @@ class ProductsController
}
$data['gallery'] = $gallery;
// Video / rich media - stored in product meta
$video_url = get_post_meta($product->get_id(), '_woonoow_video_url', true) ?: '';
$data['video_url'] = $video_url;
// Detect embed type (youtube / vimeo / direct mp4)
$data['video_type'] = '';
if ($video_url) {
if (strpos($video_url, 'youtube.com') !== false || strpos($video_url, 'youtu.be') !== false) {
$data['video_type'] = 'youtube';
} elseif (strpos($video_url, 'vimeo.com') !== false) {
$data['video_type'] = 'vimeo';
} else {
$data['video_type'] = 'mp4';
}
}
// Media attachments - allow plugins/themes to inject additional media (e.g. 3D models, extra videos)
$media_attachments = apply_filters('woonoow_product_media_attachments', [], $product->get_id(), $product);
$data['media_attachments'] = is_array($media_attachments) ? $media_attachments : [];
// Cross-sells (shown in mini-cart / cart page)
$cross_sell_ids = $product->get_cross_sell_ids();
$cross_sells = [];
foreach (array_slice($cross_sell_ids, 0, 4) as $cs_id) {
$cs = wc_get_product($cs_id);
if (!$cs || !$cs->is_visible()) continue;
$cs_image_id = $cs->get_image_id();
$cross_sells[] = [
'id' => $cs->get_id(),
'name' => $cs->get_name(),
'slug' => $cs->get_slug(),
'price' => $cs->get_price(),
'regular_price' => $cs->get_regular_price(),
'image' => $cs_image_id ? wp_get_attachment_url($cs_image_id) : '',
];
}
$data['cross_sells'] = $cross_sells;
// Upsells (shown on product page below main content)
$upsell_ids = $product->get_upsell_ids();
$upsells = [];
foreach (array_slice($upsell_ids, 0, 4) as $up_id) {
$up = wc_get_product($up_id);
if (!$up || !$up->is_visible()) continue;
$up_image_id = $up->get_image_id();
$upsells[] = [
'id' => $up->get_id(),
'name' => $up->get_name(),
'slug' => $up->get_slug(),
'price' => $up->get_price(),
'regular_price' => $up->get_regular_price(),
'image' => $up_image_id ? wp_get_attachment_url($up_image_id) : '',
];
}
$data['upsells'] = $upsells;
// Variable product specifics
if ($product->is_type('variable')) {
$data['attributes'] = self::get_product_attributes($product);
@@ -993,6 +1051,8 @@ class ProductsController
'stock_status' => $variation->get_stock_status(),
'stock_quantity' => $variation->get_stock_quantity(),
'manage_stock' => $variation->get_manage_stock(),
'virtual' => $variation->is_virtual(),
'downloadable' => $variation->is_downloadable(),
'attributes' => $formatted_attributes,
'image_id' => $variation->get_image_id(),
'image_url' => $image_url,
@@ -1118,9 +1178,11 @@ class ProductsController
$variation->set_image_id($var_data['image_id']);
}
// Inherit virtual status from parent if parent is virtual
if ($product->is_virtual()) {
$variation->set_virtual(true);
if (array_key_exists('virtual', $var_data)) {
$variation->set_virtual((bool) $var_data['virtual']);
}
if (array_key_exists('downloadable', $var_data)) {
$variation->set_downloadable((bool) $var_data['downloadable']);
}
// Save variation first

174
includes/Features.php Normal file
View File

@@ -0,0 +1,174 @@
<?php
namespace WooNooW;
/**
* Feature Flags
* Manages feature toggles for staged rollout of new features
*/
class Features
{
/**
* Feature flag defaults
*/
private static $defaults = [
'dynamic_preview' => false, // Enable template preview with real data
'schema_v1' => true, // Use v1 schema with migrations
'enhanced_ssr' => true, // Enhanced SSR with full style support
'placeholder_cache' => true, // Cache resolved placeholders
];
/**
* Get a feature flag value
*
* @param string $feature Feature name
* @param bool $default Default value if not set
* @return bool
*/
public static function get($feature, $default = null)
{
$defaults = self::$defaults;
$default = $default ?? ($defaults[$feature] ?? false);
$settings = get_option('woonoow_feature_settings', []);
return isset($settings[$feature]) ? (bool) $settings[$feature] : $default;
}
/**
* Set a feature flag value
*
* @param string $feature Feature name
* @param bool $value Value to set
*/
public static function set($feature, $value)
{
$settings = get_option('woonoow_feature_settings', []);
$settings[$feature] = (bool) $value;
update_option('woonoow_feature_settings', $settings);
}
/**
* Check if dynamic preview is enabled
*
* @return bool
*/
public static function is_dynamic_preview_enabled()
{
return self::get('dynamic_preview');
}
/**
* Check if schema v1 migration is enabled
*
* @return bool
*/
public static function is_schema_v1_enabled()
{
return self::get('schema_v1');
}
/**
* Check if enhanced SSR is enabled
*
* @return bool
*/
public static function is_enhanced_ssr_enabled()
{
return self::get('enhanced_ssr');
}
/**
* Check if placeholder caching is enabled
*
* @return bool
*/
public static function is_placeholder_cache_enabled()
{
return self::get('placeholder_cache');
}
/**
* Get all feature flag values
*
* @return array
*/
public static function get_all()
{
$settings = get_option('woonoow_feature_settings', []);
$result = [];
foreach (self::$defaults as $feature => $default) {
$result[$feature] = isset($settings[$feature])
? (bool) $settings[$feature]
: $default;
}
return $result;
}
/**
* Reset all feature flags to defaults
*/
public static function reset_all()
{
delete_option('woonoow_feature_settings');
}
}
/**
* Hook helper to conditionally enable features based on user role
*/
class FeatureAccess
{
/**
* Check if current user can access a feature
*
* @param string $feature Feature name
* @return bool
*/
public static function can_access($feature)
{
// Check if feature is globally enabled
if (!Features::get($feature)) {
// Check for admin override
$override_key = "woonoow_{$feature}_override";
if (get_transient($override_key) === 'enabled') {
return true;
}
return false;
}
return true;
}
/**
* Enable a feature for a specific user
*
* @param string $feature Feature name
* @param int $user_id User ID
*/
public static function enable_for_user($feature, $user_id = null)
{
if (!$user_id) {
$user_id = get_current_user_id();
}
$transient_key = "woonoow_user_{$user_id}_{$feature}";
set_transient($transient_key, 'enabled', DAY_IN_SECONDS);
}
/**
* Disable a feature for a specific user
*
* @param string $feature Feature name
* @param int $user_id User ID
*/
public static function disable_for_user($feature, $user_id = null)
{
if (!$user_id) {
$user_id = get_current_user_id();
}
$transient_key = "woonoow_user_{$user_id}_{$feature}";
delete_transient($transient_key);
}
}

View File

@@ -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
*/

View File

@@ -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);
}
/**

View 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;
}
}