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
427 lines
13 KiB
PHP
427 lines
13 KiB
PHP
<?php
|
|
namespace WooNooW\Frontend;
|
|
|
|
/**
|
|
* Placeholder Renderer
|
|
* Resolves dynamic placeholders to actual post/CPT data
|
|
*/
|
|
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, $options = [])
|
|
{
|
|
$options = wp_parse_args($options, [
|
|
'use_fallback' => true,
|
|
'validate_type' => true,
|
|
]);
|
|
|
|
if (empty($source) || empty($post_data)) {
|
|
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':
|
|
$value = $post_data['title'] ?? $post_data['post_title'] ?? '';
|
|
break;
|
|
|
|
case 'post_content':
|
|
case 'content':
|
|
$value = $post_data['content'] ?? $post_data['post_content'] ?? '';
|
|
break;
|
|
|
|
case 'post_excerpt':
|
|
case 'excerpt':
|
|
$value = $post_data['excerpt'] ?? $post_data['post_excerpt'] ?? '';
|
|
break;
|
|
|
|
case 'post_featured_image':
|
|
case 'featured_image':
|
|
$value = $post_data['featured_image'] ??
|
|
$post_data['thumbnail'] ??
|
|
$post_data['_thumbnail_url'] ?? '';
|
|
break;
|
|
|
|
case 'post_author':
|
|
case 'author':
|
|
$value = $post_data['author'] ?? $post_data['post_author'] ?? '';
|
|
break;
|
|
|
|
case 'post_date':
|
|
case 'date':
|
|
$value = $post_data['date'] ?? $post_data['post_date'] ?? '';
|
|
break;
|
|
|
|
case 'post_categories':
|
|
case 'categories':
|
|
$value = $post_data['categories'] ?? [];
|
|
break;
|
|
|
|
case 'post_tags':
|
|
case 'tags':
|
|
$value = $post_data['tags'] ?? [];
|
|
break;
|
|
|
|
case 'post_url':
|
|
case 'url':
|
|
case '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;
|
|
}
|
|
|
|
// 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) : '';
|
|
}
|
|
|
|
// Apply fallback for empty values if enabled
|
|
if ($options['use_fallback'] && $value === '') {
|
|
$value = self::get_fallback($source, $expected_type);
|
|
}
|
|
|
|
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);
|
|
}
|
|
|
|
/**
|
|
* Build post data array from WP_Post object
|
|
*
|
|
* @param \WP_Post|int $post Post object or ID
|
|
* @return array Post data array
|
|
*/
|
|
public static function build_post_data($post)
|
|
{
|
|
if (is_numeric($post)) {
|
|
$post = get_post($post);
|
|
}
|
|
|
|
if (!$post || !($post instanceof \WP_Post)) {
|
|
return [];
|
|
}
|
|
|
|
$data = [
|
|
'id' => $post->ID,
|
|
'title' => $post->post_title,
|
|
'content' => apply_filters('the_content', $post->post_content),
|
|
'excerpt' => $post->post_excerpt ?: wp_trim_words($post->post_content, 30),
|
|
'date' => get_the_date('', $post),
|
|
'date_iso' => get_the_date('c', $post),
|
|
'url' => get_permalink($post),
|
|
'slug' => $post->post_name,
|
|
'type' => $post->post_type,
|
|
];
|
|
|
|
// Author
|
|
$author_id = $post->post_author;
|
|
$data['author'] = get_the_author_meta('display_name', $author_id);
|
|
$data['author_url'] = get_author_posts_url($author_id);
|
|
|
|
// Featured image
|
|
$thumbnail_id = get_post_thumbnail_id($post);
|
|
if ($thumbnail_id) {
|
|
$data['featured_image'] = get_the_post_thumbnail_url($post, 'large');
|
|
$data['featured_image_id'] = $thumbnail_id;
|
|
}
|
|
|
|
// Taxonomies
|
|
$taxonomies = get_object_taxonomies($post->post_type);
|
|
foreach ($taxonomies as $taxonomy) {
|
|
$terms = get_the_terms($post, $taxonomy);
|
|
if ($terms && !is_wp_error($terms)) {
|
|
$data[$taxonomy] = array_map(function($term) {
|
|
return [
|
|
'id' => $term->term_id,
|
|
'name' => $term->name,
|
|
'slug' => $term->slug,
|
|
'url' => get_term_link($term),
|
|
];
|
|
}, $terms);
|
|
}
|
|
}
|
|
|
|
// Shortcuts for common taxonomies
|
|
if (isset($data['category'])) {
|
|
$data['categories'] = $data['category'];
|
|
}
|
|
if (isset($data['post_tag'])) {
|
|
$data['tags'] = $data['post_tag'];
|
|
}
|
|
|
|
// Custom meta fields
|
|
$meta = get_post_meta($post->ID);
|
|
if ($meta) {
|
|
$data['meta'] = [];
|
|
foreach ($meta as $key => $values) {
|
|
// Skip internal meta keys
|
|
if (strpos($key, '_') === 0) {
|
|
continue;
|
|
}
|
|
$data['meta'][$key] = count($values) === 1 ? $values[0] : $values;
|
|
}
|
|
}
|
|
|
|
return $data;
|
|
}
|
|
|
|
/**
|
|
* Get related posts
|
|
*
|
|
* @param int $post_id Current post ID
|
|
* @param int $count Number of related posts
|
|
* @param string $post_type Post type
|
|
* @return array Related posts data
|
|
*/
|
|
public static function get_related_posts($post_id, $count = 3, $post_type = 'post')
|
|
{
|
|
// Get categories of current post
|
|
$categories = get_the_category($post_id);
|
|
$category_ids = wp_list_pluck($categories, 'term_id');
|
|
|
|
$args = [
|
|
'post_type' => $post_type,
|
|
'posts_per_page' => $count,
|
|
'post__not_in' => [$post_id],
|
|
'orderby' => 'date',
|
|
'order' => 'DESC',
|
|
];
|
|
|
|
if (!empty($category_ids)) {
|
|
$args['category__in'] = $category_ids;
|
|
}
|
|
|
|
$query = new \WP_Query($args);
|
|
$related = [];
|
|
|
|
foreach ($query->posts as $post) {
|
|
$related[] = [
|
|
'id' => $post->ID,
|
|
'title' => $post->post_title,
|
|
'excerpt' => $post->post_excerpt ?: wp_trim_words($post->post_content, 20),
|
|
'url' => get_permalink($post),
|
|
'featured_image' => get_the_post_thumbnail_url($post, 'medium'),
|
|
'date' => get_the_date('', $post),
|
|
];
|
|
}
|
|
|
|
wp_reset_postdata();
|
|
|
|
return $related;
|
|
}
|
|
}
|