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
This commit is contained in:
234
includes/class-seo-schema.php
Normal file
234
includes/class-seo-schema.php
Normal file
@@ -0,0 +1,234 @@
|
||||
<?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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user