$harvested_faqs Optional Q/A pairs harvested from third-party FAQPage nodes elsewhere in the page. Passed by the Frontend layer during buffered emission; empty in admin previews. * @return array */ public static function build_graph( int $post_id, array $harvested_faqs = array() ): array { // Step 1: per-page kill switch. if ( SMFree_Overrides::is_disabled( $post_id ) ) { return array(); } // Steps 2 + 3. $nodes = self::build_preview_nodes( $post_id, $harvested_faqs ); if ( empty( $nodes ) ) { return array(); } // Steps 4 + 5. $nodes = SMFree_Overrides::apply_suppression( $nodes, $post_id ); $nodes = SMFree_Overrides::apply_custom_nodes( $nodes, $post_id ); /** * Step 6. Filter: the final list of @graph nodes for a post, after * FAQ detection and per-page overrides, before the coexistence mode. * * @param array> $nodes Graph nodes. * @param int $post_id Post ID. */ $nodes = (array) apply_filters( 'smfree_graph', $nodes, $post_id ); $nodes = array_values( array_filter( $nodes, 'is_array' ) ); if ( empty( $nodes ) ) { return array(); } return array( '@context' => 'https://schema.org', '@graph' => $nodes, ); } /** * Graph nodes before per-page overrides: the auto-generated graph plus * any auto-detected FAQPage. Used by build_graph() and by the meta box * to list the @types a user can suppress. * * @param int $post_id Post ID. * @param array $harvested_faqs Harvested third-party Q/A pairs. * @return array> */ public static function build_preview_nodes( int $post_id, array $harvested_faqs = array() ): array { $graph = self::build_auto_graph( $post_id, $harvested_faqs ); if ( empty( $graph['@graph'] ) ) { return array(); } $nodes = $graph['@graph']; $faq_node = SMFree_FAQ_Detector::build_node( $post_id ); if ( null !== $faq_node ) { $nodes = SMFree_FAQ_Detector::merge_into_graph( $nodes, $faq_node ); } return $nodes; } /** * Build the auto-generated JSON-LD graph for a post from the templates. * * @param int $post_id Post ID. * @param array $harvested_faqs Harvested third-party Q/A pairs. * @return array */ private static function build_auto_graph( int $post_id, array $harvested_faqs = array() ): array { $site = Mapper::site(); $post = Mapper::post( $post_id ); if ( ! $post ) { return array(); } $ctx = array( 'site' => $site, 'post' => $post, 'harvested_faqs' => $harvested_faqs, ); // Backbone: WebSite (with inline SearchAction) + Organization. // Every page emits these so search engines can connect entities across // URLs. Same @id for the same site keeps the graph coherent. // // SearchAction is nested inside WebSite.potentialAction per Google's // Sitelinks Search Box spec — not a separate top-level node. $graph = array(); $graph[] = Website::build( $ctx ); $graph[] = Organization::build( $ctx ); // Page-level nodes. $graph[] = WebPage::build( $ctx ); // BreadcrumbList: skip on the homepage. A homepage breadcrumb is a // degenerate case — both items point to the same URL, which looks // broken to Google and adds no navigational signal. if ( ! Eligibility::is_homepage( $post ) ) { $graph[] = BreadcrumbList::build( $ctx ); } // Content-type node. Only BlogPosting for posts. // // Prior versions emitted both Article and BlogPosting side-by-side // with identical fields. That's redundant: schema.org's inheritance // says BlogPosting IS an Article automatically (BlogPosting is a // subclass of Article), so publishing both describes the same post // twice under two @id values. Graph consumers get confused about // which entity is authoritative, and the graph carries duplicate // weight for no signal gain. BlogPosting alone is the correct // choice — it's more specific and Google's crawler recognizes it // as an Article automatically without being told twice. if ( Eligibility::is_blog_post( $post ) ) { $graph[] = BlogPosting::build( $ctx ); } // Content-detection nodes. These emit only when relevant markup is // present in the post content. Safe on any content type. $graph[] = FAQPage::build( $ctx ); $graph[] = HowTo::build( $ctx ); $graph[] = VideoObject::build( $ctx ); // WooCommerce Product (only on 'product' post type; template no-ops // otherwise). $graph[] = Product::build( $ctx ); // Homepage-only nodes. if ( Eligibility::is_homepage( $post ) ) { $graph[] = SiteNavigationElement::build( $ctx ); } /** * Filter: modify the graph before final assembly. * * @param array|null> $graph Raw graph nodes. * @param array $ctx Context. */ $graph = (array) apply_filters( 'smfree_engine_graph_nodes', $graph, $ctx ); // Deprecated pre-1.0.15 name, kept for back-compat. // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound $graph = (array) apply_filters_deprecated( 'smf_engine_graph_nodes', array( $graph, $ctx ), '1.0.15', 'smfree_engine_graph_nodes' ); // Drop nulls and reindex. $graph = array_values( array_filter( $graph, static function ( $node ) { return is_array( $node ) && ! empty( $node ); } ) ); if ( empty( $graph ) ) { return array(); } // De-duplicate on @id (last write wins). Any template accidentally // emitting two nodes with the same @id will surface here rather than // in downstream consumers. $by_id = array(); $no_id = array(); foreach ( $graph as $node ) { $id = isset( $node['@id'] ) ? (string) $node['@id'] : ''; if ( '' === $id ) { $no_id[] = $node; } else { $by_id[ $id ] = $node; } } $graph = array_merge( array_values( $by_id ), $no_id ); return array( '@context' => 'https://schema.org', '@graph' => $graph, ); } }