Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
95.65% covered (success)
95.65%
22 / 23
66.67% covered (warning)
66.67%
2 / 3
CRAP
0.00% covered (danger)
0.00%
0 / 1
Schema_Builder
95.65% covered (success)
95.65%
22 / 23
66.67% covered (warning)
66.67%
2 / 3
11
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%
11 / 11
100.00% covered (success)
100.00%
1 / 1
6
 build_document
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
4
1<?php
2/**
3 * JSON-LD Schema.org markup emitter.
4 *
5 * Serializes a Schema.org `@graph` document into the document `<head>` for the
6 * current singular request. The graph stitches together the page node (Article,
7 * or FAQPage when the post uses `core/details` blocks) built by
8 * {@see Post_Schema_Node}; site-level nodes (Organization, WebSite, …) join the
9 * same graph and cross-reference the page node by `@id`. Emission is gated on
10 * `Jetpack_SEO_Utils::is_enabled_jetpack_seo()`.
11 *
12 * This class owns only the gating and serialization; the individual nodes and
13 * their stable `@id`s live in their own builders ({@see Post_Schema_Node},
14 * {@see Schema_Node_Ids}) and are assembled by {@see Schema_Graph}.
15 *
16 * @package automattic/jetpack-seo-package
17 */
18
19namespace Automattic\Jetpack\SEO;
20
21use Jetpack_SEO_Utils;
22
23/**
24 * Emits a Schema.org JSON-LD `@graph` into the document head.
25 */
26class Schema_Builder {
27
28    /**
29     * Wire the front-end emitter.
30     *
31     * @return void
32     */
33    public static function init() {
34        add_action( 'wp_head', array( __CLASS__, 'emit' ), 5 );
35    }
36
37    /**
38     * Build and echo the JSON-LD `@graph` block for the current singular request.
39     *
40     * @return void
41     */
42    public static function emit() {
43        // Both plugin classes must be loaded — they're not guaranteed in every
44        // context, and the post node builder calls Jetpack_SEO_Posts directly.
45        // @phan-suppress-next-line PhanUndeclaredClassMethod -- Jetpack_SEO_Utils lives in plugins/jetpack; guarded by the class_exists check on the same line.
46        if ( ! class_exists( 'Jetpack_SEO_Utils' ) || ! class_exists( 'Jetpack_SEO_Posts' ) || ! Jetpack_SEO_Utils::is_enabled_jetpack_seo() ) {
47            return;
48        }
49
50        // Site-level nodes still ride along on the singular request's graph, so a
51        // page that emits no page node (and therefore no graph) emits nothing —
52        // preserving the pre-graph behavior on archives, the home page, and 404s.
53        if ( ! is_singular() ) {
54            return;
55        }
56
57        $document = self::build_document( get_queried_object() );
58        if ( null === $document ) {
59            return;
60        }
61
62        printf(
63            '<script type="application/ld+json">%s</script>',
64            // Default flags escape forward slashes — important inside <script>
65            // so a "</script>" in the data can't break out of the block.
66            wp_json_encode( $document, JSON_UNESCAPED_UNICODE ) // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
67        );
68    }
69
70    /**
71     * Assemble the `@graph` document for the queried singular object.
72     *
73     * Returns null when the post yields no page node, so the caller emits nothing
74     * rather than an empty graph. Site-level nodes are only added alongside a page
75     * node; standalone sitewide emission (home page, archives) is out of scope.
76     *
77     * Cross-node references (e.g. the Article `publisher`) are wired here rather
78     * than inside the individual node builders, which stay self-contained and
79     * unaware of each other.
80     *
81     * @param mixed $queried_object The queried object (expected to be a WP_Post).
82     * @return array|null
83     */
84    private static function build_document( $queried_object ) {
85        $post_node = Post_Schema_Node::build( $queried_object );
86        if ( null === $post_node ) {
87            return null;
88        }
89
90        $graph = new Schema_Graph();
91
92        // Site-level entities come first, then the page node references them by @id.
93        // Organization is built from site identity alone here. The persisted schema
94        // settings — social profiles (`sameAs`) and any `name`/`logo`/`email`
95        // overrides — are injected through Organization_Schema_Node::build( $settings )
96        // once the schema settings server lands; see the `$settings` seam on that
97        // builder. Until then the argument is intentionally empty, so the output
98        // matches the current site identity and nothing is configurable yet.
99        $organization = Organization_Schema_Node::build();
100        if ( null !== $organization ) {
101            $graph->add( $organization );
102
103            // Only the Article node carries a publisher; FAQPage does not.
104            if ( 'Article' === ( $post_node['@type'] ?? '' ) ) {
105                $post_node['publisher'] = array( '@id' => Schema_Node_Ids::organization() );
106            }
107        }
108
109        $graph->add( $post_node );
110
111        return $graph->to_document();
112    }
113}