- Implement local backend AI provider with Ollama integration - Add Brave Search API integration for real-time search suggestions - Add image generation manager with multiple AI providers - Create hybrid provider system with local/cloud fallback - Add comprehensive settings UI with provider management - Implement Gutenberg sidebar with writing assistance controls - Add SEO schema generation for AI-generated content - Multiple provider support: OpenRouter, local backend, Codex
235 lines
6.9 KiB
PHP
235 lines
6.9 KiB
PHP
<?php
|
|
/**
|
|
* SEO & Schema Injector
|
|
*
|
|
* @package WPAgenticWriter
|
|
*/
|
|
|
|
if ( ! defined( 'ABSPATH' ) ) {
|
|
exit;
|
|
}
|
|
|
|
class WP_Agentic_Writer_SEO_Schema {
|
|
|
|
/**
|
|
* Instance of this class.
|
|
*
|
|
* @var WP_Agentic_Writer_SEO_Schema
|
|
*/
|
|
private static $instance = null;
|
|
|
|
/**
|
|
* Get the singleton instance.
|
|
*
|
|
* @return WP_Agentic_Writer_SEO_Schema
|
|
*/
|
|
public static function get_instance() {
|
|
if ( null === self::$instance ) {
|
|
self::$instance = new self();
|
|
}
|
|
return self::$instance;
|
|
}
|
|
|
|
/**
|
|
* Constructor.
|
|
*/
|
|
private function __construct() {
|
|
add_action( 'save_post', array( $this, 'extract_and_save_faq_schema' ), 20, 3 );
|
|
add_action( 'wp_head', array( $this, 'output_faq_schema_in_head' ) );
|
|
|
|
// Integrate with Yoast SEO schema graph
|
|
add_filter( 'wpseo_schema_graph', array( $this, 'inject_into_yoast_schema' ) );
|
|
|
|
// Integrate with RankMath SEO schema graph
|
|
add_filter( 'rank_math/json_ld', array( $this, 'inject_into_rankmath_schema' ), 99, 2 );
|
|
}
|
|
|
|
/**
|
|
* Parse post content when saved, looking for Q&A structures
|
|
* to build an FAQPage JSON-LD array.
|
|
*
|
|
* @param int $post_id Post ID.
|
|
* @param WP_Post $post Post object.
|
|
* @param bool $update Whether this is an existing post being updated.
|
|
*/
|
|
public function extract_and_save_faq_schema( $post_id, $post, $update ) {
|
|
// Don't run on autosaves or revisions.
|
|
if ( wp_is_post_autosave( $post_id ) || wp_is_post_revision( $post_id ) ) {
|
|
return;
|
|
}
|
|
|
|
// Check if FAQ schema is enabled in settings
|
|
$settings = get_option( 'wp_agentic_writer_settings', array() );
|
|
$enable_faq_schema = $settings['enable_faq_schema'] ?? false;
|
|
if ( ! $enable_faq_schema ) {
|
|
delete_post_meta( $post_id, '_wpaw_faq_schema' );
|
|
return;
|
|
}
|
|
|
|
// Only run for active post types (e.g. post, page).
|
|
$allowed_types = array( 'post', 'page' );
|
|
if ( ! in_array( $post->post_type, $allowed_types, true ) ) {
|
|
return;
|
|
}
|
|
|
|
$content = $post->post_content;
|
|
if ( empty( $content ) ) {
|
|
delete_post_meta( $post_id, '_wpaw_faq_schema' );
|
|
return;
|
|
}
|
|
|
|
// Strip Gutenberg block HTML comments to make regex matching easier.
|
|
$clean_content = preg_replace( '/<!-- wp:[^>]*-->/s', '', $content );
|
|
$clean_content = preg_replace( '/<!-- \/wp:[^>]*-->/s', '', $clean_content );
|
|
|
|
// Regex to find H2, H3, or H4 that contain a question mark, immediately followed by a paragraph.
|
|
// Matches: <hX>Question?</hX> <p>Answer</p>
|
|
$pattern = '/<h([2-4])[^>]*>(.*?\?)<\/h\1>[\s]*<p[^>]*>(.*?)<\/p>/is';
|
|
|
|
$faqs = array();
|
|
|
|
if ( preg_match_all( $pattern, $clean_content, $matches, PREG_SET_ORDER ) ) {
|
|
foreach ( $matches as $match ) {
|
|
$question = wp_strip_all_tags( $match[2] );
|
|
$answer = wp_strip_all_tags( $match[3] );
|
|
|
|
// Basic validation: question must not be too short, answer must have some length.
|
|
if ( strlen( $question ) > 10 && strlen( $answer ) > 15 ) {
|
|
$faqs[] = array(
|
|
'@type' => 'Question',
|
|
'name' => $question,
|
|
'acceptedAnswer' => array(
|
|
'@type' => 'Answer',
|
|
'text' => $answer,
|
|
),
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Only save schema if we actually detected 1 or more valid FAQ pairs.
|
|
if ( ! empty( $faqs ) ) {
|
|
$schema = array(
|
|
'@context' => 'https://schema.org',
|
|
'@type' => 'FAQPage',
|
|
'mainEntity' => $faqs,
|
|
);
|
|
update_post_meta( $post_id, '_wpaw_faq_schema', wp_json_encode( $schema, JSON_UNESCAPED_UNICODE ) );
|
|
} else {
|
|
delete_post_meta( $post_id, '_wpaw_faq_schema' );
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Output the JSON-LD schema script in the frontend <head>.
|
|
* This serves as a fallback for sites without a primary SEO plugin.
|
|
*/
|
|
public function output_faq_schema_in_head() {
|
|
if ( ! is_singular() ) {
|
|
return;
|
|
}
|
|
|
|
// Abort if feature is manually disabled in Settings
|
|
$settings = get_option( 'wp_agentic_writer_settings', array() );
|
|
if ( isset( $settings['enable_faq_schema'] ) && ! $settings['enable_faq_schema'] ) {
|
|
return;
|
|
}
|
|
|
|
// Prevent duplicate output if a major SEO plugin is active and handling our schema
|
|
if ( defined( 'WPSEO_VERSION' ) || class_exists( 'RankMath' ) ) {
|
|
return;
|
|
}
|
|
|
|
$post_id = get_the_ID();
|
|
|
|
// Attempt to fetch schema generated by WP Agentic Writer.
|
|
$schema_json = get_post_meta( $post_id, '_wpaw_faq_schema', true );
|
|
|
|
if ( ! empty( $schema_json ) ) {
|
|
echo "\n<!-- WP Agentic Writer: Automated FAQ Schema -->\n";
|
|
echo '<script type="application/ld+json">' . $schema_json . '</script>'; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
|
|
echo "\n<!-- /WP Agentic Writer Schema -->\n";
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Inject the FAQ schema directly into Yoast SEO's JSON-LD graph.
|
|
* This prevents disjointed schema and consolidates everything into Yoast's payload.
|
|
*
|
|
* @param array $graph The Yoast schema graph array.
|
|
* @return array
|
|
*/
|
|
public function inject_into_yoast_schema( $graph ) {
|
|
if ( ! is_singular() ) {
|
|
return $graph;
|
|
}
|
|
|
|
// Abort if feature is manually disabled in Settings
|
|
$settings = get_option( 'wp_agentic_writer_settings', array() );
|
|
if ( isset( $settings['enable_faq_schema'] ) && ! $settings['enable_faq_schema'] ) {
|
|
return $graph;
|
|
}
|
|
|
|
$post_id = get_the_ID();
|
|
|
|
// Fetch our pre-computed schema
|
|
$schema_json = get_post_meta( $post_id, '_wpaw_faq_schema', true );
|
|
|
|
if ( ! empty( $schema_json ) ) {
|
|
$schema_array = json_decode( $schema_json, true );
|
|
if ( is_array( $schema_array ) ) {
|
|
// Yoast specifically expects nodes with an @id property
|
|
$schema_array['@id'] = get_permalink( $post_id ) . '#wpaw-faq';
|
|
|
|
// Yoast wraps all schemas in a global @context, so we can unset our local one to remain clean
|
|
if ( isset( $schema_array['@context'] ) ) {
|
|
unset( $schema_array['@context'] );
|
|
}
|
|
|
|
// Append our FAQ schema snippet to Yoast's massive graph
|
|
$graph[] = $schema_array;
|
|
}
|
|
}
|
|
|
|
return $graph;
|
|
}
|
|
|
|
/**
|
|
* Inject the FAQ schema directly into RankMath's JSON-LD output.
|
|
* This prevents disjointed schema and consolidates everything into RankMath's payload.
|
|
*
|
|
* @param array $data The RankMath JSON-LD data array.
|
|
* @param object $jsonld The RankMath JsonLd object.
|
|
* @return array
|
|
*/
|
|
public function inject_into_rankmath_schema( $data, $jsonld ) {
|
|
if ( ! is_singular() ) {
|
|
return $data;
|
|
}
|
|
|
|
// Abort if feature is manually disabled in Settings
|
|
$settings = get_option( 'wp_agentic_writer_settings', array() );
|
|
if ( isset( $settings['enable_faq_schema'] ) && ! $settings['enable_faq_schema'] ) {
|
|
return $data;
|
|
}
|
|
|
|
$post_id = get_the_ID();
|
|
|
|
// Fetch our pre-computed schema
|
|
$schema_json = get_post_meta( $post_id, '_wpaw_faq_schema', true );
|
|
|
|
if ( ! empty( $schema_json ) ) {
|
|
$schema_array = json_decode( $schema_json, true );
|
|
if ( is_array( $schema_array ) && ! empty( $schema_array['mainEntity'] ) ) {
|
|
// RankMath expects a keyed array for each schema type
|
|
$data['WPAWFaqPage'] = array(
|
|
'@type' => 'FAQPage',
|
|
'mainEntity' => $schema_array['mainEntity'],
|
|
);
|
|
}
|
|
}
|
|
|
|
return $data;
|
|
}
|
|
}
|