Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
93.15% covered (success)
93.15%
68 / 73
62.50% covered (warning)
62.50%
5 / 8
CRAP
0.00% covered (danger)
0.00%
0 / 1
Schema_Builder
93.15% covered (success)
93.15%
68 / 73
62.50% covered (warning)
62.50%
5 / 8
45.65
0.00% covered (danger)
0.00%
0 / 1
 init
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 emit
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
5
 build_document
100.00% covered (success)
100.00%
31 / 31
100.00% covered (success)
100.00%
1 / 1
14
 emit_woocommerce_breadcrumb_fallback
81.82% covered (warning)
81.82%
9 / 11
0.00% covered (danger)
0.00%
0 / 1
8.38
 woocommerce_may_output_breadcrumb_schema
87.50% covered (warning)
87.50%
7 / 8
0.00% covered (danger)
0.00%
0 / 1
7.10
 woocommerce_has_breadcrumb_schema
71.43% covered (warning)
71.43%
5 / 7
0.00% covered (danger)
0.00%
0 / 1
6.84
 output_document
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 build_person_node
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
3
1<?php
2/**
3 * JSON-LD Schema.org markup emitter.
4 *
5 * Serializes Schema.org `@graph` documents into the page. The primary graph is
6 * emitted in the document `<head>` and is assembled from independent,
7 * condition-gated contributions: the site-level Organization node (optionally
8 * extended as LocalBusiness) and WebSite node,
9 * emitted on the home page only (Google treats them as single canonical site
10 * entities), the page node (Article or FAQPage) built by {@see Post_Schema_Node}
11 * on singular requests, Person/ProfilePage nodes on author archives, and a
12 * BreadcrumbList on supported public requests. An Article references its
13 * author's full Person node (added to the same graph) by `@id`, and — like the
14 * WebSite node — references the home-page Organization as its `publisher` by
15 * stable `@id` rather than duplicating the node. Emission is gated on
16 * `Jetpack_SEO_Utils::is_enabled_jetpack_seo()`.
17 *
18 * This class owns only the gating and serialization; the individual nodes and
19 * their stable `@id`s live in their own builders ({@see Post_Schema_Node},
20 * {@see Organization_Schema_Node}, {@see Local_Business_Schema_Node},
21 * {@see Website_Schema_Node}, {@see Author_Schema_Node},
22 * {@see Breadcrumb_Schema_Node}, {@see Schema_Node_Ids}) and are assembled by
23 * {@see Schema_Graph}.
24 *
25 * @package automattic/jetpack-seo-package
26 */
27
28namespace Automattic\Jetpack\SEO;
29
30use Jetpack_SEO_Utils;
31
32/**
33 * Emits Schema.org JSON-LD `@graph` documents into the page.
34 */
35class Schema_Builder {
36
37    /**
38     * Wire the front-end emitter.
39     *
40     * @return void
41     */
42    public static function init() {
43        add_action( 'wp_head', array( __CLASS__, 'emit' ), 5 );
44        add_action( 'wp_footer', array( __CLASS__, 'emit_woocommerce_breadcrumb_fallback' ), 11 );
45    }
46
47    /**
48     * Build and echo the JSON-LD `@graph` block for the current request.
49     *
50     * @return void
51     */
52    public static function emit() {
53        // Both plugin classes must be loaded — they're not guaranteed in every
54        // context, and the post node builder calls Jetpack_SEO_Posts directly.
55        // @phan-suppress-next-line PhanUndeclaredClassMethod -- Jetpack_SEO_Utils lives in plugins/jetpack; guarded by the class_exists check on the same line.
56        if ( ! class_exists( 'Jetpack_SEO_Utils' ) || ! class_exists( 'Jetpack_SEO_Posts' ) || ! Jetpack_SEO_Utils::is_enabled_jetpack_seo() ) {
57            return;
58        }
59
60        // build_document() gates each node itself and returns null for an empty
61        // graph, so unsupported or invalid requests emit nothing.
62        $document = self::build_document();
63        if ( null === $document ) {
64            return;
65        }
66
67        self::output_document( $document );
68    }
69
70    /**
71     * Assemble the `@graph` document for the current request.
72     *
73     * The site-level nodes, author archive nodes, singular page node, and breadcrumb
74     * are independent, condition-gated contributions to one graph:
75     *
76     * - Organization and WebSite are single canonical site entities, so their full
77     *   nodes are added on the home page only (Google's guidance). Other pages
78     *   reference the Organization by `@id` instead of duplicating it.
79     * - Author archives contribute the author's Person node and the ProfilePage
80     *   wrapping it (`mainEntity` → Person `@id`).
81     * - The page node (Article/FAQPage) is added on singular requests. An Article
82     *   points its `publisher` at the home-page Organization's stable `@id` and its
83     *   `author` at the full Person node added to the same graph.
84     * - BreadcrumbList is added on supported requests when enabled. WooCommerce
85     *   requests defer to Woo's generated node, with a late Jetpack fallback when
86     *   the active template did not generate one.
87     *
88     * Returns null when the graph ends up empty (an unsupported or invalid request)
89     * so the caller emits nothing rather than an empty graph.
90     * Cross-node references are wired here rather than inside the individual node
91     * builders, which stay self-contained and unaware of each other.
92     *
93     * @return array|null
94     */
95    private static function build_document() {
96        $graph = new Schema_Graph();
97
98        // Effective Organization settings (stored overrides merged over site identity);
99        // an unconfigured site still yields a valid node from site identity alone. Build
100        // it regardless so we know whether `@id` references to it (publisher, worksFor)
101        // will resolve, but only add the full node on the home page.
102        $organization = Organization_Schema_Node::build( Schema_Settings::get_organization() );
103        $organization = Local_Business_Schema_Node::extend( $organization, Schema_Settings::get_local_business() );
104
105        // Site-level nodes (Organization, WebSite) describe a single canonical
106        // entity, so they belong on the home page only (Google's guidance) — never
107        // duplicated onto every post. WebSite references the Organization by @id.
108        if ( is_front_page() ) {
109            if ( null !== $organization ) {
110                $graph->add( $organization );
111            }
112
113            $website = Website_Schema_Node::build();
114            if ( null !== $website && null !== $organization ) {
115                $website['publisher'] = array( '@id' => Schema_Node_Ids::organization() );
116            }
117            $graph->add( $website );
118        }
119
120        if ( is_author() ) {
121            $person = self::build_person_node( get_queried_object(), null !== $organization );
122            if ( null !== $person ) {
123                $graph->add( $person );
124                $graph->add( Author_Schema_Node::build_profile_page( get_queried_object() ) );
125            }
126        }
127
128        if ( is_singular() ) {
129            $post      = get_queried_object();
130            $post_node = Post_Schema_Node::build( $post );
131            if ( null !== $post_node ) {
132                // Only the Article node carries publisher/author; FAQPage does not.
133                // Both are @id references: publisher points at the home-page
134                // Organization (never duplicated), author points at the full Person
135                // node added to this page's graph.
136                if ( 'Article' === ( $post_node['@type'] ?? '' ) ) {
137                    if ( null !== $organization ) {
138                        $post_node['publisher'] = array( '@id' => Schema_Node_Ids::organization() );
139                    }
140                    $person = self::build_person_node( (int) $post->post_author, null !== $organization );
141                    if ( null !== $person ) {
142                        $post_node['author'] = array( '@id' => $person['@id'] );
143                        $graph->add( $person );
144                    }
145                }
146                $graph->add( $post_node );
147            }
148        }
149
150        $breadcrumb_settings = Schema_Settings::get_breadcrumb_list();
151        if ( $breadcrumb_settings['enabled'] && ! self::woocommerce_may_output_breadcrumb_schema() ) {
152            $graph->add( Breadcrumb_Schema_Node::build() );
153        }
154
155        return $graph->to_document();
156    }
157
158    /**
159     * Emit Jetpack's BreadcrumbList after WooCommerce when WooCommerce was expected
160     * to provide one but did not actually generate any breadcrumb data.
161     *
162     * WooCommerce registers its generator and footer emitter independently of
163     * whether the active template renders `woocommerce_breadcrumb()`. Waiting until
164     * after WooCommerce's priority-10 footer emitter lets us inspect the generated
165     * data instead of treating callback registration as proof of output.
166     *
167     * @return void
168     */
169    public static function emit_woocommerce_breadcrumb_fallback() {
170        // @phan-suppress-next-line PhanUndeclaredClassMethod -- Jetpack_SEO_Utils lives in plugins/jetpack; guarded by the class_exists check on the same line.
171        if ( ! class_exists( 'Jetpack_SEO_Utils' ) || ! class_exists( 'Jetpack_SEO_Posts' ) || ! Jetpack_SEO_Utils::is_enabled_jetpack_seo() ) {
172            return;
173        }
174
175        $breadcrumb_settings = Schema_Settings::get_breadcrumb_list();
176        if ( ! $breadcrumb_settings['enabled'] || ! self::woocommerce_may_output_breadcrumb_schema() || self::woocommerce_has_breadcrumb_schema() ) {
177            return;
178        }
179
180        $graph = new Schema_Graph();
181        $graph->add( Breadcrumb_Schema_Node::build() );
182        $document = $graph->to_document();
183        if ( null === $document ) {
184            return;
185        }
186
187        self::output_document( $document );
188    }
189
190    /**
191     * Whether WooCommerce may emit its own BreadcrumbList for this request.
192     *
193     * This deliberately detects capability, not actual output. WooCommerce only
194     * generates breadcrumb data when its template function runs, so the late
195     * footer fallback verifies the generated data before relying on it.
196     *
197     * @return bool
198     */
199    private static function woocommerce_may_output_breadcrumb_schema() {
200        // @phan-suppress-next-line PhanUndeclaredFunction -- WooCommerce functions are guarded by function_exists().
201        if ( ! function_exists( 'is_woocommerce' ) || ! is_woocommerce() || ! function_exists( 'WC' ) ) {
202            return false;
203        }
204
205        // @phan-suppress-next-line PhanUndeclaredFunction -- Guarded by function_exists() above.
206        $woocommerce = WC();
207        if ( ! is_object( $woocommerce ) || ! isset( $woocommerce->structured_data ) ) {
208            return false;
209        }
210
211        $structured_data = $woocommerce->structured_data;
212        return false !== has_action( 'woocommerce_breadcrumb', array( $structured_data, 'generate_breadcrumblist_data' ) )
213            && false !== has_action( 'wp_footer', array( $structured_data, 'output_structured_data' ) );
214    }
215
216    /**
217     * Whether WooCommerce actually generated a BreadcrumbList for this request.
218     *
219     * @return bool
220     */
221    private static function woocommerce_has_breadcrumb_schema() {
222        if ( ! function_exists( 'WC' ) ) {
223            return false;
224        }
225
226        // @phan-suppress-next-line PhanUndeclaredFunction -- Guarded by function_exists() above.
227        $woocommerce = WC();
228        if ( ! is_object( $woocommerce ) || ! isset( $woocommerce->structured_data ) || ! method_exists( $woocommerce->structured_data, 'get_data' ) ) {
229            return false;
230        }
231
232        $data = $woocommerce->structured_data->get_data();
233        return is_array( $data ) && in_array( 'BreadcrumbList', array_column( $data, '@type' ), true );
234    }
235
236    /**
237     * Serialize and output a JSON-LD document.
238     *
239     * @param array $document Schema.org document.
240     * @return void
241     */
242    private static function output_document( array $document ) {
243        printf(
244            '<script type="application/ld+json">%s</script>',
245            // Default flags escape forward slashes — important inside <script>
246            // so a "</script>" in the data can't break out of the block.
247            wp_json_encode( $document, JSON_UNESCAPED_UNICODE ) // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
248        );
249    }
250
251    /**
252     * Build the author Person node, wiring `worksFor` to the site Organization's
253     * stable `@id` when the Organization node resolves.
254     *
255     * @param \WP_User|int|null $user             User object or ID.
256     * @param bool              $has_organization Whether the Organization node resolves.
257     * @return array|null
258     */
259    private static function build_person_node( $user, $has_organization ) {
260        $person = Author_Schema_Node::build_person( $user );
261        if ( null !== $person && $has_organization ) {
262            $person['worksFor'] = array( '@id' => Schema_Node_Ids::organization() );
263        }
264        return $person;
265    }
266}