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:
Dwindi Ramadhana
2026-01-11 22:29:30 +07:00
parent 1ff9a36af3
commit 9331989102
6 changed files with 1857 additions and 0 deletions

View 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;
}
}

View File

@@ -35,6 +35,7 @@ use WooNooW\Frontend\HookBridge;
use WooNooW\Api\Controllers\SettingsController;
use WooNooW\Api\Controllers\CartController as ApiCartController;
use WooNooW\Admin\AppearanceController;
use WooNooW\Api\PagesController;
class Routes {
public static function init() {
@@ -181,6 +182,9 @@ class Routes {
AddressController::register_routes();
WishlistController::register_routes();
HookBridge::register_routes();
// Pages and templates controller
PagesController::register_routes();
});
}
}

View 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>";
}
}

View 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;
}
}

View File

@@ -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;
}
}