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