)\s*(?:Q:|Question:?)\s*(.*?)(?:<\/h[2-4]>|<\/strong>)\s*(?:)?\s*(?:A:|Answer:?)?\s*(.*?)(?:<\/p>|(?= 'Question',
'name' => wp_strip_all_tags( $match[1] ),
'acceptedAnswer' => array(
'@type' => 'Answer',
'text' => wp_strip_all_tags( $match[2] ),
),
);
}
if ( ! empty( $faqs ) ) {
return array(
'@context' => 'https://schema.org',
'@type' => 'FAQPage',
'mainEntity' => $faqs,
);
}
}
// Detect Gutenberg FAQ blocks or accordion patterns.
if ( has_block( 'yoast/faq-block', $post ) || has_block( 'rank-math/faq-block', $post ) ) {
// These plugins handle their own FAQ schema, so defer.
return null;
}
// Detect a plain FAQ section: an "FAQ" style H2/H3, followed by
// question-shaped subheadings each answered by a paragraph. This is
// the structure our own draft generator produces and the most common
// hand-written FAQ format. Scoped to content AFTER the FAQ heading so
// an article that is entirely question-headings (answer-first style)
// is not mislabeled as an FAQPage.
if ( preg_match( '/]*>\s*(?:faqs?|frequently asked questions|common questions)\s*:?\s*<\/h\1>/i', $content, $fh, PREG_OFFSET_CAPTURE ) ) {
$section = substr( $content, $fh[0][1] + strlen( $fh[0][0] ) );
if ( preg_match_all( '/]*>(.*?\?)\s*<\/h\1>.*?]*>(.*?)<\/p>/si', $section, $qm, PREG_SET_ORDER ) ) {
$faqs = array();
foreach ( $qm as $match ) {
$question = trim( wp_strip_all_tags( $match[2] ) );
$answer = trim( wp_strip_all_tags( $match[3] ) );
$answer = preg_replace( '/\s+/', ' ', $answer );
if ( '' !== $question && '' !== $answer ) {
$faqs[] = array(
'@type' => 'Question',
'name' => $question,
'acceptedAnswer' => array(
'@type' => 'Answer',
'text' => $answer,
),
);
}
}
if ( count( $faqs ) >= 2 ) {
return array(
'@context' => 'https://schema.org',
'@type' => 'FAQPage',
'mainEntity' => $faqs,
);
}
}
}
return null;
}
/**
* Detect HowTo-style content and generate HowTo schema.
*
* @return array|null
*/
private function detect_howto_schema() {
$post = get_post();
if ( ! $post ) {
return null;
}
$content = $post->post_content;
$title = get_the_title( $post );
// ── Gate 1: Title must contain "How to". ──
if ( ! preg_match( '/\bhow\s+to\b/i', $title ) ) {
return null;
}
// ── Gate 2: Content must also show step-like structure. ──
// Require at least one of:
// a) A heading (H2-H4) with step language: "steps", "step-by-step", "instructions"
// b) Explicit "Step N:" patterns anywhere in the content
// This prevents informational guides (with lots of bullet points) from
// being incorrectly marked as HowTo schema.
$has_step_heading = preg_match( '/]*>.*?(?:\bsteps?\b|\bstep[\s-]by[\s-]step\b|\binstructions?\b|\bprocess\b|\bprocedure\b)/si', $content );
$has_explicit_steps = preg_match( '/\bStep\s+\d+\s*[:\-\.]/i', $content );
if ( ! $has_step_heading && ! $has_explicit_steps ) {
return null; // No step structure found. This is a guide, not a tutorial.
}
// Strategy 1: Ordered lists () following step-related headings.
$steps = $this->extract_steps_near_headings( $content );
// Strategy 2: Explicit "Step N:" patterns in headings or paragraphs.
if ( empty( $steps ) ) {
$steps = $this->extract_explicit_step_patterns( $content );
}
// No "any ordered list" fallback. Too risky for false positives.
// Minimum 5 steps (3 is too borderline), maximum 25.
if ( count( $steps ) < 5 ) {
return null;
}
$steps = array_slice( $steps, 0, 25 );
// Re-number positions after slicing.
foreach ( $steps as $i => &$step ) {
$step['position'] = $i + 1;
}
unset( $step );
return array(
'@context' => 'https://schema.org',
'@type' => 'HowTo',
'name' => $title,
'step' => $steps,
);
}
/**
* Extract steps from ordered lists that appear after step-related headings.
*
* @param string $content Post content.
* @return array Steps array.
*/
private function extract_steps_near_headings( $content ) {
$steps = array();
// Match: heading with step language, followed by an ordered list.
// Heading patterns: "How to...", "Steps to...", "Step-by-step...", "Instructions"
$heading_pattern = '/]*>(.*?(?:how\s+to|steps?\s|step-by-step|instructions?|process|procedure|guide).*?)<\/h[2-4]>/si';
if ( ! preg_match_all( $heading_pattern, $content, $headings, PREG_OFFSET_CAPTURE ) ) {
return $steps;
}
foreach ( $headings[0] as $match ) {
$offset = $match[1] + strlen( $match[0] );
$rest = substr( $content, $offset, 5000 ); // Look within 5000 chars after heading.
// Find the first after this heading (before any next heading).
$next_heading_pos = preg_match( '/]*>(.*?)<\/ol>/si', $section, $ol_match ) ) {
if ( preg_match_all( '/- ]*>(.*?)<\/li>/si', $ol_match[1], $li_matches ) ) {
foreach ( $li_matches[1] as $li ) {
$clean = trim( wp_strip_all_tags( $li ) );
if ( $this->is_valid_step( $clean ) ) {
$steps[] = array(
'@type' => 'HowToStep',
'position' => count( $steps ) + 1,
'text' => $clean,
);
}
}
}
}
}
return $steps;
}
// extract_ordered_list_steps intentionally removed.
// Grabbing "any ordered list" as steps was too aggressive and produced
// false positives on informational articles with numbered bullet points.
/**
* Extract steps from explicit "Step N:" patterns in headings or paragraphs.
*
* @param string $content Post content.
* @return array Steps array.
*/
private function extract_explicit_step_patterns( $content ) {
$steps = array();
// Match: "Step 1: Do something", "Step 2 - Do something else"
if ( preg_match_all( '/(?:]*>|
]*>|
- ]*>)\s*(?:Step\s+\d+[\s:.\-]+)(.*?)(?:<\/h[2-6]>|<\/p>|<\/li>)/si', $content, $matches ) ) {
foreach ( $matches[1] as $step_text ) {
$clean = trim( wp_strip_all_tags( $step_text ) );
if ( strlen( $clean ) > 15 && strlen( $clean ) < 300 ) {
$steps[] = array(
'@type' => 'HowToStep',
'position' => count( $steps ) + 1,
'text' => $clean,
);
}
}
}
return $steps;
}
/**
* Validate whether a text string is a genuine actionable step.
*
* A valid step should:
* - Be 15-300 characters (not too short, not a paragraph)
* - Start with an action verb or contain imperative mood
* - Not be a statistic, quote, or comparison
*
* @param string $text Step text to validate.
* @return bool True if this looks like a genuine step.
*/
private function is_valid_step( $text ) {
$len = strlen( $text );
// Length check: too short = fragment, too long = paragraph.
if ( $len < 15 || $len > 300 ) {
return false;
}
// Reject: pure statistics/numbers (e.g., "67% of ChatGPT responses...")
if ( preg_match( '/^\d+%/', $text ) ) {
return false;
}
// Reject: checkbox items from checklists (e.g., "[ ] Test your brand...")
if ( preg_match( '/^\[[\sx]\]/i', $text ) ) {
return false;
}
// Reject: items that look like comparisons or statements, not instructions.
// Statements starting with nouns/articles without action verbs.
$non_action_starts = array(
'/^(ChatGPT|Perplexity|Google|Multiple|SEO|AI|The|A|An|Their|Our|His|Her|Its|Most|Many|Some|Several|Various|Free|Cost|Why|Best|Average|Default|Recognized|Helped)\b/i',
);
foreach ( $non_action_starts as $pattern ) {
if ( preg_match( $pattern, $text ) ) {
return false;
}
}
// Reject: items that are quotes (start with quotation marks).
if ( preg_match( '/^[\'""\x{201C}\x{201D}]/u', $text ) ) {
return false;
}
// Require: should ideally start with an action verb (imperative mood).
// Common step starters: Add, Create, Set, Configure, Open, Click, Enter, etc.
$action_verbs = '/^(add|allow|apply|attach|audit|ask|assemble|avoid|begin|brew|build|calculate|check|choose|clean|click|close|collect|combine|compare|complete|configure|connect|consider|contact|continue|contribute|copy|create|cut|define|delete|deploy|describe|design|determine|develop|disable|download|drag|dry|edit|enable|ensure|enter|establish|evaluate|examine|export|fill|find|fix|focus|follow|format|gather|generate|get|give|go|grant|grind|handle|heat|identify|implement|import|improve|include|insert|install|integrate|investigate|keep|launch|learn|link|list|load|locate|log|maintain|make|manage|map|mark|measure|merge|modify|monitor|move|name|navigate|note|obtain|open|optimize|order|organize|participate|paste|perform|pick|place|plan|post|pour|prepare|press|prevent|print|provide|publish|pull|push|put|read|receive|record|reduce|register|release|remove|rename|repeat|replace|report|request|require|research|reset|resolve|respond|restart|restore|retrieve|return|review|revise|rinse|run|save|scan|schedule|search|secure|select|send|set|share|sign|specify|start|stop|store|structure|submit|subscribe|summarize|switch|sync|tag|take|target|tell|test|track|transfer|try|tune|turn|type|undo|uninstall|unlink|update|upgrade|upload|use|utilize|validate|verify|view|visit|wait|watch|weigh|work|write)\b/i';
if ( preg_match( $action_verbs, $text ) ) {
return true;
}
// Also accept if it contains step-like structure: "X, then Y" or "First, ..." etc.
if ( preg_match( '/^(first|second|third|next|then|finally|lastly|after|before|once|when|if)\b/i', $text ) ) {
return true;
}
return false;
}
/**
* LocalBusiness schema.
*
* @param array $settings Plugin settings.
* @return array|null
*/
private function local_business_schema( $settings ) {
$business = isset( $settings['business'] ) ? $settings['business'] : array();
$local = isset( $settings['local_business'] ) ? $settings['local_business'] : array();
if ( empty( $business['name'] ) ) {
return null;
}
$schema = array(
'@context' => 'https://schema.org',
'@type' => 'LocalBusiness',
'name' => $business['name'],
'url' => get_site_url(),
);
if ( ! empty( $local['address'] ) ) {
$schema['address'] = array(
'@type' => 'PostalAddress',
'streetAddress' => isset( $local['address']['street'] ) ? $local['address']['street'] : '',
'addressLocality' => isset( $local['address']['city'] ) ? $local['address']['city'] : '',
'addressRegion' => isset( $local['address']['region'] ) ? $local['address']['region'] : '',
'postalCode' => isset( $local['address']['postal_code'] ) ? $local['address']['postal_code'] : '',
'addressCountry' => isset( $local['address']['country'] ) ? $local['address']['country'] : '',
);
}
if ( ! empty( $local['phone'] ) ) {
$schema['telephone'] = $local['phone'];
}
if ( ! empty( $local['email'] ) ) {
$schema['email'] = $local['email'];
}
if ( ! empty( $local['geo'] ) ) {
$schema['geo'] = array(
'@type' => 'GeoCoordinates',
'latitude' => $local['geo']['lat'],
'longitude' => $local['geo']['lng'],
);
}
if ( ! empty( $local['hours'] ) ) {
$schema['openingHoursSpecification'] = $local['hours'];
}
if ( ! empty( $local['price_range'] ) ) {
$schema['priceRange'] = $local['price_range'];
}
return $schema;
}
/**
* WooCommerce Product schema.
*
* @return array|null
*/
private function product_schema() {
if ( ! function_exists( 'wc_get_product' ) ) {
return null;
}
$product = wc_get_product( get_the_ID() );
if ( ! $product ) {
return null;
}
$schema = array(
'@context' => 'https://schema.org',
'@type' => 'Product',
'name' => $product->get_name(),
'description' => wp_strip_all_tags( $product->get_short_description() ),
'url' => get_permalink(),
'sku' => $product->get_sku(),
'offers' => array(
'@type' => 'Offer',
'price' => $product->get_price(),
'priceCurrency' => get_woocommerce_currency(),
'availability' => $product->is_in_stock() ? 'https://schema.org/InStock' : 'https://schema.org/OutOfStock',
'url' => get_permalink(),
),
);
$image_id = $product->get_image_id();
if ( $image_id ) {
$img = wp_get_attachment_image_src( $image_id, 'full' );
if ( $img ) {
$schema['image'] = $img[0];
}
}
if ( $product->get_average_rating() > 0 ) {
$schema['aggregateRating'] = array(
'@type' => 'AggregateRating',
'ratingValue' => $product->get_average_rating(),
'reviewCount' => $product->get_review_count(),
);
}
return $schema;
}
/**
* Easy Digital Downloads Product schema.
*
* @return array|null
*/
private function edd_product_schema() {
if ( ! function_exists( 'edd_get_download' ) ) {
return null;
}
$post_id = get_the_ID();
$download = edd_get_download( $post_id );
if ( ! $download ) {
return null;
}
$price = edd_get_download_price( $post_id );
// Handle variable pricing: use lowest price.
if ( edd_has_variable_prices( $post_id ) ) {
$prices = edd_get_variable_prices( $post_id );
if ( ! empty( $prices ) ) {
$amounts = wp_list_pluck( $prices, 'amount' );
$price = min( array_map( 'floatval', $amounts ) );
}
}
$schema = array(
'@context' => 'https://schema.org',
'@type' => 'Product',
'name' => get_the_title( $post_id ),
'description' => wp_strip_all_tags( get_the_excerpt( $post_id ) ),
'url' => get_permalink( $post_id ),
'offers' => array(
'@type' => 'Offer',
'price' => $price,
'priceCurrency' => edd_get_currency(),
'availability' => 'https://schema.org/InStock',
'url' => get_permalink( $post_id ),
),
);
// SKU if set.
$sku = get_post_meta( $post_id, 'edd_sku', true );
if ( ! empty( $sku ) ) {
$schema['sku'] = $sku;
}
// Featured image.
$thumb_id = get_post_thumbnail_id( $post_id );
if ( $thumb_id ) {
$img = wp_get_attachment_image_src( $thumb_id, 'full' );
if ( $img ) {
$schema['image'] = $img[0];
}
}
// Reviews if EDD Reviews extension is active.
if ( function_exists( 'edd_reviews' ) ) {
$rating = get_post_meta( $post_id, 'edd_reviews_average_rating', true );
$count = get_post_meta( $post_id, 'edd_reviews_count', true );
if ( $rating && $count ) {
$schema['aggregateRating'] = array(
'@type' => 'AggregateRating',
'ratingValue' => floatval( $rating ),
'reviewCount' => intval( $count ),
);
}
}
return $schema;
}
/**
* Check if the current page is a contact page.
*
* @return bool
*/
private function is_contact_page() {
if ( ! is_singular( 'page' ) ) {
return false;
}
$post = get_post();
$slug = $post ? $post->post_name : '';
return in_array( $slug, array( 'contact', 'contact-us', 'get-in-touch' ), true );
}
/**
* Detect embedded videos and generate VideoObject schema.
*
* Only fires when a YouTube or Vimeo iframe is found in post content.
* Binary check with near-zero false positive risk.
*
* @return array|null
*/
private function detect_video_schema() {
$post = get_post();
if ( ! $post ) {
return null;
}
$content = $post->post_content;
// Match YouTube embeds.
if ( preg_match( '/(?:youtube\.com\/embed\/|youtu\.be\/)([a-zA-Z0-9_-]{11})/', $content, $yt ) ) {
$video_id = $yt[1];
return array(
'@context' => 'https://schema.org',
'@type' => 'VideoObject',
'name' => get_the_title( $post ),
'description' => wp_trim_words( wp_strip_all_tags( $post->post_content ), 30, '...' ),
'thumbnailUrl' => 'https://img.youtube.com/vi/' . $video_id . '/maxresdefault.jpg',
'uploadDate' => get_the_date( 'c', $post ),
'embedUrl' => 'https://www.youtube.com/embed/' . $video_id,
);
}
// Match Vimeo embeds.
if ( preg_match( '/vimeo\.com\/(?:video\/)?([0-9]+)/', $content, $vm ) ) {
return array(
'@context' => 'https://schema.org',
'@type' => 'VideoObject',
'name' => get_the_title( $post ),
'description' => wp_trim_words( wp_strip_all_tags( $post->post_content ), 30, '...' ),
'uploadDate' => get_the_date( 'c', $post ),
'embedUrl' => 'https://player.vimeo.com/video/' . $vm[1],
);
}
return null;
}
/**
* CollectionPage schema for archive pages.
*
* Fires on category, tag, and date archives. Template-level detection.
*
* @return array
*/
private function collection_page_schema() {
$title = '';
$desc = '';
$url = '';
if ( is_category() ) {
$cat = get_queried_object();
$title = $cat->name;
$desc = $cat->description ?: sprintf( 'Articles in %s', $cat->name );
$url = get_category_link( $cat->term_id );
} elseif ( is_tag() ) {
$tag = get_queried_object();
$title = $tag->name;
$desc = $tag->description ?: sprintf( 'Articles tagged %s', $tag->name );
$url = get_tag_link( $tag->term_id );
} elseif ( is_date() ) {
$title = get_the_archive_title();
$desc = sprintf( 'Content published in %s', $title );
$url = get_pagenum_link( 1 );
}
return array(
'@context' => 'https://schema.org',
'@type' => 'CollectionPage',
'name' => wp_strip_all_tags( $title ),
'description' => wp_strip_all_tags( $desc ),
'url' => $url,
);
}
/**
* ProfilePage schema for author archive pages.
*
* Template-level detection via is_author(). Zero false positive risk.
*
* @return array
*/
private function profile_page_schema() {
$author = get_queried_object();
$schema = array(
'@context' => 'https://schema.org',
'@type' => 'ProfilePage',
'name' => sprintf( '%s - Author Profile', $author->display_name ),
'url' => get_author_posts_url( $author->ID ),
'mainEntity' => $this->build_author_schema( $author->ID ),
);
// Add @context to nested mainEntity since it is a top-level Person.
$schema['mainEntity']['@context'] = 'https://schema.org';
return $schema;
}
/**
* Check if a third-party SEO plugin already handles a schema type.
*
* Prevents duplicate schema output when Yoast SEO or Rank Math is active.
*
* @param string $type Schema type (Article, FAQPage, HowTo, etc.).
* @return bool True if another plugin handles this type.
*/
private function third_party_handles( $type ) {
$seo_active = defined( 'WPSEO_VERSION' ) || defined( 'RANK_MATH_VERSION' ) || class_exists( '\RankMath\Paper\Paper' );
if ( ! $seo_active ) {
return false; // No third-party SEO plugin active, we handle everything.
}
// Check user's per-type resolution preference.
// Values: 'ours' (we output, their filter removes theirs),
// 'theirs' (we skip, they output),
// 'both' (we output alongside theirs).
// Default: 'theirs' for types both plugins produce (safest, no duplication).
$resolutions = get_option( 'asgm_schema_resolutions', array() );
$resolution = isset( $resolutions[ $type ] ) ? $resolutions[ $type ] : null;
// If user explicitly chose, respect it.
if ( 'ours' === $resolution || 'both' === $resolution ) {
return false; // We output.
}
if ( 'theirs' === $resolution ) {
return true; // We skip.
}
// No explicit preference set: auto-detect whether the other plugin outputs this type.
// Default to 'theirs' (skip ours) for types they produce, to avoid duplication
// until the user visits the Conflicts page and makes a choice.
// Yoast SEO.
if ( defined( 'WPSEO_VERSION' ) ) {
// phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound
$yoast_on = apply_filters( 'wpseo_json_ld_output', true );
if ( $yoast_on && in_array( $type, array( 'Article', 'WebSite', 'Organization', 'BreadcrumbList' ), true ) ) {
return true;
}
if ( 'FAQPage' === $type ) {
$post = get_post();
if ( $post && has_block( 'yoast/faq-block', $post ) ) {
return true;
}
}
if ( 'HowTo' === $type ) {
$post = get_post();
if ( $post && has_block( 'yoast/how-to-block', $post ) ) {
return true;
}
}
}
// Rank Math.
if ( defined( 'RANK_MATH_VERSION' ) || class_exists( '\RankMath\Paper\Paper' ) ) {
if ( in_array( $type, array( 'Article', 'WebSite', 'Organization', 'BreadcrumbList' ), true ) ) {
return true;
}
if ( 'FAQPage' === $type ) {
$post = get_post();
if ( $post && has_block( 'rank-math/faq-block', $post ) ) {
return true;
}
$rm_snippet = $post ? get_post_meta( $post->ID, 'rank_math_rich_snippet', true ) : '';
$rm_schema = $post ? get_post_meta( $post->ID, 'rank_math_schema_FAQPage', true ) : '';
if ( 'faq' === $rm_snippet || ! empty( $rm_schema ) ) {
return true;
}
}
if ( 'HowTo' === $type ) {
$post = get_post();
if ( $post && has_block( 'rank-math/howto-block', $post ) ) {
return true;
}
$rm_snippet = $post ? get_post_meta( $post->ID, 'rank_math_rich_snippet', true ) : '';
$rm_schema = $post ? get_post_meta( $post->ID, 'rank_math_schema_HowTo', true ) : '';
if ( 'howto' === $rm_snippet || ! empty( $rm_schema ) ) {
return true;
}
}
}
return false;
}
}