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:
@@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user