feat: Page Editor Phase 1 - Core Infrastructure
- Add is_bot() detection in TemplateOverride.php (30+ bot patterns)
- Add PageSSR.php for server-side rendering of page sections
- Add PlaceholderRenderer.php for dynamic content resolution
- Add PagesController.php REST API for pages/templates CRUD
- Register PagesController routes in Routes.php
API Endpoints:
- GET /pages - list all pages/templates
- GET /pages/{slug} - get page structure
- POST /pages/{slug} - save page
- GET /templates/{cpt} - get CPT template
- POST /templates/{cpt} - save template
- GET /content/{type}/{slug} - get content with template applied
This commit is contained in:
473
includes/Api/PagesController.php
Normal file
473
includes/Api/PagesController.php
Normal file
@@ -0,0 +1,473 @@
|
|||||||
|
<?php
|
||||||
|
namespace WooNooW\Api;
|
||||||
|
|
||||||
|
use WP_REST_Request;
|
||||||
|
use WP_REST_Response;
|
||||||
|
use WP_Error;
|
||||||
|
use WooNooW\Frontend\PlaceholderRenderer;
|
||||||
|
use WooNooW\Frontend\PageSSR;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pages Controller
|
||||||
|
* REST API for page structures and CPT templates
|
||||||
|
*/
|
||||||
|
class PagesController
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Register API routes
|
||||||
|
*/
|
||||||
|
public static function register_routes()
|
||||||
|
{
|
||||||
|
$namespace = 'woonoow/v1';
|
||||||
|
|
||||||
|
// List all pages and templates
|
||||||
|
register_rest_route($namespace, '/pages', [
|
||||||
|
'methods' => 'GET',
|
||||||
|
'callback' => [__CLASS__, 'get_pages'],
|
||||||
|
'permission_callback' => '__return_true',
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Get/Save page structure (structural pages)
|
||||||
|
register_rest_route($namespace, '/pages/(?P<slug>[a-zA-Z0-9_-]+)', [
|
||||||
|
[
|
||||||
|
'methods' => 'GET',
|
||||||
|
'callback' => [__CLASS__, 'get_page'],
|
||||||
|
'permission_callback' => '__return_true',
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'methods' => 'POST',
|
||||||
|
'callback' => [__CLASS__, 'save_page'],
|
||||||
|
'permission_callback' => [__CLASS__, 'check_admin_permission'],
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Get/Save CPT templates
|
||||||
|
register_rest_route($namespace, '/templates/(?P<cpt>[a-zA-Z0-9_-]+)', [
|
||||||
|
[
|
||||||
|
'methods' => 'GET',
|
||||||
|
'callback' => [__CLASS__, 'get_template'],
|
||||||
|
'permission_callback' => '__return_true',
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'methods' => 'POST',
|
||||||
|
'callback' => [__CLASS__, 'save_template'],
|
||||||
|
'permission_callback' => [__CLASS__, 'check_admin_permission'],
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Get post with template applied (for SPA rendering)
|
||||||
|
register_rest_route($namespace, '/content/(?P<type>[a-zA-Z0-9_-]+)/(?P<slug>[a-zA-Z0-9_-]+)', [
|
||||||
|
'methods' => 'GET',
|
||||||
|
'callback' => [__CLASS__, 'get_content_with_template'],
|
||||||
|
'permission_callback' => '__return_true',
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Create new page
|
||||||
|
register_rest_route($namespace, '/pages', [
|
||||||
|
'methods' => 'POST',
|
||||||
|
'callback' => [__CLASS__, 'create_page'],
|
||||||
|
'permission_callback' => [__CLASS__, 'check_admin_permission'],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check admin permission
|
||||||
|
*/
|
||||||
|
public static function check_admin_permission()
|
||||||
|
{
|
||||||
|
return current_user_can('manage_woocommerce');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all pages and templates
|
||||||
|
*/
|
||||||
|
public static function get_pages(WP_REST_Request $request)
|
||||||
|
{
|
||||||
|
$result = [];
|
||||||
|
|
||||||
|
// Get structural pages (pages with WooNooW structure)
|
||||||
|
$pages = get_posts([
|
||||||
|
'post_type' => 'page',
|
||||||
|
'posts_per_page' => -1,
|
||||||
|
'meta_query' => [
|
||||||
|
[
|
||||||
|
'key' => '_wn_page_structure',
|
||||||
|
'compare' => 'EXISTS',
|
||||||
|
],
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
foreach ($pages as $page) {
|
||||||
|
$result[] = [
|
||||||
|
'id' => $page->ID,
|
||||||
|
'type' => 'page',
|
||||||
|
'slug' => $page->post_name,
|
||||||
|
'title' => $page->post_title,
|
||||||
|
'url' => get_permalink($page),
|
||||||
|
'icon' => 'page',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get CPT templates
|
||||||
|
$cpts = self::get_editable_post_types();
|
||||||
|
foreach ($cpts as $cpt => $label) {
|
||||||
|
$template = get_option("wn_template_{$cpt}", null);
|
||||||
|
|
||||||
|
$result[] = [
|
||||||
|
'type' => 'template',
|
||||||
|
'cpt' => $cpt,
|
||||||
|
'title' => "{$label} Template",
|
||||||
|
'icon' => 'template',
|
||||||
|
'permalink_base' => self::get_cpt_permalink_base($cpt),
|
||||||
|
'has_template' => !empty($template),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return new WP_REST_Response($result, 200);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get page structure by slug
|
||||||
|
*/
|
||||||
|
public static function get_page(WP_REST_Request $request)
|
||||||
|
{
|
||||||
|
$slug = $request->get_param('slug');
|
||||||
|
|
||||||
|
// Find page by slug
|
||||||
|
$page = get_page_by_path($slug);
|
||||||
|
if (!$page) {
|
||||||
|
return new WP_Error('not_found', 'Page not found', ['status' => 404]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$structure = get_post_meta($page->ID, '_wn_page_structure', true);
|
||||||
|
|
||||||
|
// Get SEO data (Yoast/Rank Math)
|
||||||
|
$seo = self::get_seo_data($page->ID);
|
||||||
|
|
||||||
|
return new WP_REST_Response([
|
||||||
|
'id' => $page->ID,
|
||||||
|
'type' => 'page',
|
||||||
|
'slug' => $page->post_name,
|
||||||
|
'title' => $page->post_title,
|
||||||
|
'url' => get_permalink($page),
|
||||||
|
'seo' => $seo,
|
||||||
|
'structure' => $structure ?: ['sections' => []],
|
||||||
|
], 200);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Save page structure
|
||||||
|
*/
|
||||||
|
public static function save_page(WP_REST_Request $request)
|
||||||
|
{
|
||||||
|
$slug = $request->get_param('slug');
|
||||||
|
$body = $request->get_json_params();
|
||||||
|
|
||||||
|
// Find page by slug
|
||||||
|
$page = get_page_by_path($slug);
|
||||||
|
if (!$page) {
|
||||||
|
return new WP_Error('not_found', 'Page not found', ['status' => 404]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate structure
|
||||||
|
$structure = $body['sections'] ?? null;
|
||||||
|
if ($structure === null) {
|
||||||
|
return new WP_Error('invalid_data', 'Missing sections data', ['status' => 400]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save structure
|
||||||
|
$save_data = [
|
||||||
|
'type' => 'page',
|
||||||
|
'sections' => $structure,
|
||||||
|
'updated_at' => current_time('mysql'),
|
||||||
|
];
|
||||||
|
|
||||||
|
update_post_meta($page->ID, '_wn_page_structure', $save_data);
|
||||||
|
|
||||||
|
return new WP_REST_Response([
|
||||||
|
'success' => true,
|
||||||
|
'page' => [
|
||||||
|
'id' => $page->ID,
|
||||||
|
'slug' => $page->post_name,
|
||||||
|
],
|
||||||
|
], 200);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get CPT template
|
||||||
|
*/
|
||||||
|
public static function get_template(WP_REST_Request $request)
|
||||||
|
{
|
||||||
|
$cpt = $request->get_param('cpt');
|
||||||
|
|
||||||
|
// Validate CPT exists
|
||||||
|
if (!post_type_exists($cpt) && $cpt !== 'post') {
|
||||||
|
return new WP_Error('invalid_cpt', 'Invalid post type', ['status' => 400]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$template = get_option("wn_template_{$cpt}", null);
|
||||||
|
$cpt_obj = get_post_type_object($cpt);
|
||||||
|
|
||||||
|
return new WP_REST_Response([
|
||||||
|
'type' => 'template',
|
||||||
|
'cpt' => $cpt,
|
||||||
|
'title' => $cpt_obj ? $cpt_obj->labels->singular_name . ' Template' : ucfirst($cpt) . ' Template',
|
||||||
|
'permalink_base' => self::get_cpt_permalink_base($cpt),
|
||||||
|
'available_sources' => self::get_available_sources($cpt),
|
||||||
|
'structure' => $template ?: ['sections' => []],
|
||||||
|
], 200);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Save CPT template
|
||||||
|
*/
|
||||||
|
public static function save_template(WP_REST_Request $request)
|
||||||
|
{
|
||||||
|
$cpt = $request->get_param('cpt');
|
||||||
|
$body = $request->get_json_params();
|
||||||
|
|
||||||
|
// Validate CPT exists
|
||||||
|
if (!post_type_exists($cpt) && $cpt !== 'post') {
|
||||||
|
return new WP_Error('invalid_cpt', 'Invalid post type', ['status' => 400]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate structure
|
||||||
|
$structure = $body['sections'] ?? null;
|
||||||
|
if ($structure === null) {
|
||||||
|
return new WP_Error('invalid_data', 'Missing sections data', ['status' => 400]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save template
|
||||||
|
$save_data = [
|
||||||
|
'type' => 'template',
|
||||||
|
'cpt' => $cpt,
|
||||||
|
'sections' => $structure,
|
||||||
|
'updated_at' => current_time('mysql'),
|
||||||
|
];
|
||||||
|
|
||||||
|
update_option("wn_template_{$cpt}", $save_data);
|
||||||
|
|
||||||
|
return new WP_REST_Response([
|
||||||
|
'success' => true,
|
||||||
|
'template' => [
|
||||||
|
'cpt' => $cpt,
|
||||||
|
],
|
||||||
|
], 200);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get content with template applied (for SPA rendering)
|
||||||
|
*/
|
||||||
|
public static function get_content_with_template(WP_REST_Request $request)
|
||||||
|
{
|
||||||
|
$type = $request->get_param('type');
|
||||||
|
$slug = $request->get_param('slug');
|
||||||
|
|
||||||
|
// Handle structural pages
|
||||||
|
if ($type === 'page') {
|
||||||
|
return self::get_page($request);
|
||||||
|
}
|
||||||
|
|
||||||
|
// For CPT items, get post and apply template
|
||||||
|
$post = get_page_by_path($slug, OBJECT, $type);
|
||||||
|
if (!$post) {
|
||||||
|
// Try with post type 'post' if type is 'blog'
|
||||||
|
if ($type === 'blog') {
|
||||||
|
$post = get_page_by_path($slug, OBJECT, 'post');
|
||||||
|
$type = 'post';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!$post) {
|
||||||
|
return new WP_Error('not_found', 'Content not found', ['status' => 404]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get template for this CPT
|
||||||
|
$template = get_option("wn_template_{$type}", null);
|
||||||
|
|
||||||
|
// Build post data
|
||||||
|
$post_data = PlaceholderRenderer::build_post_data($post);
|
||||||
|
|
||||||
|
// Get SEO data
|
||||||
|
$seo = self::get_seo_data($post->ID);
|
||||||
|
|
||||||
|
// If template exists, resolve placeholders
|
||||||
|
$rendered_sections = [];
|
||||||
|
if ($template && !empty($template['sections'])) {
|
||||||
|
foreach ($template['sections'] as $section) {
|
||||||
|
$resolved_section = $section;
|
||||||
|
$resolved_section['props'] = PageSSR::resolve_props($section['props'] ?? [], $post_data);
|
||||||
|
$rendered_sections[] = $resolved_section;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return new WP_REST_Response([
|
||||||
|
'type' => 'content',
|
||||||
|
'cpt' => $type,
|
||||||
|
'post' => $post_data,
|
||||||
|
'seo' => $seo,
|
||||||
|
'template' => $template ?: ['sections' => []],
|
||||||
|
'rendered' => [
|
||||||
|
'sections' => $rendered_sections,
|
||||||
|
],
|
||||||
|
], 200);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create new page
|
||||||
|
*/
|
||||||
|
public static function create_page(WP_REST_Request $request)
|
||||||
|
{
|
||||||
|
$body = $request->get_json_params();
|
||||||
|
|
||||||
|
$title = sanitize_text_field($body['title'] ?? '');
|
||||||
|
$slug = sanitize_title($body['slug'] ?? $title);
|
||||||
|
|
||||||
|
if (empty($title)) {
|
||||||
|
return new WP_Error('invalid_data', 'Title is required', ['status' => 400]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if page already exists
|
||||||
|
if (get_page_by_path($slug)) {
|
||||||
|
return new WP_Error('exists', 'Page with this slug already exists', ['status' => 400]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create page
|
||||||
|
$page_id = wp_insert_post([
|
||||||
|
'post_type' => 'page',
|
||||||
|
'post_title' => $title,
|
||||||
|
'post_name' => $slug,
|
||||||
|
'post_status' => 'publish',
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (is_wp_error($page_id)) {
|
||||||
|
return $page_id;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize empty structure
|
||||||
|
$structure = [
|
||||||
|
'type' => 'page',
|
||||||
|
'sections' => [],
|
||||||
|
'created_at' => current_time('mysql'),
|
||||||
|
];
|
||||||
|
|
||||||
|
update_post_meta($page_id, '_wn_page_structure', $structure);
|
||||||
|
|
||||||
|
return new WP_REST_Response([
|
||||||
|
'success' => true,
|
||||||
|
'page' => [
|
||||||
|
'id' => $page_id,
|
||||||
|
'slug' => $slug,
|
||||||
|
'title' => $title,
|
||||||
|
'url' => get_permalink($page_id),
|
||||||
|
],
|
||||||
|
], 201);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// Helper Methods
|
||||||
|
// ========================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get editable post types for templates
|
||||||
|
*/
|
||||||
|
private static function get_editable_post_types()
|
||||||
|
{
|
||||||
|
$types = [
|
||||||
|
'post' => 'Blog Post',
|
||||||
|
];
|
||||||
|
|
||||||
|
// Get public custom post types
|
||||||
|
$custom_types = get_post_types([
|
||||||
|
'public' => true,
|
||||||
|
'_builtin' => false,
|
||||||
|
], 'objects');
|
||||||
|
|
||||||
|
foreach ($custom_types as $type) {
|
||||||
|
// Skip WooCommerce types (handled separately)
|
||||||
|
if (in_array($type->name, ['product', 'shop_order', 'shop_coupon'])) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$types[$type->name] = $type->labels->singular_name;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $types;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get permalink base for a CPT
|
||||||
|
*/
|
||||||
|
private static function get_cpt_permalink_base($cpt)
|
||||||
|
{
|
||||||
|
if ($cpt === 'post') {
|
||||||
|
// Get blog permalink structure
|
||||||
|
$struct = get_option('permalink_structure');
|
||||||
|
if (strpos($struct, '%postname%') !== false) {
|
||||||
|
return '/blog/';
|
||||||
|
}
|
||||||
|
return '/';
|
||||||
|
}
|
||||||
|
|
||||||
|
$obj = get_post_type_object($cpt);
|
||||||
|
if ($obj && isset($obj->rewrite['slug'])) {
|
||||||
|
return '/' . $obj->rewrite['slug'] . '/';
|
||||||
|
}
|
||||||
|
|
||||||
|
return '/' . $cpt . '/';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get available dynamic sources for a CPT
|
||||||
|
*/
|
||||||
|
private static function get_available_sources($cpt)
|
||||||
|
{
|
||||||
|
$sources = [
|
||||||
|
['value' => 'post_title', 'label' => 'Title'],
|
||||||
|
['value' => 'post_content', 'label' => 'Content'],
|
||||||
|
['value' => 'post_excerpt', 'label' => 'Excerpt'],
|
||||||
|
['value' => 'post_featured_image', 'label' => 'Featured Image'],
|
||||||
|
['value' => 'post_author', 'label' => 'Author'],
|
||||||
|
['value' => 'post_date', 'label' => 'Date'],
|
||||||
|
['value' => 'post_url', 'label' => 'Permalink'],
|
||||||
|
];
|
||||||
|
|
||||||
|
// Add taxonomy sources
|
||||||
|
$taxonomies = get_object_taxonomies($cpt, 'objects');
|
||||||
|
foreach ($taxonomies as $tax) {
|
||||||
|
if ($tax->public) {
|
||||||
|
$sources[] = [
|
||||||
|
'value' => $tax->name,
|
||||||
|
'label' => $tax->labels->name,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add related posts source
|
||||||
|
$sources[] = ['value' => 'related_posts', 'label' => 'Related Posts'];
|
||||||
|
|
||||||
|
return $sources;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get SEO data for a post (Yoast/Rank Math compatible)
|
||||||
|
*/
|
||||||
|
private static function get_seo_data($post_id)
|
||||||
|
{
|
||||||
|
$seo = [];
|
||||||
|
|
||||||
|
// Try Yoast
|
||||||
|
$seo['meta_title'] = get_post_meta($post_id, '_yoast_wpseo_title', true) ?: get_the_title($post_id);
|
||||||
|
$seo['meta_description'] = get_post_meta($post_id, '_yoast_wpseo_metadesc', true);
|
||||||
|
$seo['canonical'] = get_post_meta($post_id, '_yoast_wpseo_canonical', true) ?: get_permalink($post_id);
|
||||||
|
$seo['og_title'] = get_post_meta($post_id, '_yoast_wpseo_opengraph-title', true);
|
||||||
|
$seo['og_description'] = get_post_meta($post_id, '_yoast_wpseo_opengraph-description', true);
|
||||||
|
$seo['og_image'] = get_post_meta($post_id, '_yoast_wpseo_opengraph-image', true);
|
||||||
|
|
||||||
|
// Try Rank Math if Yoast not available
|
||||||
|
if (empty($seo['meta_description'])) {
|
||||||
|
$seo['meta_description'] = get_post_meta($post_id, 'rank_math_description', true);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $seo;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -35,6 +35,7 @@ use WooNooW\Frontend\HookBridge;
|
|||||||
use WooNooW\Api\Controllers\SettingsController;
|
use WooNooW\Api\Controllers\SettingsController;
|
||||||
use WooNooW\Api\Controllers\CartController as ApiCartController;
|
use WooNooW\Api\Controllers\CartController as ApiCartController;
|
||||||
use WooNooW\Admin\AppearanceController;
|
use WooNooW\Admin\AppearanceController;
|
||||||
|
use WooNooW\Api\PagesController;
|
||||||
|
|
||||||
class Routes {
|
class Routes {
|
||||||
public static function init() {
|
public static function init() {
|
||||||
@@ -181,6 +182,9 @@ class Routes {
|
|||||||
AddressController::register_routes();
|
AddressController::register_routes();
|
||||||
WishlistController::register_routes();
|
WishlistController::register_routes();
|
||||||
HookBridge::register_routes();
|
HookBridge::register_routes();
|
||||||
|
|
||||||
|
// Pages and templates controller
|
||||||
|
PagesController::register_routes();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
290
includes/Frontend/PageSSR.php
Normal file
290
includes/Frontend/PageSSR.php
Normal file
@@ -0,0 +1,290 @@
|
|||||||
|
<?php
|
||||||
|
namespace WooNooW\Frontend;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Page SSR (Server-Side Rendering)
|
||||||
|
* Renders page sections as static HTML for search engine crawlers
|
||||||
|
*/
|
||||||
|
class PageSSR
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Render page structure to HTML
|
||||||
|
*
|
||||||
|
* @param array $structure Page structure with sections
|
||||||
|
* @param array|null $post_data Post data for dynamic placeholders
|
||||||
|
* @return string Rendered HTML
|
||||||
|
*/
|
||||||
|
public static function render($structure, $post_data = null)
|
||||||
|
{
|
||||||
|
if (empty($structure) || empty($structure['sections'])) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
$html = '';
|
||||||
|
|
||||||
|
foreach ($structure['sections'] as $section) {
|
||||||
|
$html .= self::render_section($section, $post_data);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $html;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render a single section to HTML
|
||||||
|
*
|
||||||
|
* @param array $section Section data
|
||||||
|
* @param array|null $post_data Post data for placeholders
|
||||||
|
* @return string Section HTML
|
||||||
|
*/
|
||||||
|
public static function render_section($section, $post_data = null)
|
||||||
|
{
|
||||||
|
$type = $section['type'] ?? 'content';
|
||||||
|
$props = $section['props'] ?? [];
|
||||||
|
$layout = $section['layoutVariant'] ?? 'default';
|
||||||
|
$color_scheme = $section['colorScheme'] ?? 'default';
|
||||||
|
|
||||||
|
// Resolve all props (replace dynamic placeholders with actual values)
|
||||||
|
$resolved_props = self::resolve_props($props, $post_data);
|
||||||
|
|
||||||
|
// Generate section ID for anchor links
|
||||||
|
$section_id = $section['id'] ?? 'section-' . uniqid();
|
||||||
|
|
||||||
|
// Render based on section type
|
||||||
|
$method = 'render_' . str_replace('-', '_', $type);
|
||||||
|
if (method_exists(__CLASS__, $method)) {
|
||||||
|
return self::$method($resolved_props, $layout, $color_scheme, $section_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: generic section wrapper
|
||||||
|
return self::render_generic($resolved_props, $type, $section_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve props - replace dynamic placeholders with actual values
|
||||||
|
*
|
||||||
|
* @param array $props Section props
|
||||||
|
* @param array|null $post_data Post data
|
||||||
|
* @return array Resolved props with actual values
|
||||||
|
*/
|
||||||
|
public static function resolve_props($props, $post_data = null)
|
||||||
|
{
|
||||||
|
$resolved = [];
|
||||||
|
|
||||||
|
foreach ($props as $key => $prop) {
|
||||||
|
if (!is_array($prop)) {
|
||||||
|
$resolved[$key] = $prop;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$type = $prop['type'] ?? 'static';
|
||||||
|
|
||||||
|
if ($type === 'static') {
|
||||||
|
$resolved[$key] = $prop['value'] ?? '';
|
||||||
|
} elseif ($type === 'dynamic' && $post_data) {
|
||||||
|
$source = $prop['source'] ?? '';
|
||||||
|
$resolved[$key] = PlaceholderRenderer::get_value($source, $post_data);
|
||||||
|
} else {
|
||||||
|
$resolved[$key] = $prop['value'] ?? '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $resolved;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// Section Renderers
|
||||||
|
// ========================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render Hero section
|
||||||
|
*/
|
||||||
|
public static function render_hero($props, $layout, $color_scheme, $id)
|
||||||
|
{
|
||||||
|
$title = esc_html($props['title'] ?? '');
|
||||||
|
$subtitle = esc_html($props['subtitle'] ?? '');
|
||||||
|
$image = esc_url($props['image'] ?? '');
|
||||||
|
$cta_text = esc_html($props['cta_text'] ?? '');
|
||||||
|
$cta_url = esc_url($props['cta_url'] ?? '');
|
||||||
|
|
||||||
|
$html = "<section id=\"{$id}\" class=\"wn-section wn-hero wn-hero--{$layout} wn-scheme--{$color_scheme}\">";
|
||||||
|
|
||||||
|
if ($image) {
|
||||||
|
$html .= "<img src=\"{$image}\" alt=\"{$title}\" class=\"wn-hero__image\" />";
|
||||||
|
}
|
||||||
|
|
||||||
|
$html .= '<div class="wn-hero__content">';
|
||||||
|
if ($title) {
|
||||||
|
$html .= "<h1 class=\"wn-hero__title\">{$title}</h1>";
|
||||||
|
}
|
||||||
|
if ($subtitle) {
|
||||||
|
$html .= "<p class=\"wn-hero__subtitle\">{$subtitle}</p>";
|
||||||
|
}
|
||||||
|
if ($cta_text && $cta_url) {
|
||||||
|
$html .= "<a href=\"{$cta_url}\" class=\"wn-hero__cta\">{$cta_text}</a>";
|
||||||
|
}
|
||||||
|
$html .= '</div>';
|
||||||
|
$html .= '</section>';
|
||||||
|
|
||||||
|
return $html;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render Content section (for post body, rich text)
|
||||||
|
*/
|
||||||
|
public static function render_content($props, $layout, $color_scheme, $id)
|
||||||
|
{
|
||||||
|
$content = $props['content'] ?? '';
|
||||||
|
// Apply WordPress content filters (shortcodes, autop, etc.)
|
||||||
|
$content = apply_filters('the_content', $content);
|
||||||
|
|
||||||
|
return "<section id=\"{$id}\" class=\"wn-section wn-content wn-scheme--{$color_scheme}\">{$content}</section>";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render Image + Text section
|
||||||
|
*/
|
||||||
|
public static function render_image_text($props, $layout, $color_scheme, $id)
|
||||||
|
{
|
||||||
|
$title = esc_html($props['title'] ?? '');
|
||||||
|
$text = wp_kses_post($props['text'] ?? '');
|
||||||
|
$image = esc_url($props['image'] ?? '');
|
||||||
|
|
||||||
|
$html = "<section id=\"{$id}\" class=\"wn-section wn-image-text wn-image-text--{$layout} wn-scheme--{$color_scheme}\">";
|
||||||
|
|
||||||
|
if ($image) {
|
||||||
|
$html .= "<div class=\"wn-image-text__image\"><img src=\"{$image}\" alt=\"{$title}\" /></div>";
|
||||||
|
}
|
||||||
|
|
||||||
|
$html .= '<div class="wn-image-text__content">';
|
||||||
|
if ($title) {
|
||||||
|
$html .= "<h2 class=\"wn-image-text__title\">{$title}</h2>";
|
||||||
|
}
|
||||||
|
if ($text) {
|
||||||
|
$html .= "<div class=\"wn-image-text__text\">{$text}</div>";
|
||||||
|
}
|
||||||
|
$html .= '</div>';
|
||||||
|
$html .= '</section>';
|
||||||
|
|
||||||
|
return $html;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render Feature Grid section
|
||||||
|
*/
|
||||||
|
public static function render_feature_grid($props, $layout, $color_scheme, $id)
|
||||||
|
{
|
||||||
|
$heading = esc_html($props['heading'] ?? '');
|
||||||
|
$items = $props['items'] ?? [];
|
||||||
|
|
||||||
|
$html = "<section id=\"{$id}\" class=\"wn-section wn-feature-grid wn-feature-grid--{$layout} wn-scheme--{$color_scheme}\">";
|
||||||
|
|
||||||
|
if ($heading) {
|
||||||
|
$html .= "<h2 class=\"wn-feature-grid__heading\">{$heading}</h2>";
|
||||||
|
}
|
||||||
|
|
||||||
|
$html .= '<div class="wn-feature-grid__items">';
|
||||||
|
foreach ($items as $item) {
|
||||||
|
$item_title = esc_html($item['title'] ?? '');
|
||||||
|
$item_desc = esc_html($item['description'] ?? '');
|
||||||
|
$item_icon = esc_html($item['icon'] ?? '');
|
||||||
|
|
||||||
|
$html .= '<div class="wn-feature-grid__item">';
|
||||||
|
if ($item_icon) {
|
||||||
|
$html .= "<span class=\"wn-feature-grid__icon\">{$item_icon}</span>";
|
||||||
|
}
|
||||||
|
if ($item_title) {
|
||||||
|
$html .= "<h3 class=\"wn-feature-grid__item-title\">{$item_title}</h3>";
|
||||||
|
}
|
||||||
|
if ($item_desc) {
|
||||||
|
$html .= "<p class=\"wn-feature-grid__item-desc\">{$item_desc}</p>";
|
||||||
|
}
|
||||||
|
$html .= '</div>';
|
||||||
|
}
|
||||||
|
$html .= '</div>';
|
||||||
|
$html .= '</section>';
|
||||||
|
|
||||||
|
return $html;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render CTA Banner section
|
||||||
|
*/
|
||||||
|
public static function render_cta_banner($props, $layout, $color_scheme, $id)
|
||||||
|
{
|
||||||
|
$title = esc_html($props['title'] ?? '');
|
||||||
|
$text = esc_html($props['text'] ?? '');
|
||||||
|
$button_text = esc_html($props['button_text'] ?? '');
|
||||||
|
$button_url = esc_url($props['button_url'] ?? '');
|
||||||
|
|
||||||
|
$html = "<section id=\"{$id}\" class=\"wn-section wn-cta-banner wn-cta-banner--{$layout} wn-scheme--{$color_scheme}\">";
|
||||||
|
$html .= '<div class="wn-cta-banner__content">';
|
||||||
|
|
||||||
|
if ($title) {
|
||||||
|
$html .= "<h2 class=\"wn-cta-banner__title\">{$title}</h2>";
|
||||||
|
}
|
||||||
|
if ($text) {
|
||||||
|
$html .= "<p class=\"wn-cta-banner__text\">{$text}</p>";
|
||||||
|
}
|
||||||
|
if ($button_text && $button_url) {
|
||||||
|
$html .= "<a href=\"{$button_url}\" class=\"wn-cta-banner__button\">{$button_text}</a>";
|
||||||
|
}
|
||||||
|
|
||||||
|
$html .= '</div>';
|
||||||
|
$html .= '</section>';
|
||||||
|
|
||||||
|
return $html;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render Contact Form section
|
||||||
|
*/
|
||||||
|
public static function render_contact_form($props, $layout, $color_scheme, $id)
|
||||||
|
{
|
||||||
|
$title = esc_html($props['title'] ?? '');
|
||||||
|
$webhook_url = esc_url($props['webhook_url'] ?? '');
|
||||||
|
$redirect_url = esc_url($props['redirect_url'] ?? '');
|
||||||
|
$fields = $props['fields'] ?? ['name', 'email', 'message'];
|
||||||
|
|
||||||
|
$html = "<section id=\"{$id}\" class=\"wn-section wn-contact-form wn-scheme--{$color_scheme}\">";
|
||||||
|
|
||||||
|
if ($title) {
|
||||||
|
$html .= "<h2 class=\"wn-contact-form__title\">{$title}</h2>";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Form is rendered but won't work for bots (they just see the structure)
|
||||||
|
$html .= '<form class="wn-contact-form__form" method="post">';
|
||||||
|
|
||||||
|
foreach ($fields as $field) {
|
||||||
|
$field_label = ucfirst(str_replace('_', ' ', $field));
|
||||||
|
$html .= '<div class="wn-contact-form__field">';
|
||||||
|
$html .= "<label>{$field_label}</label>";
|
||||||
|
if ($field === 'message') {
|
||||||
|
$html .= "<textarea name=\"{$field}\" placeholder=\"{$field_label}\"></textarea>";
|
||||||
|
} else {
|
||||||
|
$html .= "<input type=\"text\" name=\"{$field}\" placeholder=\"{$field_label}\" />";
|
||||||
|
}
|
||||||
|
$html .= '</div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
$html .= '<button type="submit">Submit</button>';
|
||||||
|
$html .= '</form>';
|
||||||
|
$html .= '</section>';
|
||||||
|
|
||||||
|
return $html;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generic section fallback
|
||||||
|
*/
|
||||||
|
public static function render_generic($props, $type, $id)
|
||||||
|
{
|
||||||
|
$content = '';
|
||||||
|
foreach ($props as $key => $value) {
|
||||||
|
if (is_string($value)) {
|
||||||
|
$content .= "<div class=\"wn-{$type}__{$key}\">" . wp_kses_post($value) . "</div>";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return "<section id=\"{$id}\" class=\"wn-section wn-{$type}\">{$content}</section>";
|
||||||
|
}
|
||||||
|
}
|
||||||
213
includes/Frontend/PlaceholderRenderer.php
Normal file
213
includes/Frontend/PlaceholderRenderer.php
Normal file
@@ -0,0 +1,213 @@
|
|||||||
|
<?php
|
||||||
|
namespace WooNooW\Frontend;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Placeholder Renderer
|
||||||
|
* Resolves dynamic placeholders to actual post/CPT data
|
||||||
|
*/
|
||||||
|
class PlaceholderRenderer
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* 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
|
||||||
|
* @return mixed Resolved value
|
||||||
|
*/
|
||||||
|
public static function get_value($source, $post_data)
|
||||||
|
{
|
||||||
|
if (empty($source) || empty($post_data)) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Standard post fields
|
||||||
|
switch ($source) {
|
||||||
|
case 'post_title':
|
||||||
|
case 'title':
|
||||||
|
return $post_data['title'] ?? $post_data['post_title'] ?? '';
|
||||||
|
|
||||||
|
case 'post_content':
|
||||||
|
case 'content':
|
||||||
|
return $post_data['content'] ?? $post_data['post_content'] ?? '';
|
||||||
|
|
||||||
|
case 'post_excerpt':
|
||||||
|
case 'excerpt':
|
||||||
|
return $post_data['excerpt'] ?? $post_data['post_excerpt'] ?? '';
|
||||||
|
|
||||||
|
case 'post_featured_image':
|
||||||
|
case 'featured_image':
|
||||||
|
return $post_data['featured_image'] ??
|
||||||
|
$post_data['thumbnail'] ??
|
||||||
|
$post_data['_thumbnail_url'] ?? '';
|
||||||
|
|
||||||
|
case 'post_author':
|
||||||
|
case 'author':
|
||||||
|
return $post_data['author'] ?? $post_data['post_author'] ?? '';
|
||||||
|
|
||||||
|
case 'post_date':
|
||||||
|
case 'date':
|
||||||
|
return $post_data['date'] ?? $post_data['post_date'] ?? '';
|
||||||
|
|
||||||
|
case 'post_categories':
|
||||||
|
case 'categories':
|
||||||
|
return $post_data['categories'] ?? [];
|
||||||
|
|
||||||
|
case 'post_tags':
|
||||||
|
case 'tags':
|
||||||
|
return $post_data['tags'] ?? [];
|
||||||
|
|
||||||
|
case 'post_url':
|
||||||
|
case 'url':
|
||||||
|
case 'permalink':
|
||||||
|
return $post_data['url'] ?? $post_data['permalink'] ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for direct key match
|
||||||
|
if (isset($post_data[$source])) {
|
||||||
|
return $post_data[$source];
|
||||||
|
}
|
||||||
|
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -648,4 +648,100 @@ class TemplateOverride
|
|||||||
|
|
||||||
return $template;
|
return $template;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Detect if current request is from a bot/crawler
|
||||||
|
* Used to serve SSR content for SEO instead of SPA redirect
|
||||||
|
*
|
||||||
|
* @return bool True if request is from a known bot
|
||||||
|
*/
|
||||||
|
public static function is_bot()
|
||||||
|
{
|
||||||
|
// Get User-Agent
|
||||||
|
$user_agent = $_SERVER['HTTP_USER_AGENT'] ?? '';
|
||||||
|
if (empty($user_agent)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert to lowercase for case-insensitive matching
|
||||||
|
$user_agent = strtolower($user_agent);
|
||||||
|
|
||||||
|
// Known bot patterns
|
||||||
|
$bot_patterns = [
|
||||||
|
// Search engine crawlers
|
||||||
|
'googlebot',
|
||||||
|
'bingbot',
|
||||||
|
'slurp', // Yahoo
|
||||||
|
'duckduckbot',
|
||||||
|
'baiduspider',
|
||||||
|
'yandexbot',
|
||||||
|
'sogou',
|
||||||
|
'exabot',
|
||||||
|
|
||||||
|
// Generic patterns
|
||||||
|
'crawler',
|
||||||
|
'spider',
|
||||||
|
'robot',
|
||||||
|
'scraper',
|
||||||
|
|
||||||
|
// Social media bots (for link previews)
|
||||||
|
'facebookexternalhit',
|
||||||
|
'twitterbot',
|
||||||
|
'linkedinbot',
|
||||||
|
'whatsapp',
|
||||||
|
'slackbot',
|
||||||
|
'telegrambot',
|
||||||
|
'discordbot',
|
||||||
|
|
||||||
|
// Other known bots
|
||||||
|
'applebot',
|
||||||
|
'semrushbot',
|
||||||
|
'ahrefsbot',
|
||||||
|
'mj12bot',
|
||||||
|
'dotbot',
|
||||||
|
'petalbot',
|
||||||
|
'bytespider',
|
||||||
|
|
||||||
|
// Prerender services
|
||||||
|
'prerender',
|
||||||
|
'headlesschrome',
|
||||||
|
];
|
||||||
|
|
||||||
|
// Check if User-Agent contains any bot pattern
|
||||||
|
foreach ($bot_patterns as $pattern) {
|
||||||
|
if (strpos($user_agent, $pattern) !== false) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Serve SSR content for bots
|
||||||
|
* Renders page structure as static HTML for search engine indexing
|
||||||
|
*
|
||||||
|
* @param int $page_id Page ID to render
|
||||||
|
* @param string $type 'page' or 'template'
|
||||||
|
* @param array|null $post_data Post data for template rendering (CPT items)
|
||||||
|
*/
|
||||||
|
public static function serve_ssr_content($page_id, $type = 'page', $post_data = null)
|
||||||
|
{
|
||||||
|
// Get page structure
|
||||||
|
if ($type === 'page') {
|
||||||
|
$structure = get_post_meta($page_id, '_wn_page_structure', true);
|
||||||
|
} else {
|
||||||
|
// CPT template
|
||||||
|
$structure = get_option("wn_template_{$type}", null);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (empty($structure) || empty($structure['sections'])) {
|
||||||
|
return false; // No structure, let normal WP handle it
|
||||||
|
}
|
||||||
|
|
||||||
|
// Will be implemented in PageSSR class
|
||||||
|
// For now, return false to let normal WP handle
|
||||||
|
// TODO: Implement PageSSR::render($structure, $post_data)
|
||||||
|
return false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
781
woonoow-page-editor-brief.md
Normal file
781
woonoow-page-editor-brief.md
Normal file
@@ -0,0 +1,781 @@
|
|||||||
|
# WooNooW Page Editor Strategy - Final Decision Brief (v3)
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
Extend WooNooW SPA to support structural pages and CPT templates with:
|
||||||
|
- **Unified Page Editor** for pages (page CPT) and CPT templates (post, portfolio, custom)
|
||||||
|
- **Native WordPress `page` CPT** for structural pages (SEO compatible)
|
||||||
|
- **Dynamic placeholders** for CPT templates (blog posts, portfolio items, etc.)
|
||||||
|
- **Dual rendering strategy** for bots vs humans (SSR + SPA redirect)
|
||||||
|
- **Zero page builder plugin dependencies**
|
||||||
|
- **Integrated admin UI** with collapsible sidebar
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Core Architecture
|
||||||
|
|
||||||
|
### Data Storage
|
||||||
|
|
||||||
|
#### Structural Pages (page CPT)
|
||||||
|
- **Location**: WordPress `wp_posts` table (post_type='page')
|
||||||
|
- **Structure Storage**: `wp_postmeta`, key: `_wn_page_structure`
|
||||||
|
- **Type**: Static content only (no placeholders)
|
||||||
|
- **Example**: Homepage, About, Contact, Terms, Privacy
|
||||||
|
|
||||||
|
#### CPT Templates
|
||||||
|
- **Location**: WordPress `wp_options` table
|
||||||
|
- **Storage Keys**: `wn_template_{cpt_name}`
|
||||||
|
- `wn_template_post` (for blog posts)
|
||||||
|
- `wn_template_portfolio` (for portfolio items)
|
||||||
|
- `wn_template_custom_cpt` (for custom CPTs)
|
||||||
|
- **Type**: Dynamic content with placeholders
|
||||||
|
- **Applies To**: All items of that CPT (one template per CPT)
|
||||||
|
|
||||||
|
### Page Structure Storage Format
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": "page|template",
|
||||||
|
"sections": [
|
||||||
|
{
|
||||||
|
"id": "section-1",
|
||||||
|
"type": "hero",
|
||||||
|
"layoutVariant": "hero-left-image",
|
||||||
|
"colorScheme": "primary",
|
||||||
|
"props": {
|
||||||
|
"title": {
|
||||||
|
"type": "static",
|
||||||
|
"value": "Welcome"
|
||||||
|
},
|
||||||
|
"subtitle": {
|
||||||
|
"type": "static",
|
||||||
|
"value": "Join us"
|
||||||
|
},
|
||||||
|
"image": {
|
||||||
|
"type": "static",
|
||||||
|
"value": "/uploads/hero.jpg"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "section-2",
|
||||||
|
"type": "content",
|
||||||
|
"props": {
|
||||||
|
"content": {
|
||||||
|
"type": "dynamic",
|
||||||
|
"source": "post_content"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "section-3",
|
||||||
|
"type": "feature-grid",
|
||||||
|
"layoutVariant": "grid-3-columns",
|
||||||
|
"props": {
|
||||||
|
"heading": {
|
||||||
|
"type": "static",
|
||||||
|
"value": "Why Choose Us"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Field Types: Static vs Dynamic
|
||||||
|
|
||||||
|
**Static Fields** (Structural Pages):
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": "static",
|
||||||
|
"value": "Hardcoded text or value"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Dynamic Fields** (CPT Templates):
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": "dynamic",
|
||||||
|
"source": "post_title|post_content|post_excerpt|post_featured_image|related_posts|..."
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Available Dynamic Sources (per CPT)
|
||||||
|
|
||||||
|
**For `post` CPT:**
|
||||||
|
- `post_title` - Post title
|
||||||
|
- `post_excerpt` - Post excerpt
|
||||||
|
- `post_content` - Post body content
|
||||||
|
- `post_featured_image` - Featured image URL
|
||||||
|
- `post_author` - Author name
|
||||||
|
- `post_date` - Publication date
|
||||||
|
- `post_categories` - Category list
|
||||||
|
- `post_tags` - Tag list
|
||||||
|
- `related_posts` - Query for related posts (with count parameter)
|
||||||
|
|
||||||
|
**For Custom CPTs:**
|
||||||
|
- `{cpt_name}_title` - CPT item title
|
||||||
|
- `{cpt_name}_featured_image` - Featured image
|
||||||
|
- `{cpt_name}_field_{field_name}` - Custom meta fields
|
||||||
|
- Custom sources defined per CPT
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Unified Page Editor Concept
|
||||||
|
|
||||||
|
### Single Editor, Two Modes
|
||||||
|
|
||||||
|
**Mode 1: Structural Pages (Static)**
|
||||||
|
- Edit individual pages (page CPT)
|
||||||
|
- All content is static/hardcoded
|
||||||
|
- Examples: Home, About, Contact, Terms, Privacy
|
||||||
|
- SEO managed by Yoast/Rank Math
|
||||||
|
|
||||||
|
**Mode 2: CPT Templates (Dynamic)**
|
||||||
|
- Design template for entire CPT
|
||||||
|
- Content has dynamic placeholders
|
||||||
|
- One template applies to all items of that CPT
|
||||||
|
- Examples: Blog Post template, Portfolio template, News template
|
||||||
|
|
||||||
|
### Create New Page Flow
|
||||||
|
|
||||||
|
When user clicks `[+ Create Page]`:
|
||||||
|
|
||||||
|
```
|
||||||
|
Modal Dialog:
|
||||||
|
┌──────────────────────────────────┐
|
||||||
|
│ Create New Page │
|
||||||
|
├──────────────────────────────────┤
|
||||||
|
│ │
|
||||||
|
│ What are you creating? │
|
||||||
|
│ │
|
||||||
|
│ ○ Structural Page │
|
||||||
|
│ Static content (page CPT) │
|
||||||
|
│ Examples: About, Contact │
|
||||||
|
│ Permalink: /page-slug │
|
||||||
|
│ │
|
||||||
|
│ ○ Blog Post Template │
|
||||||
|
│ Dynamic template for posts │
|
||||||
|
│ Applies to: /blog/* │
|
||||||
|
│ Includes placeholders │
|
||||||
|
│ │
|
||||||
|
│ ○ Portfolio Template │
|
||||||
|
│ Dynamic template for portfolio │
|
||||||
|
│ Applies to: /portfolio/* │
|
||||||
|
│ Includes placeholders │
|
||||||
|
│ │
|
||||||
|
│ ○ Other CPT Template │
|
||||||
|
│ [Select CPT ▼] │
|
||||||
|
│ Applies to: /cpt-base/* │
|
||||||
|
│ │
|
||||||
|
│ [Cancel] [Next] │
|
||||||
|
└──────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### Editor Left Panel: Pages List (Unified)
|
||||||
|
|
||||||
|
```
|
||||||
|
Pages Sidebar
|
||||||
|
|
||||||
|
📄 STRUCTURAL PAGES
|
||||||
|
├─ ✓ Homepage (page)
|
||||||
|
├─ ○ About (page)
|
||||||
|
├─ ○ Contact (page)
|
||||||
|
└─ ○ Terms (page)
|
||||||
|
|
||||||
|
📋 TEMPLATES (Dynamic)
|
||||||
|
├─ ○ Blog Post (post)
|
||||||
|
│ └─ Applies to: /blog/*
|
||||||
|
├─ ○ Portfolio (portfolio)
|
||||||
|
│ └─ Applies to: /portfolio/*
|
||||||
|
├─ ○ News (custom_cpt)
|
||||||
|
│ └─ Applies to: /news/*
|
||||||
|
└─ ○ Custom CPT (custom_name)
|
||||||
|
└─ Applies to: /custom-base/*
|
||||||
|
|
||||||
|
Visual indicators:
|
||||||
|
- 📄 = Structural page (static)
|
||||||
|
- 📋 = Template (dynamic)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Rendering Strategy: Dual Path
|
||||||
|
|
||||||
|
### Path 1: Bot Detection → Server-Side Rendering (SSR)
|
||||||
|
|
||||||
|
**When**: Bot user agent detected
|
||||||
|
**Action**: Serve pre-rendered HTML (no redirect)
|
||||||
|
**Content**: All sections rendered as static HTML strings
|
||||||
|
**Placeholders**: Replaced with actual post data at render time
|
||||||
|
**Result**: ✅ Full content indexed by search engines
|
||||||
|
|
||||||
|
```
|
||||||
|
Bot visits /blog/my-article
|
||||||
|
↓
|
||||||
|
is_bot() === true
|
||||||
|
↓
|
||||||
|
Get post data + load post template
|
||||||
|
↓
|
||||||
|
Replace placeholders (post_title, post_content, etc.)
|
||||||
|
↓
|
||||||
|
Render sections to static HTML
|
||||||
|
↓
|
||||||
|
Output: <html><h1>Article Title</h1><p>Post content...</p></html>
|
||||||
|
↓
|
||||||
|
✓ Bot crawls full content
|
||||||
|
✓ All text indexed
|
||||||
|
✓ All links followable
|
||||||
|
✓ SEO perfect
|
||||||
|
```
|
||||||
|
|
||||||
|
### Path 2: Human Detection → SPA Redirect
|
||||||
|
|
||||||
|
**When**: Human user agent detected
|
||||||
|
**Action**: HTTP 302 redirect to SPA single-page app
|
||||||
|
**Destination**: Matches WordPress permalink structure
|
||||||
|
- `/about` → `/store/about`
|
||||||
|
- `/blog/slug` → `/store/blog/slug`
|
||||||
|
- `/portfolio/slug` → `/store/portfolio/slug`
|
||||||
|
**Result**: ✅ Beautiful interactive React UI
|
||||||
|
|
||||||
|
```
|
||||||
|
Human visits /blog/my-article
|
||||||
|
↓
|
||||||
|
is_bot() === false
|
||||||
|
↓
|
||||||
|
HTTP 302 → /store/blog/my-article
|
||||||
|
↓
|
||||||
|
SPA loads at /store/blog/my-article
|
||||||
|
↓
|
||||||
|
React detects path base (/blog) → loads post template
|
||||||
|
↓
|
||||||
|
React fetches post data for "my-article"
|
||||||
|
↓
|
||||||
|
React replaces placeholders with post data
|
||||||
|
↓
|
||||||
|
Renders sections with React components
|
||||||
|
↓
|
||||||
|
✓ Interactive UI
|
||||||
|
✓ No page reload
|
||||||
|
✓ Great UX
|
||||||
|
```
|
||||||
|
|
||||||
|
### Bot Detection Logic
|
||||||
|
|
||||||
|
Detect bots by User-Agent string:
|
||||||
|
```php
|
||||||
|
$bot_patterns = [
|
||||||
|
'googlebot', 'bingbot', 'slurp', 'duckduckbot',
|
||||||
|
'baiduspider', 'yandexbot', 'crawler', 'robot',
|
||||||
|
'spider', 'facebookexternalhit', 'twitterbot',
|
||||||
|
'linkedinbot', 'whatsapp', 'slackbot', 'applebot'
|
||||||
|
];
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## SPA Architecture (Enhanced)
|
||||||
|
|
||||||
|
### SPA Remains Centralized
|
||||||
|
- **Single page location**: WordPress page (configurable, e.g., `/store/`)
|
||||||
|
- **Single React router**: Handles all SPA routes
|
||||||
|
- **Routes**:
|
||||||
|
- `/` - Homepage (products)
|
||||||
|
- `/shop` - Shop listing
|
||||||
|
- `/product/:slug` - Product detail
|
||||||
|
- `/cart` - Shopping cart
|
||||||
|
- `/checkout` - Checkout
|
||||||
|
- `/my-account/*` - User account
|
||||||
|
- **NEW Dynamic Routes**:
|
||||||
|
- `/:slug` - Structural page (About, Contact, etc.)
|
||||||
|
- `/:pathBase/:slug` - CPT item (blog post, portfolio, etc.)
|
||||||
|
|
||||||
|
### Dynamic Route Matching
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// React Router detects what to render
|
||||||
|
const router = createBrowserRouter([
|
||||||
|
// Existing routes (higher priority)
|
||||||
|
{ path: '/', element: <Shop /> },
|
||||||
|
{ path: '/shop', element: <Shop /> },
|
||||||
|
{ path: '/product/:slug', element: <Product /> },
|
||||||
|
|
||||||
|
// NEW: Dynamic routes (lower priority)
|
||||||
|
{ path: '/:pathBase/:slug', element: <DynamicPageRenderer /> },
|
||||||
|
{ path: '/:slug', element: <DynamicPageRenderer /> },
|
||||||
|
]);
|
||||||
|
|
||||||
|
// DynamicPageRenderer logic
|
||||||
|
function DynamicPageRenderer() {
|
||||||
|
const { pathBase, slug } = useParams();
|
||||||
|
const [pageData, setPageData] = useState(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Determine what template to use
|
||||||
|
if (!pathBase) {
|
||||||
|
// /:slug → Structural page (About, Contact, etc.)
|
||||||
|
fetchPage(slug);
|
||||||
|
} else {
|
||||||
|
// /:pathBase/:slug → CPT item
|
||||||
|
detectCPTFromPath(pathBase); // /blog → post, /portfolio → portfolio
|
||||||
|
fetchCPTTemplate(cptType);
|
||||||
|
fetchPostData(slug);
|
||||||
|
}
|
||||||
|
}, [pathBase, slug]);
|
||||||
|
|
||||||
|
return <PageRenderer pageData={pageData} />;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### No Multiple "SPA Islands"
|
||||||
|
- Pages route through same SPA instance
|
||||||
|
- Same styling, same theme variables, same JavaScript context
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Page Editor UI Layout (3-Column Design)
|
||||||
|
|
||||||
|
### Visual Layout
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────────┐
|
||||||
|
│ [☰] WooNooW Appearance > Pages > [Blog Post Template] │
|
||||||
|
├─────────────────────────────────────────────────────────────┤
|
||||||
|
│ │
|
||||||
|
│ ┌────────────┬──────────────────┬──────────────────────┐ │
|
||||||
|
│ │ Pages │ │ │ │
|
||||||
|
│ │ Sidebar │ Editor Panel │ Settings + Preview │ │
|
||||||
|
│ │ │ │ │ │
|
||||||
|
│ │ 📄 Home │ SECTIONS │ Page Settings │ │
|
||||||
|
│ │ ○ About │ ────────────────│ ├─ Type: Template │ │
|
||||||
|
│ │ ○ Contact │ 1. Hero Section │ ├─ CPT: post │ │
|
||||||
|
│ │ │ [Image ◆] │ └─ Permalink: /blog │ │
|
||||||
|
│ │ 📋 Posts │ 2. Content │ │ │
|
||||||
|
│ │ ○ Portfolio│ [Post Body ◆]│ Section Settings │ │
|
||||||
|
│ │ ○ News │ 3. Related Posts│ ├─ Layout: Grid 3 │ │
|
||||||
|
│ │ │ [Query ◆] │ ├─ Count: [3___] │ │
|
||||||
|
│ │ [+ Create] │ 4. CTA Banner │ └─ [Delete] │ │
|
||||||
|
│ │ │ │ │ │
|
||||||
|
│ │ │ [+ Add Section] │ Preview │ │
|
||||||
|
│ │ │ │ [Desktop] [Mobile] │ │
|
||||||
|
│ │ │ Legend: │ ┌──────────────────┐│ │
|
||||||
|
│ │ │ [◆] = Dynamic │ │ Live SPA Preview ││ │
|
||||||
|
│ │ │ placeholder │ │ (Real-time) ││ │
|
||||||
|
│ │ │ │ └──────────────────┘│ │
|
||||||
|
│ │ │ │ │ │
|
||||||
|
│ └────────────┴──────────────────┴──────────────────────┘ │
|
||||||
|
│ │
|
||||||
|
│ [Discard Changes] [Save Changes] [Preview] │
|
||||||
|
│ │
|
||||||
|
└─────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### Settings Panel Behavior
|
||||||
|
|
||||||
|
**For Structural Pages (No Section Selected):**
|
||||||
|
```
|
||||||
|
Page Settings
|
||||||
|
├─ Type: Page (Structural)
|
||||||
|
├─ Title: About Us
|
||||||
|
├─ Slug: about
|
||||||
|
└─ SEO: Manage in Yoast [→]
|
||||||
|
```
|
||||||
|
|
||||||
|
**For Templates (No Section Selected):**
|
||||||
|
```
|
||||||
|
Template Settings
|
||||||
|
├─ Type: Template (Dynamic)
|
||||||
|
├─ CPT: Post
|
||||||
|
├─ Applies to: /blog/*
|
||||||
|
└─ Edit CPT permalink: [→]
|
||||||
|
```
|
||||||
|
|
||||||
|
**For Sections with Dynamic Fields:**
|
||||||
|
```
|
||||||
|
Section Settings: Hero
|
||||||
|
|
||||||
|
Layout Variant
|
||||||
|
[Hero Left Image ▼]
|
||||||
|
|
||||||
|
Title Field
|
||||||
|
○ Static: [____________]
|
||||||
|
◉ Dynamic: [Post Title ◆]
|
||||||
|
|
||||||
|
Image Field
|
||||||
|
○ Static: [Choose Image]
|
||||||
|
◉ Dynamic: [Featured Image ◆]
|
||||||
|
|
||||||
|
[Delete Section]
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Admin SPA Integration
|
||||||
|
|
||||||
|
### Navigation Location
|
||||||
|
- **Menu**: Appearance (existing)
|
||||||
|
- **Submenu**: Pages (NEW)
|
||||||
|
- **Path**: `/admin/appearance/pages`
|
||||||
|
|
||||||
|
### Sidebar Behavior
|
||||||
|
|
||||||
|
**Default (Expanded):**
|
||||||
|
- Admin sidebar shows full menu with labels
|
||||||
|
- Page editor takes remaining width (3-column layout)
|
||||||
|
|
||||||
|
**Collapsed:**
|
||||||
|
- Admin sidebar shows icons only (~60px width)
|
||||||
|
- Page editor expands to use more width
|
||||||
|
- Tooltips show on hover
|
||||||
|
|
||||||
|
**Layout dimensions:**
|
||||||
|
|
||||||
|
| State | Sidebar | Content Area | Pages List | Editor | Settings+Preview |
|
||||||
|
|-------|---------|--------------|-----------|--------|------------------|
|
||||||
|
| Expanded | 240px | ~1160px | 120px | 650px | 390px |
|
||||||
|
| Collapsed | 60px | ~1340px | 120px | 750px | 470px |
|
||||||
|
|
||||||
|
### No Full-Screen Takeover
|
||||||
|
- ✅ Part of admin SPA normal content flow
|
||||||
|
- ✅ User maintains context (can quickly jump to other admin sections)
|
||||||
|
- ✅ Sidebar collapse is user-controlled, not forced
|
||||||
|
- ✅ Can navigate back via breadcrumb or admin menu
|
||||||
|
|
||||||
|
### Escape Routes
|
||||||
|
1. Click WooNooW logo → Dashboard
|
||||||
|
2. Click any menu item in sidebar → Navigate to that section
|
||||||
|
3. Press ESC key → Back (with unsaved changes warning)
|
||||||
|
4. Click browser back button → Back (with unsaved changes warning)
|
||||||
|
5. Click breadcrumb "Appearance" → Back to Appearance settings
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## SEO Preservation
|
||||||
|
|
||||||
|
### Yoast/Rank Math Integration
|
||||||
|
- ✅ Meta title, meta description managed by Yoast/Rank Math
|
||||||
|
- ✅ Canonical URLs managed by WordPress
|
||||||
|
- ✅ Open Graph tags managed by SEO plugins
|
||||||
|
- ✅ Structured data can be added to page template
|
||||||
|
- ✅ No changes needed to existing SEO workflow
|
||||||
|
|
||||||
|
### For CPT Items (Posts, Portfolio, etc.)
|
||||||
|
- Yoast/Rank Math still manages SEO for individual CPT items
|
||||||
|
- Templates don't interfere with SEO workflow
|
||||||
|
- Bot sees pre-rendered content with all placeholders filled
|
||||||
|
|
||||||
|
### What Bots See
|
||||||
|
|
||||||
|
**Example: Blog Post with Template**
|
||||||
|
```html
|
||||||
|
<h1>Article Title</h1>
|
||||||
|
<img src="/uploads/featured.jpg" alt="Article featured image" />
|
||||||
|
<article>
|
||||||
|
<p>Article body content goes here...</p>
|
||||||
|
</article>
|
||||||
|
<section class="related-posts">
|
||||||
|
<h2>Related Articles</h2>
|
||||||
|
<div class="grid">
|
||||||
|
<a href="/blog/related-1">Related Post 1</a>
|
||||||
|
<a href="/blog/related-2">Related Post 2</a>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
```
|
||||||
|
|
||||||
|
- ✅ All text crawlable
|
||||||
|
- ✅ All links followable
|
||||||
|
- ✅ Structure preserved
|
||||||
|
- ✅ Meta tags present
|
||||||
|
- ✅ Images indexed
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## API Endpoints
|
||||||
|
|
||||||
|
### Get All Pages & Templates
|
||||||
|
```
|
||||||
|
GET /wp-json/woonoow/v1/pages
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
```json
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"id": 42,
|
||||||
|
"type": "page",
|
||||||
|
"slug": "home",
|
||||||
|
"title": "Homepage",
|
||||||
|
"icon": "page"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "template",
|
||||||
|
"cpt": "post",
|
||||||
|
"title": "Blog Post Template",
|
||||||
|
"icon": "template",
|
||||||
|
"permalink_base": "/blog/"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
### Get Page or Template Structure
|
||||||
|
```
|
||||||
|
GET /wp-json/woonoow/v1/pages/about
|
||||||
|
GET /wp-json/woonoow/v1/templates/post
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": "page|template",
|
||||||
|
"id": 42,
|
||||||
|
"slug": "about",
|
||||||
|
"title": "About Us",
|
||||||
|
"url": "https://mystore.com/about",
|
||||||
|
"seo": {
|
||||||
|
"meta_title": "About Us | My Store",
|
||||||
|
"meta_description": "Learn about our company...",
|
||||||
|
"canonical": "https://mystore.com/about",
|
||||||
|
"og_title": "About Us",
|
||||||
|
"og_image": "https://mystore.com/og-image.jpg"
|
||||||
|
},
|
||||||
|
"structure": {
|
||||||
|
"sections": [...]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Get Single Post with Template Applied
|
||||||
|
```
|
||||||
|
GET /wp-json/woonoow/v1/posts/{post_id}/with-template
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"post": {
|
||||||
|
"id": 123,
|
||||||
|
"title": "Article Title",
|
||||||
|
"content": "Article body...",
|
||||||
|
"featured_image": "/uploads/image.jpg",
|
||||||
|
"author": "John Doe",
|
||||||
|
"date": "2026-01-11"
|
||||||
|
},
|
||||||
|
"template": {
|
||||||
|
"sections": [...]
|
||||||
|
},
|
||||||
|
"rendered": {
|
||||||
|
"sections": [
|
||||||
|
{
|
||||||
|
"type": "hero",
|
||||||
|
"props": {
|
||||||
|
"title": "Article Title", // Placeholder filled
|
||||||
|
"image": "/uploads/image.jpg" // Placeholder filled
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Save Page or Template Structure
|
||||||
|
```
|
||||||
|
POST /wp-json/woonoow/v1/pages/about
|
||||||
|
POST /wp-json/woonoow/v1/templates/post
|
||||||
|
```
|
||||||
|
|
||||||
|
**Request body:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"sections": [...]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"page": { ... }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Implementation Phases
|
||||||
|
|
||||||
|
### Phase 1: Core Infrastructure (Priority)
|
||||||
|
- [ ] Add bot detection logic to `TemplateOverride.php`
|
||||||
|
- [ ] Build `PageSSR` class to render sections as HTML
|
||||||
|
- [ ] Build `PlaceholderRenderer` class to replace dynamic placeholders
|
||||||
|
- [ ] Create REST API endpoints:
|
||||||
|
- `GET /wp-json/woonoow/v1/pages` (list all)
|
||||||
|
- `GET /wp-json/woonoow/v1/pages/{slug}` (get page)
|
||||||
|
- `GET /wp-json/woonoow/v1/templates/{cpt}` (get template)
|
||||||
|
- `GET /wp-json/woonoow/v1/posts/{id}/with-template` (get post with template)
|
||||||
|
- `POST /wp-json/woonoow/v1/pages/{slug}` (save page)
|
||||||
|
- `POST /wp-json/woonoow/v1/templates/{cpt}` (save template)
|
||||||
|
- [ ] Update template_redirect hook:
|
||||||
|
- Detect if structural page or CPT item
|
||||||
|
- Load appropriate template
|
||||||
|
- Replace placeholders (for CPT items)
|
||||||
|
- Serve SSR for bots (exit without redirect)
|
||||||
|
- Redirect humans to SPA with matching permalink structure
|
||||||
|
- [ ] Create React `DynamicPageRenderer` component
|
||||||
|
- [ ] Test bot crawling vs human browsing
|
||||||
|
|
||||||
|
### Phase 2: Admin SPA Integration
|
||||||
|
- [ ] Add "Pages" submenu to Appearance menu
|
||||||
|
- [ ] Create `/admin/appearance/pages` route
|
||||||
|
- [ ] Build `PageEditor` component with 3-column layout
|
||||||
|
- [ ] Implement sidebar collapse/expand functionality
|
||||||
|
- [ ] Add breadcrumb navigation
|
||||||
|
- [ ] Implement create page modal (structural vs template selection)
|
||||||
|
- [ ] Test navigation flow
|
||||||
|
|
||||||
|
### Phase 3: WooNooW Page Editor (UI)
|
||||||
|
- [ ] Build custom admin page editor UI (React)
|
||||||
|
- [ ] Section list with drag-to-reorder
|
||||||
|
- [ ] Section selector popup when clicking "+ Add Section"
|
||||||
|
- [ ] Layout variant dropdown per section
|
||||||
|
- [ ] Color scheme selector per section
|
||||||
|
- [ ] Form fields for section content:
|
||||||
|
- Static field: text input
|
||||||
|
- Dynamic field: dropdown with available sources
|
||||||
|
- [ ] Live SPA preview iframe (real-time updates)
|
||||||
|
- [ ] Desktop/Mobile preview toggle
|
||||||
|
- [ ] Save/Publish button → stores to postmeta or wp_options
|
||||||
|
- [ ] Visual indicator for dynamic placeholders ([◆])
|
||||||
|
|
||||||
|
### Phase 4: Section Library
|
||||||
|
- [ ] Define section types and their props schema
|
||||||
|
- [ ] Hero section renderer (SSR + React)
|
||||||
|
- [ ] Content section renderer (for post body)
|
||||||
|
- [ ] Feature grid renderer (SSR + React)
|
||||||
|
- [ ] Related items section (for related posts/CPT items)
|
||||||
|
- [ ] CTA banner renderer (SSR + React)
|
||||||
|
- [ ] Add more sections as needed
|
||||||
|
|
||||||
|
### Phase 5: Polish & Launch
|
||||||
|
- [ ] Caching for SSR (cache rendered HTML for 1 hour)
|
||||||
|
- [ ] Unsaved changes warning (ESC key, browser back, menu click)
|
||||||
|
- [ ] Test SEO with Google Search Console
|
||||||
|
- [ ] Test with bot simulators (curl with bot user agents)
|
||||||
|
- [ ] Test dynamic placeholder rendering
|
||||||
|
- [ ] Documentation for merchants
|
||||||
|
- [ ] Documentation for developers (extending sections, custom placeholders)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Database Schema
|
||||||
|
|
||||||
|
### Structural Pages (page CPT)
|
||||||
|
```sql
|
||||||
|
wp_posts:
|
||||||
|
├─ id: 42
|
||||||
|
├─ post_type: 'page'
|
||||||
|
├─ post_name: 'about'
|
||||||
|
├─ post_title: 'About Us'
|
||||||
|
├─ post_status: 'publish'
|
||||||
|
└─ post_modified: '2026-01-11 20:58:00'
|
||||||
|
|
||||||
|
wp_postmeta:
|
||||||
|
├─ post_id: 42, meta_key: '_wn_page_structure'
|
||||||
|
│ value: {"type": "page", "sections": [...]}
|
||||||
|
├─ post_id: 42, meta_key: '_yoast_wpseo_title'
|
||||||
|
│ value: "About Us | My Store"
|
||||||
|
├─ post_id: 42, meta_key: '_yoast_wpseo_metadesc'
|
||||||
|
│ value: "Learn about our company..."
|
||||||
|
├─ post_id: 42, meta_key: '_yoast_wpseo_canonical'
|
||||||
|
│ value: "https://mystore.com/about"
|
||||||
|
└─ post_id: 42, meta_key: '_yoast_wpseo_opengraph-image'
|
||||||
|
value: "https://mystore.com/og-image.jpg"
|
||||||
|
```
|
||||||
|
|
||||||
|
### CPT Templates
|
||||||
|
```sql
|
||||||
|
wp_options:
|
||||||
|
├─ option_name: 'wn_template_post'
|
||||||
|
│ option_value: {"type": "template", "cpt": "post", "sections": [...]}
|
||||||
|
├─ option_name: 'wn_template_portfolio'
|
||||||
|
│ option_value: {"type": "template", "cpt": "portfolio", "sections": [...]}
|
||||||
|
└─ option_name: 'wn_template_custom_cpt'
|
||||||
|
option_value: {"type": "template", "cpt": "custom_cpt", "sections": [...]}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Target Users & Experience
|
||||||
|
|
||||||
|
### Tech-Savvy Users (Developers/Agencies)
|
||||||
|
**Use Case**: Building custom stores with WooNooW
|
||||||
|
**Tools**: WooNooW CSS variables, class documentation, section schema
|
||||||
|
**Value**: Framework for rapid SPA development, design system enforcement
|
||||||
|
|
||||||
|
### Non-Tech Merchants
|
||||||
|
**Use Case**: Quick store setup with predefined pages and templates
|
||||||
|
**Tools**: WooNooW Page Editor (constrained choices, no freestyle design)
|
||||||
|
**Value**: Professional pages in minutes, consistent styling across all CPTs
|
||||||
|
|
||||||
|
### Both
|
||||||
|
**Result**: Consistent visual identity across structural pages + dynamic CPT items
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Decision Summary
|
||||||
|
|
||||||
|
| Aspect | Decision |
|
||||||
|
|--------|----------|
|
||||||
|
| **Editor Type** | Single unified editor for pages + CPT templates |
|
||||||
|
| **Structural Pages** | Native `page` CPT, stored in postmeta |
|
||||||
|
| **CPT Templates** | Stored in wp_options (one per CPT) |
|
||||||
|
| **Field Types** | Static (hardcoded) or Dynamic (with placeholders) |
|
||||||
|
| **Available Placeholders** | post_title, post_content, featured_image, author, date, related_posts, etc. |
|
||||||
|
| **Page Builder Integration** | None (custom WooNooW editor only) |
|
||||||
|
| **Admin Location** | Appearance > Pages (single submenu) |
|
||||||
|
| **Editor Context** | Part of admin SPA (not full-screen) |
|
||||||
|
| **Sidebar Behavior** | Collapsible (expanded or icon-only) |
|
||||||
|
| **Create Flow** | Modal asks: Structural page or CPT template? |
|
||||||
|
| **SPA Routing** | Matches WordPress permalink structure |
|
||||||
|
| **Permalink Matching** | `/blog/slug` → `/store/blog/slug` |
|
||||||
|
| **SEO** | Yoast/Rank Math for pages, templates don't interfere |
|
||||||
|
| **Bot Handling** | No redirect, serve pre-rendered HTML with placeholders filled |
|
||||||
|
| **Human Handling** | HTTP 302 redirect to SPA with matching permalink structure |
|
||||||
|
| **SPA Location** | Single centralized page (e.g., `/store/`) |
|
||||||
|
| **Performance** | Cache SSR HTML (1 hour TTL) |
|
||||||
|
| **UI Pattern** | 3-column layout (pages list, editor, settings+preview) |
|
||||||
|
| **Template Types** | Hero, Content, Feature Grid, Related Items, CTA Banner |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## What's NOT Changing
|
||||||
|
|
||||||
|
✅ Existing shop/product/cart/checkout functionality
|
||||||
|
✅ Existing SPA architecture (single page, centralized)
|
||||||
|
✅ Existing template override logic (extended, not replaced)
|
||||||
|
✅ Existing API patterns (new endpoints follow conventions)
|
||||||
|
✅ Existing CSS variable system (reused for all sections)
|
||||||
|
✅ SEO plugin compatibility (Yoast, Rank Math continue to work)
|
||||||
|
✅ Admin menu structure (Pages added to existing Appearance menu)
|
||||||
|
✅ Admin sidebar (enhanced with collapse functionality, not replaced)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Key Architectural Benefits
|
||||||
|
|
||||||
|
✅ **One editor, two modes** - Merchants don't learn separate systems
|
||||||
|
✅ **Clean separation** - Static pages vs dynamic templates clear
|
||||||
|
✅ **URL consistency** - SPA routes match WordPress permalinks
|
||||||
|
✅ **SEO perfect** - Bots see full content, humans get SPA experience
|
||||||
|
✅ **Scalable** - Works for current CPTs and future custom CPTs
|
||||||
|
✅ **Flexible placeholders** - Can add any post field or custom meta
|
||||||
|
✅ **No page builder lock-in** - Custom sections, not Gutenberg/Elementor
|
||||||
|
✅ **Maintains design consistency** - Predefined sections, no freestyle design
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Next Discussion Topics
|
||||||
|
- [ ] Section library detailed specs (Hero, Content, Features, Related, CTA)
|
||||||
|
- [ ] Placeholder source definitions and extensibility
|
||||||
|
- [ ] Section schema validation
|
||||||
|
- [ ] Caching strategy details (Redis vs file-based vs transient)
|
||||||
|
- [ ] Migration path for existing pages/posts
|
||||||
|
- [ ] Developer documentation (extending sections, custom placeholders)
|
||||||
|
- [ ] Component structure and file organization (React + PHP)
|
||||||
Reference in New Issue
Block a user