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

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