` in with WebSite, * Organization, Article (singular posts/pages), BreadcrumbList (non-front * singular), and FAQPage (auto-detected from heading/Q&A patterns). * * Auto-suppresses when an SEO plugin is active (see active_seo_plugin()) * to prevent duplicate structured-data warnings. * * @package Ajaco */ namespace Ajaco; if ( ! defined( 'ABSPATH' ) ) { exit; } add_action( 'wp_head', __NAMESPACE__ . '\\output_json_ld_schema', 1 ); /** * Extract Q&A pairs from post content for FAQPage schema. * * Walks the DOM looking first for
/
definition lists, then for * question-shaped headings (ending in `?` or starting with what/why/how/...) * followed by sibling content up to the next heading. * * @param string $html Raw post_content HTML. * @return array */ function extract_faq_pairs( string $html ): array { $faqs = array(); if ( trim( $html ) === '' ) { return $faqs; } libxml_use_internal_errors( true ); $doc = new \DOMDocument(); $doc->loadHTML( '' . $html . '', LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD ); libxml_clear_errors(); $xpath = new \DOMXPath( $doc ); // Strategy 1:
/
definition-list pairs. foreach ( $xpath->query( '//dt' ) as $dt ) { $dd = $dt->nextSibling; while ( $dd && $dd->nodeType !== XML_ELEMENT_NODE ) { $dd = $dd->nextSibling; } if ( $dd && strtolower( $dd->nodeName ) === 'dd' ) { $q = trim( $dt->textContent ); $a = trim( preg_replace( '/\s+/', ' ', $dd->textContent ) ); if ( $q !== '' && strlen( $a ) > 10 ) { $faqs[] = array( 'q' => $q, 'a' => $a, ); } } } if ( ! empty( $faqs ) ) { return $faqs; } // Strategy 2: question-shaped headings followed by sibling content up to next heading. $heading_levels = array( 'h1', 'h2', 'h3', 'h4', 'h5', 'h6' ); foreach ( $xpath->query( '//h2 | //h3 | //h4' ) as $heading ) { $q = trim( preg_replace( '/\s+/', ' ', $heading->textContent ) ); if ( $q === '' ) { continue; } $looks_like_question = (bool) preg_match( '/\?\s*$/u', $q ) || (bool) preg_match( '/^(what|why|how|when|where|who|is|are|can|do|does)\b/i', $q ); if ( ! $looks_like_question ) { continue; } $a = ''; $sibling = $heading->nextSibling; while ( $sibling ) { if ( $sibling->nodeType === XML_ELEMENT_NODE && in_array( strtolower( $sibling->nodeName ), $heading_levels, true ) ) { break; } if ( $sibling->nodeType === XML_ELEMENT_NODE ) { $a .= ' ' . $sibling->textContent; } $sibling = $sibling->nextSibling; } $a = trim( preg_replace( '/\s+/', ' ', $a ) ); if ( strlen( $a ) > 10 ) { $faqs[] = array( 'q' => $q, 'a' => $a, ); } } return $faqs; } /** * Output JSON-LD structured data schema. * * @return void */ function output_json_ld_schema(): void { if ( ! is_feature_enabled( 'json_ld' ) ) { return; } // Auto-suppress when a major SEO plugin is active — they emit their own // WebSite/Organization/Article schemas, and shipping ours alongside causes // duplicate-schema warnings in Google Search Console. if ( active_seo_plugin() !== null ) { return; } $site_url = home_url( '/' ); $site_name = get_bloginfo( 'name' ); $site_description = get_bloginfo( 'description' ); // Resolve logo: theme custom logo first, then site icon, then nothing. $logo_block = null; $custom_logo_id = (int) get_theme_mod( 'custom_logo' ); if ( $custom_logo_id ) { $img = wp_get_attachment_image_src( $custom_logo_id, 'full' ); if ( $img ) { $logo_block = array( '@type' => 'ImageObject', 'url' => $img[0], 'width' => (int) $img[1], 'height' => (int) $img[2], ); } } if ( ! $logo_block ) { $icon = get_site_icon_url( 512 ); if ( $icon ) { $logo_block = array( '@type' => 'ImageObject', 'url' => $icon, 'width' => 512, 'height' => 512, ); } } $organization = array( '@type' => 'Organization', '@id' => $site_url . '#organization', 'name' => $site_name, 'alternateName' => $site_name, 'url' => $site_url, ); if ( $logo_block ) { $organization['logo'] = $logo_block; } $schema = array( '@context' => 'https://schema.org', '@graph' => array( // WebSite schema. array( '@type' => 'WebSite', '@id' => $site_url . '#website', 'url' => $site_url, 'name' => $site_name, 'description' => $site_description, 'inLanguage' => get_bloginfo( 'language' ) ?: 'en-US', 'publisher' => array( '@id' => $site_url . '#organization', ), 'potentialAction' => array( '@type' => 'SearchAction', 'target' => array( '@type' => 'EntryPoint', 'urlTemplate' => $site_url . '?s={search_term_string}', ), 'query-input' => array( '@type' => 'PropertyValueSpecification', 'valueRequired' => true, 'valueName' => 'search_term_string', ), ), ), $organization, ), ); // Add Article schema for singular posts/pages of any public post type // that opts into the REST API (per spec). if ( is_singular() ) { $post_type = get_post_type(); $pt_object = $post_type ? get_post_type_object( $post_type ) : null; $is_supported = $pt_object && ! empty( $pt_object->public ) && ! empty( $pt_object->show_in_rest ); if ( $is_supported ) { global $post; if ( $post ) { setup_postdata( $post ); $raw_desc = has_excerpt() ? get_the_excerpt() : wp_strip_all_tags( get_the_content() ); $description = trim( preg_replace( '/\s+/', ' ', html_entity_decode( wp_strip_all_tags( $raw_desc ), ENT_QUOTES | ENT_HTML5, 'UTF-8' ) ) ); $description = preg_replace( '/\s*\[(\.\.\.|…)\]\s*$/u', '', $description ); $article_schema = array( '@type' => 'Article', '@id' => get_permalink() . '#article', 'headline' => get_the_title(), 'description' => $description, 'datePublished' => get_the_date( 'c' ), 'dateModified' => get_the_modified_date( 'c' ), 'author' => array( '@type' => 'Person', 'name' => get_the_author(), ), 'publisher' => array( '@id' => $site_url . '#organization', ), 'image' => has_post_thumbnail() ? get_the_post_thumbnail_url( get_the_ID(), 'large' ) : null, 'url' => get_permalink(), 'mainEntityOfPage' => array( '@type' => 'WebPage', '@id' => get_permalink(), ), ); $article_schema = array_filter( $article_schema, function ( $value ) { return $value !== null && $value !== ''; } ); $schema['@graph'][] = $article_schema; wp_reset_postdata(); } } } // Add BreadcrumbList for singular pages and posts (not front page). if ( is_singular() && ! is_front_page() ) { global $post; if ( $post ) { $crumbs = array( '@type' => 'BreadcrumbList', 'itemListElement' => array(), ); $crumbs['itemListElement'][] = array( '@type' => 'ListItem', 'position' => 1, 'name' => 'Home', 'item' => $site_url, ); if ( is_single() ) { $cats = get_the_category( $post->ID ); if ( ! empty( $cats ) ) { $cat = $cats[0]; if ( $cat->parent ) { $parent = get_category( $cat->parent ); if ( $parent && ! is_wp_error( $parent ) ) { $crumbs['itemListElement'][] = array( '@type' => 'ListItem', 'position' => 2, 'name' => $parent->name, 'item' => get_category_link( $parent->term_id ), ); } } $crumbs['itemListElement'][] = array( '@type' => 'ListItem', 'position' => count( $crumbs['itemListElement'] ) + 1, 'name' => $cat->name, 'item' => get_category_link( $cat->term_id ), ); } } $crumbs['itemListElement'][] = array( '@type' => 'ListItem', 'position' => count( $crumbs['itemListElement'] ) + 1, 'name' => get_the_title(), 'item' => get_permalink(), ); $schema['@graph'][] = $crumbs; } } // FAQPage detection on singular content. if ( is_singular() ) { global $post; if ( $post ) { $plain = strtolower( wp_strip_all_tags( $post->post_content ) ); $faq_detected = ( strpos( $plain, 'faq' ) !== false || strpos( $plain, 'frequently asked' ) !== false || preg_match( '/q:\s*.+\?\s*\n?\s*a:\s*.+\?/i', $plain ) || preg_match( '/what (is|are|do|does|can|will)/i', $plain ) ); if ( $faq_detected ) { $pairs = extract_faq_pairs( $post->post_content ); if ( ! empty( $pairs ) ) { $main_entity = array(); foreach ( $pairs as $pair ) { $main_entity[] = array( '@type' => 'Question', 'name' => $pair['q'], 'acceptedAnswer' => array( '@type' => 'Answer', 'text' => $pair['a'], ), ); } $schema['@graph'][] = array( '@type' => 'FAQPage', '@id' => get_permalink() . '#faq', 'mainEntity' => $main_entity, ); } } } } /** * Filter the JSON-LD @graph array before output. * * Allows plugins to add custom Schema.org entries (Product, Recipe, Event, * VideoObject, etc.) or modify the existing WebSite/Organization/Article * entries. The filter receives the full `@graph` array (without the * `@context` wrapper). * * @param array $graph */ $schema['@graph'] = apply_filters( 'ajaco_json_ld_graph', $schema['@graph'] ); // JSON-LD is embedded in an HTML ` and break out of the block. JSON_UNESCAPED_SLASHES is kept so // the many URLs in the graph stay readable. // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped echo '' . "\n"; }