Files
wp-agentic-writer/includes/class-seo-schema.php
Dwindi Ramadhana d2c10756ab Add AI writing assistant plugin with local backend, brave search, and image generation support
- 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
2026-05-17 10:48:05 +07:00

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