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