Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
96.15% covered (success)
96.15%
25 / 26
66.67% covered (warning)
66.67%
2 / 3
CRAP
0.00% covered (danger)
0.00%
0 / 1
Schema_Builder
96.15% covered (success)
96.15%
25 / 26
66.67% covered (warning)
66.67%
2 / 3
15
0.00% covered (danger)
0.00%
0 / 1
 init
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 emit
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
5
 build_document
100.00% covered (success)
100.00%
16 / 16
100.00% covered (success)
100.00%
1 / 1
9
1<?php
2/**
3 * JSON-LD Schema.org markup emitter.
4 *
5 * Serializes a Schema.org `@graph` document into the document `<head>`. The graph
6 * is assembled from independent, condition-gated contributions: the site-level
7 * Organization and WebSite nodes, emitted on the home page only (Google treats
8 * them as single canonical site entities), and the page node (Article, or
9 * FAQPage when the post uses `core/details` blocks) built by
10 * {@see Post_Schema_Node} on singular requests. An Article — and the WebSite
11 * node — reference the home-page Organization as their `publisher` by stable
12 * `@id` rather than duplicating the node. Emission is gated on
13 * `Jetpack_SEO_Utils::is_enabled_jetpack_seo()`.
14 *
15 * This class owns only the gating and serialization; the individual nodes and
16 * their stable `@id`s live in their own builders ({@see Post_Schema_Node},
17 * {@see Organization_Schema_Node}, {@see Website_Schema_Node},
18 * {@see Schema_Node_Ids}) and are assembled by {@see Schema_Graph}.
19 *
20 * @package automattic/jetpack-seo-package
21 */
22
23namespace Automattic\Jetpack\SEO;
24
25use Jetpack_SEO_Utils;
26
27/**
28 * Emits a Schema.org JSON-LD `@graph` into the document head.
29 */
30class Schema_Builder {
31
32    /**
33     * Wire the front-end emitter.
34     *
35     * @return void
36     */
37    public static function init() {
38        add_action( 'wp_head', array( __CLASS__, 'emit' ), 5 );
39    }
40
41    /**
42     * Build and echo the JSON-LD `@graph` block for the current request.
43     *
44     * @return void
45     */
46    public static function emit() {
47        // Both plugin classes must be loaded — they're not guaranteed in every
48        // context, and the post node builder calls Jetpack_SEO_Posts directly.
49        // @phan-suppress-next-line PhanUndeclaredClassMethod -- Jetpack_SEO_Utils lives in plugins/jetpack; guarded by the class_exists check on the same line.
50        if ( ! class_exists( 'Jetpack_SEO_Utils' ) || ! class_exists( 'Jetpack_SEO_Posts' ) || ! Jetpack_SEO_Utils::is_enabled_jetpack_seo() ) {
51            return;
52        }
53
54        // build_document() gates each node itself and returns null for an empty
55        // graph, so archives and 404s (no front-page, no page node) emit nothing.
56        $document = self::build_document();
57        if ( null === $document ) {
58            return;
59        }
60
61        printf(
62            '<script type="application/ld+json">%s</script>',
63            // Default flags escape forward slashes — important inside <script>
64            // so a "</script>" in the data can't break out of the block.
65            wp_json_encode( $document, JSON_UNESCAPED_UNICODE ) // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
66        );
67    }
68
69    /**
70     * Assemble the `@graph` document for the current request.
71     *
72     * The site-level nodes and the singular page node are two independent,
73     * condition-gated contributions to one graph:
74     *
75     * - Organization and WebSite are single canonical site entities, so their full
76     *   nodes are added on the home page only (Google's guidance). Other pages
77     *   reference the Organization by `@id` instead of duplicating it.
78     * - The page node (Article/FAQPage) is added on singular requests. An Article
79     *   points its `publisher` at the home-page Organization's stable `@id`.
80     *
81     * Returns null when the graph ends up empty (archives, 404, a page with no
82     * node) so the caller emits nothing rather than an empty graph. Cross-node
83     * references are wired here rather than inside the individual node builders,
84     * which stay self-contained and unaware of each other.
85     *
86     * @return array|null
87     */
88    private static function build_document() {
89        $graph = new Schema_Graph();
90
91        // Effective Organization settings (stored overrides merged over site identity);
92        // an unconfigured site still yields a valid node from site identity alone. Build
93        // it regardless so we know whether a publisher @id reference will resolve, but
94        // only add the full node on the home page.
95        $organization = Organization_Schema_Node::build( Schema_Settings::get_organization() );
96
97        // Site-level nodes (Organization, WebSite) describe a single canonical
98        // entity, so they belong on the home page only (Google's guidance) — never
99        // duplicated onto every post. WebSite references the Organization by @id.
100        if ( is_front_page() ) {
101            if ( null !== $organization ) {
102                $graph->add( $organization );
103            }
104
105            $website = Website_Schema_Node::build();
106            if ( null !== $website && null !== $organization ) {
107                $website['publisher'] = array( '@id' => Schema_Node_Ids::organization() );
108            }
109            $graph->add( $website );
110        }
111
112        if ( is_singular() ) {
113            $post_node = Post_Schema_Node::build( get_queried_object() );
114            if ( null !== $post_node ) {
115                // Only the Article node carries a publisher; FAQPage does not. It
116                // references the home-page Organization by @id, never duplicating it.
117                if ( null !== $organization && 'Article' === ( $post_node['@type'] ?? '' ) ) {
118                    $post_node['publisher'] = array( '@id' => Schema_Node_Ids::organization() );
119                }
120                $graph->add( $post_node );
121            }
122        }
123
124        return $graph->to_document();
125    }
126}