PluginProbe
Jetpack – WP Security, Backup, Speed, & Growth / 16.3
Jetpack – WP Security, Backup, Speed, & Growth v16.3
16.3 16.3-beta 16.3-a.5 16.3-a.7 16.3-a.3 16.3-a.1 16.2 16.2-beta 12.0.3 12.1.3 12.2.3 12.3.2 12.4.2 12.5.2 12.6.4 12.7.3 12.8.3 12.9.5 13.0.2 13.1.5 13.2.4 13.3.3 13.4.5 13.5.2 13.6.2 All 508 releases
← All changes | jetpack_vendor/automattic/jetpack-seo/src/class-schema-builder.php +266 -0 16.2-beta → 16.3 View file →
@@ -1,0 +1,266 @@
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 +
28 +namespace Automattic\Jetpack\SEO;
29 +
30 +use Jetpack_SEO_Utils;
31 +
32 +/**
33 + * Emits Schema.org JSON-LD `@graph` documents into the page.
34 + */
35 +class 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 +}