PluginProbe
Jetpack – WP Security, Backup, Speed, & Growth / 16.3-a.7
Jetpack – WP Security, Backup, Speed, & Growth v16.3-a.7
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 13.7.2 13.8.3 All 506 releases
jetpack / jetpack_vendor / automattic / jetpack-seo / src / class-schema-builder.php

class-schema-builder.php in Jetpack – WP Security, Backup, Speed, & Growth 16.3-a.7, at jetpack_vendor/automattic/jetpack-seo/src/class-schema-builder.php

267 lines 10.4 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
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 }
267