← All changes
|
includes/frontend/class-global-seo-schema-output.php
+186
-26
2.7.0
→
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 | */ |
| @@ -314,10 +340,12 @@ | ||
| 314 | 340 | 'url' => home_url('/'), |
| 315 | 341 | ], |
| 316 | 342 | ]; |
| 317 | 343 | |
| 318 | - $description = trim(wp_strip_all_tags($description)); | |
| 319 | - 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) { | |
| 320 | 348 | $schema['description'] = $description; |
| 321 | 349 | } |
| 322 | 350 | |
| 323 | 351 | /** |
| @@ -523,13 +551,15 @@ | ||
| 523 | 551 | 'datePublished' => get_the_date('c', $post), |
| 524 | 552 | 'dateModified' => get_the_modified_date('c', $post), |
| 525 | 553 | ]; |
| 526 | 554 | |
| 527 | - // Add description. A Bricks page's stored `post_content` is not on the | |
| 528 | - // page, so core's derived excerpt must not describe it (#651). | |
| 529 | - $excerpt = $this->post_excerpt_text($post); | |
| 530 | - if (!empty($excerpt)) { | |
| 531 | - $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; | |
| 532 | 562 | } |
| 533 | 563 | |
| 534 | 564 | // Add author |
| 535 | 565 | $author_id = $post->post_author; |
| @@ -677,12 +707,13 @@ | ||
| 677 | 707 | 'datePublished' => get_the_date('c', $post), |
| 678 | 708 | 'dateModified' => get_the_modified_date('c', $post), |
| 679 | 709 | ]; |
| 680 | 710 | |
| 681 | - // Add description | |
| 682 | - $excerpt = $this->post_excerpt_text($post); | |
| 683 | - if (!empty($excerpt)) { | |
| 684 | - $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; | |
| 685 | 716 | } |
| 686 | 717 | |
| 687 | 718 | // Add featured image if available |
| 688 | 719 | if (has_post_thumbnail($post)) { |
| @@ -717,9 +748,9 @@ | ||
| 717 | 748 | // Add caption/description |
| 718 | 749 | $caption = wp_get_attachment_caption($post->ID); |
| 719 | 750 | if (!empty($caption)) { |
| 720 | 751 | $schema['caption'] = $caption; |
| 721 | - $schema['description'] = $caption; | |
| 752 | + $schema['description'] = self::normalize_description((string) $caption); | |
| 722 | 753 | } |
| 723 | 754 | |
| 724 | 755 | // Add dimensions |
| 725 | 756 | if (!empty($image_meta['width']) && !empty($image_meta['height'])) { |
| @@ -747,18 +778,18 @@ | ||
| 747 | 778 | 'name' => get_the_title($post), |
| 748 | 779 | 'url' => get_permalink($post), |
| 749 | 780 | ]; |
| 750 | 781 | |
| 751 | - // Add description | |
| 752 | - $description = $this->post_excerpt_text($post); | |
| 753 | - if (empty($description)) { | |
| 754 | - $caption = wp_get_attachment_caption($post->ID); | |
| 755 | - if (!empty($caption)) { | |
| 756 | - $description = $caption; | |
| 757 | - } | |
| 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 | + ); | |
| 758 | 789 | } |
| 759 | - if (!empty($description)) { | |
| 760 | - $schema['description'] = wp_strip_all_tags($description); | |
| 790 | + if ('' !== $description) { | |
| 791 | + $schema['description'] = $description; | |
| 761 | 792 | } |
| 762 | 793 | |
| 763 | 794 | // For video attachments, add contentUrl |
| 764 | 795 | if ($post->post_type === 'attachment') { |
| @@ -914,13 +945,16 @@ | ||
| 914 | 945 | private function get_product_description(\WP_Post $post): string { |
| 915 | 946 | // Try custom meta field first |
| 916 | 947 | $description = get_post_meta($post->ID, '_thinkrank_product_description', true); |
| 917 | 948 | |
| 918 | - // Fallback to excerpt or content. On a Bricks page the excerpt core | |
| 919 | - // derives comes from discarded `post_content`, so the visible body is | |
| 920 | - // used instead (#651). | |
| 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). | |
| 921 | 955 | if (empty($description)) { |
| 922 | - $description = $this->post_excerpt_text($post); | |
| 956 | + $description = $this->schema_description($post); | |
| 923 | 957 | } |
| 924 | 958 | |
| 925 | 959 | if (empty($description)) { |
| 926 | 960 | $description = \ThinkRank\SEO\Pattern_Resolver::derive_excerpt( |
| @@ -928,9 +962,14 @@ | ||
| 928 | 962 | 30 |
| 929 | 963 | ); |
| 930 | 964 | } |
| 931 | 965 | |
| 932 | - 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); | |
| 933 | 972 | } |
| 934 | 973 | |
| 935 | 974 | /** |
| 936 | 975 | * Get product image |
| @@ -1212,8 +1251,129 @@ | ||
| 1212 | 1251 | |
| 1213 | 1252 | return '' !== $superseding |
| 1214 | 1253 | ? \ThinkRank\SEO\Pattern_Resolver::derive_excerpt($superseding, 30) |
| 1215 | 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; | |
| 1216 | 1376 | } |
| 1217 | 1377 | |
| 1218 | 1378 | /** |
| 1219 | 1379 | * Register generated schema with the request's schema graph. |