← All changes
|
includes/frontend/class-global-seo-schema-output.php
+292
-30
2.0.1
→
2.10.0
View file →
| @@ -46,8 +46,34 @@ | ||
| 46 | 46 | */ |
| 47 | 47 | private const SCHEMA_CONTEXT = 'https://schema.org'; |
| 48 | 48 | |
| 49 | 49 | /** |
| 50 | + * Returns the description already resolved for this request, or null. | |
| 51 | + * | |
| 52 | + * Injected rather than resolved here, because the chain behind it (post | |
| 53 | + * meta, global template, archive, site-identity default, derived excerpt, | |
| 54 | + * tagline) reads request state that Seo_Manager owns. Duplicating it would | |
| 55 | + * be a second implementation to keep in step; this way schema and the meta | |
| 56 | + * tags cannot disagree (#766). | |
| 57 | + * | |
| 58 | + * @since 2.10.0 | |
| 59 | + * @var callable|null | |
| 60 | + */ | |
| 61 | + private $description_resolver = null; | |
| 62 | + | |
| 63 | + /** | |
| 64 | + * Supply the request's resolved description. | |
| 65 | + * | |
| 66 | + * @since 2.10.0 | |
| 67 | + * | |
| 68 | + * @param callable $resolver Returns string|null. | |
| 69 | + * @return void | |
| 70 | + */ | |
| 71 | + public function set_description_resolver(callable $resolver): void { | |
| 72 | + $this->description_resolver = $resolver; | |
| 73 | + } | |
| 74 | + | |
| 75 | + /** | |
| 50 | 76 | * Initialize the schema output |
| 51 | 77 | * |
| 52 | 78 | * @since 1.0.0 |
| 53 | 79 | */ |
| @@ -104,8 +130,22 @@ | ||
| 104 | 130 | } |
| 105 | 131 | |
| 106 | 132 | $settings = $this->get_global_seo_settings($post_type); |
| 107 | 133 | if (($settings['schema_type'] ?? '') === 'Product') { |
| 134 | + // ...but only if this class is actually going to emit it. The | |
| 135 | + // per-content-type Schema switch (#660) makes | |
| 136 | + // output_global_seo_schema() return before it builds anything, so | |
| 137 | + // claiming the entity here as well left the page with NO product | |
| 138 | + // structured data at all — strictly worse than the duplicate this | |
| 139 | + // method exists to prevent, and the opposite of what the docblock | |
| 140 | + // above promises for "ThinkRank's product schema disabled". | |
| 141 | + if (!\ThinkRank\SEO\Content_Type_Settings::is_enabled_for_current( | |
| 142 | + \ThinkRank\SEO\Content_Type_Settings::FEATURE_SCHEMA, | |
| 143 | + true | |
| 144 | + )) { | |
| 145 | + return $markup; | |
| 146 | + } | |
| 147 | + | |
| 108 | 148 | return []; |
| 109 | 149 | } |
| 110 | 150 | |
| 111 | 151 | // A per-post DEPLOYED Product schema duplicates WooCommerce's markup |
| @@ -153,8 +193,17 @@ | ||
| 153 | 193 | * @since 1.0.0 |
| 154 | 194 | * @return void |
| 155 | 195 | */ |
| 156 | 196 | public function output_global_seo_schema(): void { |
| 197 | + // Per-content-type schema switch. 'inherit' (the default) keeps schema | |
| 198 | + // on, exactly as before the matrix existed (#660). | |
| 199 | + if (!\ThinkRank\SEO\Content_Type_Settings::is_enabled_for_current( | |
| 200 | + \ThinkRank\SEO\Content_Type_Settings::FEATURE_SCHEMA, | |
| 201 | + true | |
| 202 | + )) { | |
| 203 | + return; | |
| 204 | + } | |
| 205 | + | |
| 157 | 206 | // Archives get a CollectionPage schema instead of the per-post-type one |
| 158 | 207 | if (!is_singular()) { |
| 159 | 208 | $this->output_archive_schema(); |
| 160 | 209 | return; |
| @@ -231,9 +280,22 @@ | ||
| 231 | 280 | $name = $posts_page_id ? get_the_title($posts_page_id) : __('Blog', 'thinkrank'); |
| 232 | 281 | $url = $posts_page_id ? (string) get_permalink($posts_page_id) : home_url('/'); |
| 233 | 282 | } elseif (is_post_type_archive()) { |
| 234 | 283 | $post_type_object = get_queried_object(); |
| 284 | + | |
| 285 | + // WooCommerce maps the shop archive onto a real page, so | |
| 286 | + // get_queried_object() returns that WP_Post while | |
| 287 | + // is_post_type_archive() is still true. Bailing here left every | |
| 288 | + // store's main archive with no CollectionPage (#466). Fall back to | |
| 289 | + // the query var, exactly as the canonical resolver already does. | |
| 235 | 290 | if (!$post_type_object instanceof \WP_Post_Type) { |
| 291 | + $queried_post_type = (string) get_query_var('post_type'); | |
| 292 | + $post_type_object = $queried_post_type | |
| 293 | + ? get_post_type_object($queried_post_type) | |
| 294 | + : null; | |
| 295 | + } | |
| 296 | + | |
| 297 | + if (!$post_type_object instanceof \WP_Post_Type) { | |
| 236 | 298 | return; |
| 237 | 299 | } |
| 238 | 300 | $name = $post_type_object->labels->name ?? $post_type_object->label; |
| 239 | 301 | $url = (string) get_post_type_archive_link($post_type_object->name); |
| @@ -278,10 +340,12 @@ | ||
| 278 | 340 | 'url' => home_url('/'), |
| 279 | 341 | ], |
| 280 | 342 | ]; |
| 281 | 343 | |
| 282 | - $description = trim(wp_strip_all_tags($description)); | |
| 283 | - if (!empty($description)) { | |
| 344 | + // Normalised like every other description: an entity or a trailing | |
| 345 | + // excerpt marker is as wrong in a CollectionPage as anywhere (#766). | |
| 346 | + $description = self::normalize_description((string) $description); | |
| 347 | + if ('' !== $description) { | |
| 284 | 348 | $schema['description'] = $description; |
| 285 | 349 | } |
| 286 | 350 | |
| 287 | 351 | /** |
| @@ -314,8 +378,16 @@ | ||
| 314 | 378 | * @param string $post_type Post type slug. |
| 315 | 379 | * @return bool True when structured data would be output for this post type. |
| 316 | 380 | */ |
| 317 | 381 | public function would_output_schema(string $post_type): bool { |
| 382 | + if (!\ThinkRank\SEO\Content_Type_Settings::is_enabled( | |
| 383 | + \ThinkRank\SEO\Content_Type_Settings::FEATURE_SCHEMA, | |
| 384 | + $post_type, | |
| 385 | + true | |
| 386 | + )) { | |
| 387 | + return false; | |
| 388 | + } | |
| 389 | + | |
| 318 | 390 | $settings = $this->get_global_seo_settings($post_type); |
| 319 | 391 | |
| 320 | 392 | return !empty($settings['schema_type']); |
| 321 | 393 | } |
| @@ -320,8 +392,27 @@ | ||
| 320 | 392 | return !empty($settings['schema_type']); |
| 321 | 393 | } |
| 322 | 394 | |
| 323 | 395 | /** |
| 396 | + * Whether this post type has a SAVED schema type, ignoring the built-in | |
| 397 | + * per-post-type default. | |
| 398 | + * | |
| 399 | + * would_output_schema() answers "will JSON-LD be emitted?", which the | |
| 400 | + * fallback in get_global_seo_settings() makes true for every public post | |
| 401 | + * type. The audit needs the different question "has the user configured | |
| 402 | + * anything?", so this reads the stored option without the default merge. | |
| 403 | + * | |
| 404 | + * @since 2.2.0 | |
| 405 | + * @param string $post_type Post type. | |
| 406 | + * @return bool True when an explicit schema_type is stored for this type. | |
| 407 | + */ | |
| 408 | + public function has_explicit_schema_type(string $post_type): bool { | |
| 409 | + $all_settings = get_option(self::OPTION_NAME, []); | |
| 410 | + | |
| 411 | + return !empty($all_settings[$post_type]['schema_type']); | |
| 412 | + } | |
| 413 | + | |
| 414 | + /** | |
| 324 | 415 | * Get Global SEO settings for a specific post type |
| 325 | 416 | * |
| 326 | 417 | * @since 1.0.0 |
| 327 | 418 | * @param string $post_type Post type |
| @@ -460,12 +551,15 @@ | ||
| 460 | 551 | 'datePublished' => get_the_date('c', $post), |
| 461 | 552 | 'dateModified' => get_the_modified_date('c', $post), |
| 462 | 553 | ]; |
| 463 | 554 | |
| 464 | - // Add description | |
| 465 | - $excerpt = get_the_excerpt($post); | |
| 466 | - if (!empty($excerpt)) { | |
| 467 | - $schema['description'] = wp_strip_all_tags($excerpt); | |
| 555 | + // Add description. Prefers the request's resolved description so the | |
| 556 | + // article describes itself the same way in JSON-LD as in the head | |
| 557 | + // (#766); a Bricks page's stored `post_content` is not on the page, so | |
| 558 | + // the excerpt fallback must not describe it either (#651). | |
| 559 | + $description = $this->schema_description($post); | |
| 560 | + if ('' !== $description) { | |
| 561 | + $schema['description'] = $description; | |
| 468 | 562 | } |
| 469 | 563 | |
| 470 | 564 | // Add author |
| 471 | 565 | $author_id = $post->post_author; |
| @@ -534,9 +628,9 @@ | ||
| 534 | 628 | } |
| 535 | 629 | require_once $builder_file; |
| 536 | 630 | } |
| 537 | 631 | |
| 538 | - $excerpt = get_the_excerpt($post); | |
| 632 | + $excerpt = $this->post_excerpt_text($post); | |
| 539 | 633 | |
| 540 | 634 | $builder = new \ThinkRank\SEO\Schema_Builder(); |
| 541 | 635 | $schema = $builder->build_schema( |
| 542 | 636 | 'FAQPage', |
| @@ -541,9 +635,9 @@ | ||
| 541 | 635 | $schema = $builder->build_schema( |
| 542 | 636 | 'FAQPage', |
| 543 | 637 | [ |
| 544 | 638 | 'title' => get_the_title($post), |
| 545 | - 'content' => $post->post_content, | |
| 639 | + 'content' => \ThinkRank\SEO\Builder_Content::visible_content($post), | |
| 546 | 640 | 'excerpt' => $excerpt ? wp_strip_all_tags($excerpt) : '', |
| 547 | 641 | 'url' => get_permalink($post), |
| 548 | 642 | ], |
| 549 | 643 | get_post_type($post) === 'page' ? 'page' : 'post' |
| @@ -613,12 +707,13 @@ | ||
| 613 | 707 | 'datePublished' => get_the_date('c', $post), |
| 614 | 708 | 'dateModified' => get_the_modified_date('c', $post), |
| 615 | 709 | ]; |
| 616 | 710 | |
| 617 | - // Add description | |
| 618 | - $excerpt = get_the_excerpt($post); | |
| 619 | - if (!empty($excerpt)) { | |
| 620 | - $schema['description'] = wp_strip_all_tags($excerpt); | |
| 711 | + // Add description, preferring the one already resolved for this | |
| 712 | + // request over core's auto excerpt (#766). | |
| 713 | + $description = $this->schema_description($post); | |
| 714 | + if ('' !== $description) { | |
| 715 | + $schema['description'] = $description; | |
| 621 | 716 | } |
| 622 | 717 | |
| 623 | 718 | // Add featured image if available |
| 624 | 719 | if (has_post_thumbnail($post)) { |
| @@ -653,9 +748,9 @@ | ||
| 653 | 748 | // Add caption/description |
| 654 | 749 | $caption = wp_get_attachment_caption($post->ID); |
| 655 | 750 | if (!empty($caption)) { |
| 656 | 751 | $schema['caption'] = $caption; |
| 657 | - $schema['description'] = $caption; | |
| 752 | + $schema['description'] = self::normalize_description((string) $caption); | |
| 658 | 753 | } |
| 659 | 754 | |
| 660 | 755 | // Add dimensions |
| 661 | 756 | if (!empty($image_meta['width']) && !empty($image_meta['height'])) { |
| @@ -683,18 +778,18 @@ | ||
| 683 | 778 | 'name' => get_the_title($post), |
| 684 | 779 | 'url' => get_permalink($post), |
| 685 | 780 | ]; |
| 686 | 781 | |
| 687 | - // Add description | |
| 688 | - $description = get_the_excerpt($post); | |
| 689 | - if (empty($description)) { | |
| 690 | - $caption = wp_get_attachment_caption($post->ID); | |
| 691 | - if (!empty($caption)) { | |
| 692 | - $description = $caption; | |
| 693 | - } | |
| 782 | + // Add description. Same resolution as the page-level types (#766); an | |
| 783 | + // attachment's caption remains the last resort. | |
| 784 | + $description = $this->schema_description($post); | |
| 785 | + if ('' === $description) { | |
| 786 | + $description = self::normalize_description( | |
| 787 | + (string) wp_get_attachment_caption($post->ID) | |
| 788 | + ); | |
| 694 | 789 | } |
| 695 | - if (!empty($description)) { | |
| 696 | - $schema['description'] = wp_strip_all_tags($description); | |
| 790 | + if ('' !== $description) { | |
| 791 | + $schema['description'] = $description; | |
| 697 | 792 | } |
| 698 | 793 | |
| 699 | 794 | // For video attachments, add contentUrl |
| 700 | 795 | if ($post->post_type === 'attachment') { |
| @@ -850,18 +945,31 @@ | ||
| 850 | 945 | private function get_product_description(\WP_Post $post): string { |
| 851 | 946 | // Try custom meta field first |
| 852 | 947 | $description = get_post_meta($post->ID, '_thinkrank_product_description', true); |
| 853 | 948 | |
| 854 | - // Fallback to excerpt or content | |
| 949 | + // Then the description resolved for this request, so a product with a | |
| 950 | + // hand-written meta description does not describe itself differently | |
| 951 | + // in its Product node than in the head (#766). schema_description() | |
| 952 | + // falls through to the excerpt on its own, and on a Bricks page that | |
| 953 | + // excerpt comes from the visible body rather than the discarded | |
| 954 | + // `post_content` (#651). | |
| 855 | 955 | if (empty($description)) { |
| 856 | - $description = get_the_excerpt($post); | |
| 956 | + $description = $this->schema_description($post); | |
| 857 | 957 | } |
| 858 | 958 | |
| 859 | 959 | if (empty($description)) { |
| 860 | - $description = \ThinkRank\SEO\Pattern_Resolver::derive_excerpt((string) $post->post_content, 30); | |
| 960 | + $description = \ThinkRank\SEO\Pattern_Resolver::derive_excerpt( | |
| 961 | + \ThinkRank\SEO\Builder_Content::visible_content($post), | |
| 962 | + 30 | |
| 963 | + ); | |
| 861 | 964 | } |
| 862 | 965 | |
| 863 | - return wp_strip_all_tags($description); | |
| 966 | + // Normalised like every other description rather than merely stripped. | |
| 967 | + // `_thinkrank_product_description` is the branch a product author is | |
| 968 | + // most likely to be using, and it reached the Product node verbatim: | |
| 969 | + // an `&` stayed an entity and a trailing `[…]` stayed a marker, | |
| 970 | + // which is the bug this was supposed to fix (#766). | |
| 971 | + return self::normalize_description((string) $description); | |
| 864 | 972 | } |
| 865 | 973 | |
| 866 | 974 | /** |
| 867 | 975 | * Get product image |
| @@ -878,15 +986,27 @@ | ||
| 878 | 986 | |
| 879 | 987 | if ($image_url) { |
| 880 | 988 | $image_meta = wp_get_attachment_metadata($image_id); |
| 881 | 989 | |
| 882 | - return [ | |
| 990 | + // SVGs, offloaded media and failed metadata regeneration all | |
| 991 | + // report no dimensions. Omit the keys entirely — a literal JSON | |
| 992 | + // null is an invalid value that Google flags, which is what the | |
| 993 | + // previous `: null` fallback emitted (#471). Matches | |
| 994 | + // Schema_Builder::format_image_schema(). | |
| 995 | + $image_object = [ | |
| 883 | 996 | '@type' => 'ImageObject', |
| 884 | 997 | 'url' => $image_url, |
| 885 | - // SVGs report 0x0 — send null rather than a zero dimension. | |
| 886 | - 'width' => !empty($image_meta['width']) ? (int) $image_meta['width'] : null, | |
| 887 | - 'height' => !empty($image_meta['height']) ? (int) $image_meta['height'] : null, | |
| 888 | 998 | ]; |
| 999 | + | |
| 1000 | + if (!empty($image_meta['width'])) { | |
| 1001 | + $image_object['width'] = (int) $image_meta['width']; | |
| 1002 | + } | |
| 1003 | + | |
| 1004 | + if (!empty($image_meta['height'])) { | |
| 1005 | + $image_object['height'] = (int) $image_meta['height']; | |
| 1006 | + } | |
| 1007 | + | |
| 1008 | + return $image_object; | |
| 889 | 1009 | } |
| 890 | 1010 | } |
| 891 | 1011 | |
| 892 | 1012 | // Try custom meta field |
| @@ -1110,8 +1230,150 @@ | ||
| 1110 | 1230 | } |
| 1111 | 1231 | } |
| 1112 | 1232 | |
| 1113 | 1233 | return $reviews; |
| 1234 | + } | |
| 1235 | + | |
| 1236 | + /** | |
| 1237 | + * The post's excerpt, taken from content the page actually renders. | |
| 1238 | + * | |
| 1239 | + * `get_the_excerpt()` falls back to trimming `post_content`, which a Bricks | |
| 1240 | + * page discards — so on one of those it describes text no visitor sees. A | |
| 1241 | + * hand-written excerpt is the author's own summary and still wins, because | |
| 1242 | + * `superseding_excerpt_source()` yields nothing for a post that has one | |
| 1243 | + * (#651). | |
| 1244 | + * | |
| 1245 | + * @since 2.3.1 | |
| 1246 | + * @param \WP_Post $post Post being described. | |
| 1247 | + * @return string | |
| 1248 | + */ | |
| 1249 | + private function post_excerpt_text(\WP_Post $post): string { | |
| 1250 | + $superseding = \ThinkRank\SEO\Builder_Content::superseding_excerpt_source($post); | |
| 1251 | + | |
| 1252 | + return '' !== $superseding | |
| 1253 | + ? \ThinkRank\SEO\Pattern_Resolver::derive_excerpt($superseding, 30) | |
| 1254 | + : (string) get_the_excerpt($post); | |
| 1255 | + } | |
| 1256 | + | |
| 1257 | + /** | |
| 1258 | + * The description a schema node should carry for a post. | |
| 1259 | + * | |
| 1260 | + * Prefers the description ThinkRank already resolved for this request — | |
| 1261 | + * the same value behind `<meta name="description">`, og:description and | |
| 1262 | + * twitter:description, with the author's own meta at the top of its | |
| 1263 | + * fallback chain. Schema used the auto excerpt instead, so a page with a | |
| 1264 | + * hand-written description described itself one way to crawlers reading | |
| 1265 | + * the head and another way to answer engines reading the JSON-LD (#766). | |
| 1266 | + * | |
| 1267 | + * The resolver is only consulted for the post the request is actually | |
| 1268 | + * about. A node describing some other post (a related item, a listing | |
| 1269 | + * entry) must not inherit this page's description, so those keep deriving | |
| 1270 | + * their own excerpt. | |
| 1271 | + * | |
| 1272 | + * @since 2.10.0 | |
| 1273 | + * | |
| 1274 | + * @param \WP_Post $post Post being described. | |
| 1275 | + * @return string Description, or '' when nothing resolves. | |
| 1276 | + */ | |
| 1277 | + private function schema_description(\WP_Post $post): string { | |
| 1278 | + if (is_callable($this->description_resolver) && $this->describes_queried_object($post)) { | |
| 1279 | + $resolved = (string) call_user_func($this->description_resolver); | |
| 1280 | + | |
| 1281 | + if ('' !== trim($resolved)) { | |
| 1282 | + return self::normalize_description($resolved); | |
| 1283 | + } | |
| 1284 | + } | |
| 1285 | + | |
| 1286 | + return self::normalize_description($this->post_excerpt_text($post)); | |
| 1287 | + } | |
| 1288 | + | |
| 1289 | + /** | |
| 1290 | + * Whether this post is the one the current request is about. | |
| 1291 | + * | |
| 1292 | + * @since 2.10.0 | |
| 1293 | + * | |
| 1294 | + * @param \WP_Post $post Post being described. | |
| 1295 | + * @return bool | |
| 1296 | + */ | |
| 1297 | + private function describes_queried_object(\WP_Post $post): bool { | |
| 1298 | + if (!function_exists('is_singular') || !is_singular()) { | |
| 1299 | + return false; | |
| 1300 | + } | |
| 1301 | + | |
| 1302 | + return (int) $post->ID === (int) get_queried_object_id(); | |
| 1303 | + } | |
| 1304 | + | |
| 1305 | + /** | |
| 1306 | + * Make a description fit to appear in JSON-LD. | |
| 1307 | + * | |
| 1308 | + * Delegates to Seo_Text so the Schema Manager's builder, whose deployed | |
| 1309 | + * nodes outrank this class's, normalises exactly the same way (#766). | |
| 1310 | + * | |
| 1311 | + * @since 2.10.0 | |
| 1312 | + * | |
| 1313 | + * @param string $description Raw description. | |
| 1314 | + * @return string | |
| 1315 | + */ | |
| 1316 | + private static function normalize_description(string $description): string { | |
| 1317 | + return \ThinkRank\Core\Seo_Text::normalize_schema_text($description); | |
| 1318 | + } | |
| 1319 | + | |
| 1320 | + /** | |
| 1321 | + * Types a deployed node describes with the post's own description. | |
| 1322 | + * | |
| 1323 | + * Schema_Builder fills `description` for these from the post excerpt or | |
| 1324 | + * content and nothing else; the Schema Manager form has no description | |
| 1325 | + * field for them. Types with such a field (Product, Event, HowTo, | |
| 1326 | + * SoftwareApplication, VideoObject, Person) are absent on purpose: what the | |
| 1327 | + * author typed there is theirs, not a stale copy of the page summary. | |
| 1328 | + * | |
| 1329 | + * @since 2.10.0 | |
| 1330 | + * @var string[] | |
| 1331 | + */ | |
| 1332 | + private const POST_DESCRIBED_TYPES = [ | |
| 1333 | + 'WebPage', 'AboutPage', 'ContactPage', 'ProfilePage', | |
| 1334 | + 'Article', 'BlogPosting', 'NewsArticle', 'TechnicalArticle', 'ScholarlyArticle', 'Report', | |
| 1335 | + ]; | |
| 1336 | + | |
| 1337 | + /** | |
| 1338 | + * Give a deployed node the description the automatic node would carry. | |
| 1339 | + * | |
| 1340 | + * A node deployed through the Schema Manager is a snapshot taken when the | |
| 1341 | + * author pressed Deploy, and it outranks the node this class builds. So a | |
| 1342 | + * page that deployed AboutPage, ContactPage or ProfilePage (#624), or an | |
| 1343 | + * Article, published a frozen excerpt instead of the description the head | |
| 1344 | + * resolves, and kept it after the meta description was edited. Replacing | |
| 1345 | + * it here, at output, makes the two nodes agree and fixes existing | |
| 1346 | + * deployments without a redeploy. | |
| 1347 | + * | |
| 1348 | + * Leaves the node alone when it is not about the queried post, or when | |
| 1349 | + * nothing resolves, so the stored value still stands in that case. | |
| 1350 | + * | |
| 1351 | + * @since 2.10.0 | |
| 1352 | + * | |
| 1353 | + * @param array $node Deployed schema node. | |
| 1354 | + * @param string $schema_type Deployed schema type. | |
| 1355 | + * @param \WP_Post $post Post the node was deployed on. | |
| 1356 | + * @return array | |
| 1357 | + */ | |
| 1358 | + public function refresh_deployed_description(array $node, string $schema_type, \WP_Post $post): array { | |
| 1359 | + $type = '' !== $schema_type ? $schema_type : (string) ($node['@type'] ?? ''); | |
| 1360 | + | |
| 1361 | + if (!in_array($type, self::POST_DESCRIBED_TYPES, true)) { | |
| 1362 | + return $node; | |
| 1363 | + } | |
| 1364 | + | |
| 1365 | + if (!is_callable($this->description_resolver) || !$this->describes_queried_object($post)) { | |
| 1366 | + return $node; | |
| 1367 | + } | |
| 1368 | + | |
| 1369 | + $resolved = self::normalize_description((string) call_user_func($this->description_resolver)); | |
| 1370 | + | |
| 1371 | + if ('' !== $resolved) { | |
| 1372 | + $node['description'] = $resolved; | |
| 1373 | + } | |
| 1374 | + | |
| 1375 | + return $node; | |
| 1114 | 1376 | } |
| 1115 | 1377 | |
| 1116 | 1378 | /** |
| 1117 | 1379 | * Register generated schema with the request's schema graph. |