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