← 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 | +} | |