PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / 2.10.0
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO v2.10.0
2.10.0 2.9.0 2.8.0 2.7.0 2.6.0 2.5.0 2.4.0 2.3.0 2.2.0 2.1.1 2.1.0 2.0.2 2.0.1 2.0.0 1.32.0 1.31.0 1.30.0 1.29.0 1.28.0 1.27.0 1.26.0 1.25.0 trunk 1.0.0 1.0.1 All 51 releases
thinkrank / includes / frontend / class-global-seo-schema-output.php

class-global-seo-schema-output.php in ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO 2.10.0, at includes/frontend/class-global-seo-schema-output.php

1,403 lines 49.9 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Global SEO Schema Output Class
4 *
5 * Handles JSON-LD schema markup output based on Global SEO settings for different post types.
6 * Generates appropriate schema markup according to the schema_type setting configured in
7 * the Global SEO options for each post type.
8 *
9 * @package ThinkRank\Frontend
10 * @subpackage SEO
11 * @since 1.0.0
12 */
13
14 declare(strict_types=1);
15
16 namespace ThinkRank\Frontend;
17
18 // Prevent direct access
19 if (!defined('ABSPATH')) {
20 exit;
21 }
22
23 /**
24 * Global SEO Schema Output Class
25 *
26 * Generates and outputs JSON-LD schema markup based on Global SEO settings.
27 * Supports various schema types including WebPage, Article, BlogPosting, etc.
28 *
29 * @since 1.0.0
30 */
31 class Global_SEO_Schema_Output {
32
33 /**
34 * WordPress option name for storing global SEO settings
35 *
36 * @since 1.0.0
37 * @var string
38 */
39 private const OPTION_NAME = 'thinkrank_global_seo_settings';
40
41 /**
42 * Schema context URL
43 *
44 * @since 1.0.0
45 * @var string
46 */
47 private const SCHEMA_CONTEXT = 'https://schema.org';
48
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 /**
76 * Initialize the schema output
77 *
78 * @since 1.0.0
79 */
80 public function init(): void {
81 // Hook into wp_head to output schema markup
82 add_action('wp_head', [$this, 'output_global_seo_schema'], 15);
83
84 // One Product entity per product page: when ThinkRank emits the
85 // Product schema (the default for WooCommerce products), WooCommerce
86 // core's own JSON-LD must stand down, or the page carries two
87 // aggregateRating blocks and Search Console raises the critical
88 // "Review has multiple aggregate ratings" error. Registered eagerly
89 // and decided lazily inside the callback, because WooCommerce
90 // generates its data during the product template render — which on
91 // block themes can run before wp_head, too early for a flag set at
92 // output time to exist yet.
93 add_filter('woocommerce_structured_data_product', [$this, 'suppress_woocommerce_product_schema'], 20, 2);
94 }
95
96 /**
97 * Yield WooCommerce's Product structured data when ThinkRank emits the
98 * Product entity for the page being viewed.
99 *
100 * Mirrors what other SEO plugins do with WC_Structured_Data: exactly one
101 * plugin may describe the product. Suppression is surgical — only the
102 * queried product on its own singular view, only when this class's
103 * settings resolution says a Product schema will be generated (explicit
104 * or the WooCommerce default), and WooCommerce's breadcrumb and other
105 * structured data are never touched. With ThinkRank's product schema
106 * disabled or set to another type, WooCommerce's markup passes through
107 * unchanged.
108 *
109 * @since 2.0.1
110 * @param array $markup WooCommerce's generated Product markup.
111 * @param mixed $product WC_Product being described.
112 * @return array Original markup, or empty to suppress.
113 */
114 public function suppress_woocommerce_product_schema($markup, $product = null) {
115 if (!is_array($markup) || !is_singular()) {
116 return $markup;
117 }
118
119 // Only the main product of this page — a card grid or related-products
120 // widget describing other products is not ours to silence.
121 $queried_id = (int) get_queried_object_id();
122 $product_id = is_object($product) && method_exists($product, 'get_id') ? (int) $product->get_id() : 0;
123 if (!$queried_id || !$product_id || $queried_id !== $product_id) {
124 return $markup;
125 }
126
127 $post_type = (string) get_post_type($queried_id);
128 if ($post_type === '') {
129 return $markup;
130 }
131
132 $settings = $this->get_global_seo_settings($post_type);
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
148 return [];
149 }
150
151 // A per-post DEPLOYED Product schema duplicates WooCommerce's markup
152 // just the same, even when the post-type-wide setting points elsewhere.
153 // Checked second because the default path above answers without a
154 // query; this one is a single indexed lookup and only runs on the
155 // rare configured-away sites.
156 if ($this->post_has_deployed_product_schema($queried_id)) {
157 return [];
158 }
159
160 return $markup;
161 }
162
163 /**
164 * Whether an active per-post Product schema deployment exists for a post.
165 *
166 * Reads the deployment table directly rather than constructing
167 * Schema_Management_System — this runs inside WooCommerce's structured
168 * data filter on product pages, where spinning up the full manager (and
169 * its builder) to answer a yes/no question would be waste. Query shape
170 * matches get_deployed_schemas(): active rows for the post context.
171 *
172 * @since 2.0.1
173 * @param int $post_id Post to check.
174 * @return bool
175 */
176 private function post_has_deployed_product_schema(int $post_id): bool {
177 global $wpdb;
178
179 $table = $wpdb->prefix . 'thinkrank_seo_schema';
180
181 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- one indexed EXISTS-style lookup on the render path; the deployment cache layer belongs to the full manager this deliberately avoids constructing.
182 $found = $wpdb->get_var($wpdb->prepare(
183 "SELECT 1 FROM {$table} WHERE context_type = 'post' AND context_id = %d AND schema_type = 'Product' AND is_active = 1 LIMIT 1", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- table name from $wpdb->prefix.
184 $post_id
185 ));
186
187 return '1' === (string) $found;
188 }
189
190 /**
191 * Output JSON-LD schema markup based on Global SEO settings
192 *
193 * @since 1.0.0
194 * @return void
195 */
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
206 // Archives get a CollectionPage schema instead of the per-post-type one
207 if (!is_singular()) {
208 $this->output_archive_schema();
209 return;
210 }
211
212 $post = get_post();
213 if (!$post) {
214 return;
215 }
216
217 $post_type = get_post_type($post);
218 if (!$post_type) {
219 return;
220 }
221
222 // Get Global SEO settings for this post type
223 $settings = $this->get_global_seo_settings($post_type);
224 if (empty($settings) || empty($settings['schema_type'])) {
225 return;
226 }
227
228 $schema_type = $settings['schema_type'];
229 $article_type = $settings['article_type'] ?? '';
230 $media_type = $settings['media_type'] ?? '';
231
232 // Generate schema markup
233 $schema = $this->generate_schema($schema_type, $article_type, $media_type, $post);
234
235 if (empty($schema)) {
236 return;
237 }
238
239 /**
240 * Filter the generated schema graph before output.
241 *
242 * Lets add-ons (e.g. ThinkRank Pro's WooCommerce module) enrich the
243 * schema — adding GTIN/MPN, variation offers, brand, etc. — without
244 * forking this class.
245 *
246 * @since 1.14.0
247 *
248 * @param array $schema The schema array.
249 * @param string $schema_type The configured schema type.
250 * @param \WP_Post $post The current post.
251 */
252 $schema = apply_filters('thinkrank_schema_output', $schema, $schema_type, $post);
253
254 if (empty($schema)) {
255 return;
256 }
257
258 // Register as a candidate for the page's single page-level entity. The
259 // Schema Manager's per-post deployment outranks this post-type-wide
260 // default when both describe the same page (#355).
261 $this->register_schema($schema, $schema_type, 'global_seo');
262 }
263
264 /**
265 * Output CollectionPage schema for archive contexts.
266 *
267 * Covers the blog home, post type archives (e.g. a docs archive) and
268 * taxonomy archives. Search results, 404s and other contexts get nothing.
269 *
270 * @since 1.16.0
271 * @return void
272 */
273 private function output_archive_schema(): void {
274 $name = '';
275 $url = '';
276 $description = '';
277
278 if (is_home() && !is_front_page()) {
279 $posts_page_id = (int) get_option('page_for_posts');
280 $name = $posts_page_id ? get_the_title($posts_page_id) : __('Blog', 'thinkrank');
281 $url = $posts_page_id ? (string) get_permalink($posts_page_id) : home_url('/');
282 } elseif (is_post_type_archive()) {
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.
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) {
298 return;
299 }
300 $name = $post_type_object->labels->name ?? $post_type_object->label;
301 $url = (string) get_post_type_archive_link($post_type_object->name);
302 $description = $post_type_object->description;
303 } elseif (is_category() || is_tag() || is_tax()) {
304 $term = get_queried_object();
305 if (!$term instanceof \WP_Term) {
306 return;
307 }
308 $term_link = get_term_link($term);
309 if (is_wp_error($term_link)) {
310 return;
311 }
312 $name = $term->name;
313 $url = $term_link;
314 $description = (string) term_description($term);
315 } else {
316 return;
317 }
318
319 if (empty($url)) {
320 return;
321 }
322
323 // Page 2 of an archive is a different URL and must be a different node.
324 // The link above is always the un-paginated one, so Schema_Graph::base_url()
325 // minted the identical #collectionpage and #breadcrumb @id on every
326 // page — distinct URLs claiming the same node identity (#397).
327 $url = \ThinkRank\Frontend\SEO_Manager::with_pagination(
328 (string) $url,
329 \ThinkRank\Frontend\SEO_Manager::current_page_number()
330 );
331
332 $schema = [
333 '@context' => self::SCHEMA_CONTEXT,
334 '@type' => 'CollectionPage',
335 'name' => $name,
336 'url' => $url,
337 'isPartOf' => [
338 '@type' => 'WebSite',
339 '@id' => home_url('/#website'),
340 'url' => home_url('/'),
341 ],
342 ];
343
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) {
348 $schema['description'] = $description;
349 }
350
351 /**
352 * Filter the archive CollectionPage schema before output.
353 *
354 * @since 1.16.0
355 *
356 * @param array $schema The schema array ([] suppresses output).
357 */
358 $schema = apply_filters('thinkrank_archive_schema_output', $schema);
359
360 if (empty($schema)) {
361 return;
362 }
363
364 $this->register_schema($schema, 'CollectionPage', 'global_seo');
365 }
366
367 /**
368 * Whether ThinkRank would emit structured data for a given post type.
369 *
370 * Reflects the exact decision `output_global_seo_schema()` makes for
371 * singular views: schema is emitted when a `schema_type` resolves for the
372 * post type — either an explicit saved value or the built-in per-post-type
373 * default. Exposed so the Site SEO Analyzer can ask the output layer
374 * directly instead of re-reading a legacy option, keeping the audit and the
375 * rendered page from ever disagreeing about whether schema is configured.
376 *
377 * @since 1.23.1
378 * @param string $post_type Post type slug.
379 * @return bool True when structured data would be output for this post type.
380 */
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
390 $settings = $this->get_global_seo_settings($post_type);
391
392 return !empty($settings['schema_type']);
393 }
394
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 /**
415 * Get Global SEO settings for a specific post type
416 *
417 * @since 1.0.0
418 * @param string $post_type Post type
419 * @return array Settings array
420 */
421 private function get_global_seo_settings(string $post_type): array {
422 $all_settings = get_option(self::OPTION_NAME, []);
423 $settings = $all_settings[$post_type] ?? [];
424
425 // Fall back to a sensible default schema type when nothing is saved for
426 // this post type, so structured data works out of the box on sites that
427 // never opened the Global SEO settings (e.g. migrated from Rank Math).
428 // An explicit saved schema_type always wins. Mirrors the per-post-type
429 // defaults the REST endpoint (Global_SEO_Endpoint::get_default_settings)
430 // exposes to the admin UI.
431 if (empty($settings['schema_type'])) {
432 $default = $this->get_default_schema_type($post_type);
433 if ($default !== null) {
434 $settings = array_merge($default, $settings);
435 }
436 }
437
438 return $settings;
439 }
440
441 /**
442 * Default schema type (and sub-type) for a post type when unconfigured.
443 *
444 * @since 1.15.x
445 * @param string $post_type Post type slug
446 * @return array|null ['schema_type' => ..., 'article_type' => ..., 'media_type' => ...] or null to emit nothing
447 */
448 private function get_default_schema_type(string $post_type): ?array {
449 switch ($post_type) {
450 case 'post':
451 return ['schema_type' => 'Article', 'article_type' => 'BlogPosting', 'media_type' => ''];
452 case 'page':
453 return ['schema_type' => 'WebPage', 'article_type' => '', 'media_type' => ''];
454 case 'attachment':
455 return ['schema_type' => 'Media', 'article_type' => '', 'media_type' => 'ImageObject'];
456 case 'product':
457 // Only claim Product schema when WooCommerce is actually present,
458 // so a generic CPT named "product" without WooCommerce still gets
459 // WebPage rather than an offers-less Product graph.
460 return class_exists('WooCommerce')
461 ? ['schema_type' => 'Product', 'article_type' => '', 'media_type' => '']
462 : ['schema_type' => 'WebPage', 'article_type' => '', 'media_type' => ''];
463 default:
464 // Public custom post types (e.g. BetterDocs `docs`) get WebPage.
465 $object = get_post_type_object($post_type);
466 if ($object && empty($object->public)) {
467 return null;
468 }
469 return ['schema_type' => 'WebPage', 'article_type' => '', 'media_type' => ''];
470 }
471 }
472
473 /**
474 * Generate schema markup based on schema type
475 *
476 * @since 1.0.0
477 * @param string $schema_type Schema type (e.g., 'Article', 'WebPage', 'Media')
478 * @param string $article_type Article type (e.g., 'BlogPosting', 'NewsArticle')
479 * @param string $media_type Media type (e.g., 'ImageObject', 'VideoObject')
480 * @param \WP_Post $post WordPress post object
481 * @return array Schema markup array
482 */
483 private function generate_schema(string $schema_type, string $article_type, string $media_type, \WP_Post $post): array {
484 // Determine the actual type to use based on schema_type and sub-types
485 $type = $schema_type;
486
487 // Use article_type if schema_type is 'Article' and article_type is specified
488 if ($schema_type === 'Article' && !empty($article_type)) {
489 $type = $article_type;
490 }
491
492 // Use media_type if schema_type is 'Media' and media_type is specified
493 if ($schema_type === 'Media' && !empty($media_type)) {
494 $type = $media_type;
495 }
496
497 // Generate schema based on type
498 switch ($type) {
499 case 'Article':
500 case 'BlogPosting':
501 case 'NewsArticle':
502 case 'ScholarlyArticle':
503 case 'TechArticle':
504 return $this->generate_article_schema($type, $post);
505
506 case 'FAQPage':
507 return $this->generate_faq_schema($post);
508
509 case 'WebPage':
510 case 'AboutPage':
511 case 'ContactPage':
512 case 'ProfilePage':
513 return $this->generate_webpage_schema($type, $post);
514
515 case 'ImageObject':
516 return $this->generate_image_schema($post);
517
518 case 'VideoObject':
519 return $this->generate_video_schema($post);
520
521 case 'Product':
522 return $this->generate_product_schema($post);
523
524 case 'Event':
525 return $this->generate_event_schema($post);
526
527 case 'Media':
528 // Fallback to ImageObject if Media is selected but no media_type specified
529 return $this->generate_image_schema($post);
530
531 default:
532 // Fallback to WebPage for unknown types
533 return $this->generate_webpage_schema('WebPage', $post);
534 }
535 }
536
537 /**
538 * Generate Article schema markup
539 *
540 * @since 1.0.0
541 * @param string $type Article type
542 * @param \WP_Post $post WordPress post object
543 * @return array Schema markup
544 */
545 private function generate_article_schema(string $type, \WP_Post $post): array {
546 $schema = [
547 '@context' => self::SCHEMA_CONTEXT,
548 '@type' => $type,
549 'headline' => get_the_title($post),
550 'url' => get_permalink($post),
551 'datePublished' => get_the_date('c', $post),
552 'dateModified' => get_the_modified_date('c', $post),
553 ];
554
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;
562 }
563
564 // Add author
565 $author_id = $post->post_author;
566 if ($author_id) {
567 $schema['author'] = [
568 '@type' => 'Person',
569 'name' => get_the_author_meta('display_name', $author_id),
570 'url' => get_author_posts_url($author_id),
571 ];
572 }
573
574 // Add publisher (site info)
575 $schema['publisher'] = $this->get_publisher_schema();
576
577 // Add featured image if available
578 if (has_post_thumbnail($post)) {
579 $image_id = get_post_thumbnail_id($post);
580 $image_url = wp_get_attachment_image_url($image_id, 'full');
581 if ($image_url) {
582 $schema['image'] = [
583 '@type' => 'ImageObject',
584 'url' => $image_url,
585 ];
586
587 // Add image dimensions if available
588 $image_meta = wp_get_attachment_metadata($image_id);
589 if (!empty($image_meta['width']) && !empty($image_meta['height'])) {
590 $schema['image']['width'] = $image_meta['width'];
591 $schema['image']['height'] = $image_meta['height'];
592 }
593 }
594 }
595
596 // Add main entity of page
597 $schema['mainEntityOfPage'] = [
598 '@type' => 'WebPage',
599 '@id' => get_permalink($post),
600 ];
601
602 return $schema;
603 }
604
605 /**
606 * Generate FAQPage schema markup
607 *
608 * FAQPage previously fell through to generate_webpage_schema(), which emits a
609 * WebPage-shaped object labelled @type FAQPage with no mainEntity — invalid for
610 * rich results. Delegate to Schema_Builder instead, which owns the FAQ question
611 * extraction already used by the deploy path, rather than growing a second
612 * FAQ implementation here.
613 *
614 * Unlike the deploy path, this runs automatically on every post of the type with
615 * no human reviewing the result, so questions that don't actually read as
616 * questions are dropped and a page with none left falls back to WebPage — an
617 * FAQPage with an empty mainEntity is worse than a valid WebPage.
618 *
619 * @since 1.32.0
620 * @param \WP_Post $post WordPress post object
621 * @return array Schema markup
622 */
623 private function generate_faq_schema(\WP_Post $post): array {
624 if (!class_exists('ThinkRank\\SEO\\Schema_Builder')) {
625 $builder_file = THINKRANK_PLUGIN_DIR . 'includes/seo/class-schema-builder.php';
626 if (!file_exists($builder_file)) {
627 return $this->generate_webpage_schema('WebPage', $post);
628 }
629 require_once $builder_file;
630 }
631
632 $excerpt = $this->post_excerpt_text($post);
633
634 $builder = new \ThinkRank\SEO\Schema_Builder();
635 $schema = $builder->build_schema(
636 'FAQPage',
637 [
638 'title' => get_the_title($post),
639 'content' => \ThinkRank\SEO\Builder_Content::visible_content($post),
640 'excerpt' => $excerpt ? wp_strip_all_tags($excerpt) : '',
641 'url' => get_permalink($post),
642 ],
643 get_post_type($post) === 'page' ? 'page' : 'post'
644 );
645
646 if (!empty($schema['_error'])) {
647 return $this->generate_webpage_schema('WebPage', $post);
648 }
649
650 $schema['mainEntity'] = $this->filter_faq_entities($schema['mainEntity'] ?? []);
651
652 // No usable Q&A pairs — emit a valid WebPage rather than an empty FAQPage.
653 if (empty($schema['mainEntity'])) {
654 return $this->generate_webpage_schema('WebPage', $post);
655 }
656
657 $schema['datePublished'] = get_the_date('c', $post);
658 $schema['dateModified'] = get_the_modified_date('c', $post);
659
660 return $schema;
661 }
662
663 /**
664 * Keep only FAQ entities that genuinely read as a question/answer pair.
665 *
666 * Schema_Builder's content extraction falls back to a heading-followed-by-paragraph
667 * pattern, which on an ordinary page matches every section and would fabricate Q&A
668 * that never appears on the page as such.
669 *
670 * @since 1.32.0
671 * @param array $entities Candidate mainEntity entries
672 * @return array Filtered entries
673 */
674 private function filter_faq_entities(array $entities): array {
675 $filtered = [];
676
677 foreach ($entities as $entity) {
678 $question = isset($entity['name']) ? trim((string) $entity['name']) : '';
679 $answer = isset($entity['acceptedAnswer']['text'])
680 ? trim((string) $entity['acceptedAnswer']['text'])
681 : '';
682
683 if ($question === '' || $answer === '' || strpos($question, '?') === false) {
684 continue;
685 }
686
687 $filtered[] = $entity;
688 }
689
690 return array_values($filtered);
691 }
692
693 /**
694 * Generate WebPage schema markup
695 *
696 * @since 1.0.0
697 * @param string $type WebPage type
698 * @param \WP_Post $post WordPress post object
699 * @return array Schema markup
700 */
701 private function generate_webpage_schema(string $type, \WP_Post $post): array {
702 $schema = [
703 '@context' => self::SCHEMA_CONTEXT,
704 '@type' => $type,
705 'name' => get_the_title($post),
706 'url' => get_permalink($post),
707 'datePublished' => get_the_date('c', $post),
708 'dateModified' => get_the_modified_date('c', $post),
709 ];
710
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;
716 }
717
718 // Add featured image if available
719 if (has_post_thumbnail($post)) {
720 $image_url = get_the_post_thumbnail_url($post, 'full');
721 if ($image_url) {
722 $schema['image'] = $image_url;
723 }
724 }
725
726 return $schema;
727 }
728
729 /**
730 * Generate ImageObject schema markup
731 *
732 * @since 1.0.0
733 * @param \WP_Post $post WordPress post object (attachment)
734 * @return array Schema markup
735 */
736 private function generate_image_schema(\WP_Post $post): array {
737 $image_url = wp_get_attachment_url($post->ID);
738 $image_meta = wp_get_attachment_metadata($post->ID);
739
740 $schema = [
741 '@context' => self::SCHEMA_CONTEXT,
742 '@type' => 'ImageObject',
743 'contentUrl' => $image_url,
744 'url' => get_permalink($post),
745 'name' => get_the_title($post),
746 ];
747
748 // Add caption/description
749 $caption = wp_get_attachment_caption($post->ID);
750 if (!empty($caption)) {
751 $schema['caption'] = $caption;
752 $schema['description'] = self::normalize_description((string) $caption);
753 }
754
755 // Add dimensions
756 if (!empty($image_meta['width']) && !empty($image_meta['height'])) {
757 $schema['width'] = $image_meta['width'];
758 $schema['height'] = $image_meta['height'];
759 }
760
761 // Add upload date
762 $schema['uploadDate'] = get_the_date('c', $post);
763
764 return $schema;
765 }
766
767 /**
768 * Generate VideoObject schema markup
769 *
770 * @since 1.0.0
771 * @param \WP_Post $post WordPress post object (attachment or post with video)
772 * @return array Schema markup
773 */
774 private function generate_video_schema(\WP_Post $post): array {
775 $schema = [
776 '@context' => self::SCHEMA_CONTEXT,
777 '@type' => 'VideoObject',
778 'name' => get_the_title($post),
779 'url' => get_permalink($post),
780 ];
781
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 );
789 }
790 if ('' !== $description) {
791 $schema['description'] = $description;
792 }
793
794 // For video attachments, add contentUrl
795 if ($post->post_type === 'attachment') {
796 $video_url = wp_get_attachment_url($post->ID);
797 if ($video_url) {
798 $schema['contentUrl'] = $video_url;
799 }
800
801 // Add upload date
802 $schema['uploadDate'] = get_the_date('c', $post);
803 }
804
805 // Add thumbnail/poster image if available
806 if (has_post_thumbnail($post)) {
807 $thumbnail_url = get_the_post_thumbnail_url($post, 'full');
808 if ($thumbnail_url) {
809 $schema['thumbnailUrl'] = $thumbnail_url;
810 }
811 }
812
813 // Add duration if available from meta
814 $duration = get_post_meta($post->ID, '_thinkrank_video_duration', true);
815 if (!empty($duration)) {
816 $schema['duration'] = $duration; // Should be in ISO 8601 format (e.g., PT1M30S)
817 }
818
819 // Add embed URL if available from meta
820 $embed_url = get_post_meta($post->ID, '_thinkrank_video_embed_url', true);
821 if (!empty($embed_url)) {
822 $schema['embedUrl'] = $embed_url;
823 }
824
825 return $schema;
826 }
827
828 /**
829 * Generate Product schema markup
830 *
831 * Generates valid Schema.org Product markup with required and recommended properties.
832 * Supports custom meta fields and WooCommerce integration.
833 *
834 * @since 1.0.0
835 * @param \WP_Post $post WordPress post object
836 * @return array Schema markup
837 */
838 private function generate_product_schema(\WP_Post $post): array {
839 // Base Product schema with required properties
840 $schema = [
841 '@context' => self::SCHEMA_CONTEXT,
842 '@type' => 'Product',
843 'name' => get_the_title($post),
844 'url' => get_permalink($post),
845 ];
846
847 // Add description (required for valid Product schema)
848 $description = $this->get_product_description($post);
849 if (!empty($description)) {
850 $schema['description'] = $description;
851 }
852
853 // Add image (required for valid Product schema)
854 $image = $this->get_product_image($post);
855 if (!empty($image)) {
856 $schema['image'] = $image;
857 }
858
859 // Add SKU if available
860 $sku = $this->get_product_sku($post);
861 if (!empty($sku)) {
862 $schema['sku'] = $sku;
863 }
864
865 // Add brand (recommended)
866 $brand = $this->get_product_brand($post);
867 if (!empty($brand)) {
868 $schema['brand'] = [
869 '@type' => 'Brand',
870 'name' => $brand,
871 ];
872 }
873
874 // Add offers (required for valid Product schema)
875 $offers = $this->get_product_offers($post);
876 if (!empty($offers)) {
877 $schema['offers'] = $offers;
878 }
879
880 // Add aggregate rating (recommended)
881 $rating = $this->get_product_rating($post);
882 if (!empty($rating)) {
883 $schema['aggregateRating'] = $rating;
884 }
885
886 // Add reviews (recommended)
887 $reviews = $this->get_product_reviews($post);
888 if (!empty($reviews)) {
889 $schema['review'] = $reviews;
890 }
891
892 return $schema;
893 }
894
895 /**
896 * Generate Event schema markup (placeholder)
897 *
898 * @since 1.0.0
899 * @param \WP_Post $post WordPress post object
900 * @return array Schema markup
901 */
902 private function generate_event_schema(\WP_Post $post): array {
903 // Basic Event schema - can be extended based on requirements
904 return $this->generate_webpage_schema('WebPage', $post);
905 }
906
907 /**
908 * Get publisher schema (Organization or Person)
909 *
910 * @since 1.0.0
911 * @return array Publisher schema
912 */
913 private function get_publisher_schema(): array {
914 $site_name = get_bloginfo('name');
915 $site_url = home_url();
916
917 $publisher = [
918 '@type' => 'Organization',
919 'name' => $site_name,
920 'url' => $site_url,
921 ];
922
923 // Add logo if available
924 $custom_logo_id = get_theme_mod('custom_logo');
925 if ($custom_logo_id) {
926 $logo_url = wp_get_attachment_image_url($custom_logo_id, 'full');
927 if ($logo_url) {
928 $publisher['logo'] = [
929 '@type' => 'ImageObject',
930 'url' => $logo_url,
931 ];
932 }
933 }
934
935 return $publisher;
936 }
937
938 /**
939 * Get product description
940 *
941 * @since 1.0.0
942 * @param \WP_Post $post WordPress post object
943 * @return string Product description
944 */
945 private function get_product_description(\WP_Post $post): string {
946 // Try custom meta field first
947 $description = get_post_meta($post->ID, '_thinkrank_product_description', true);
948
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).
955 if (empty($description)) {
956 $description = $this->schema_description($post);
957 }
958
959 if (empty($description)) {
960 $description = \ThinkRank\SEO\Pattern_Resolver::derive_excerpt(
961 \ThinkRank\SEO\Builder_Content::visible_content($post),
962 30
963 );
964 }
965
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 `&amp;` 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);
972 }
973
974 /**
975 * Get product image
976 *
977 * @since 1.0.0
978 * @param \WP_Post $post WordPress post object
979 * @return array|string Product image data
980 */
981 private function get_product_image(\WP_Post $post) {
982 // Try featured image first
983 if (has_post_thumbnail($post)) {
984 $image_id = get_post_thumbnail_id($post);
985 $image_url = wp_get_attachment_image_url($image_id, 'full');
986
987 if ($image_url) {
988 $image_meta = wp_get_attachment_metadata($image_id);
989
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 = [
996 '@type' => 'ImageObject',
997 'url' => $image_url,
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;
1009 }
1010 }
1011
1012 // Try custom meta field
1013 $custom_image = get_post_meta($post->ID, '_thinkrank_product_image', true);
1014 if (!empty($custom_image)) {
1015 return $custom_image;
1016 }
1017
1018 return '';
1019 }
1020
1021 /**
1022 * Get product SKU
1023 *
1024 * @since 1.0.0
1025 * @param \WP_Post $post WordPress post object
1026 * @return string Product SKU
1027 */
1028 private function get_product_sku(\WP_Post $post): string {
1029 // Try custom meta field
1030 $sku = get_post_meta($post->ID, '_thinkrank_product_sku', true);
1031
1032 // Try WooCommerce if available
1033 if (empty($sku) && function_exists('wc_get_product')) {
1034 $product = wc_get_product($post->ID);
1035 if ($product) {
1036 $sku = $product->get_sku();
1037 }
1038 }
1039
1040 return (string) $sku;
1041 }
1042
1043 /**
1044 * Get product brand
1045 *
1046 * @since 1.0.0
1047 * @param \WP_Post $post WordPress post object
1048 * @return string Product brand
1049 */
1050 private function get_product_brand(\WP_Post $post): string {
1051 // Try custom meta field
1052 $brand = get_post_meta($post->ID, '_thinkrank_product_brand', true);
1053
1054 // Try WooCommerce brand taxonomy if available
1055 if (empty($brand) && taxonomy_exists('product_brand')) {
1056 $terms = get_the_terms($post->ID, 'product_brand');
1057 if (!empty($terms) && !is_wp_error($terms)) {
1058 $brand = $terms[0]->name;
1059 }
1060 }
1061
1062 return (string) $brand;
1063 }
1064
1065 /**
1066 * Get product offers
1067 *
1068 * @since 1.0.0
1069 * @param \WP_Post $post WordPress post object
1070 * @return array Product offers data
1071 */
1072 private function get_product_offers(\WP_Post $post): array {
1073 $offers = [
1074 '@type' => 'Offer',
1075 'url' => get_permalink($post),
1076 ];
1077
1078 // Get price
1079 $price = get_post_meta($post->ID, '_thinkrank_product_price', true);
1080
1081 // Try WooCommerce if available
1082 if (empty($price) && function_exists('wc_get_product')) {
1083 $product = wc_get_product($post->ID);
1084 if ($product) {
1085 $price = $product->get_price();
1086 }
1087 }
1088
1089 if (!empty($price)) {
1090 $offers['price'] = (string) $price;
1091 }
1092
1093 // Get currency
1094 $currency = get_post_meta($post->ID, '_thinkrank_product_currency', true);
1095
1096 // Try WooCommerce currency if available
1097 if (empty($currency) && function_exists('get_woocommerce_currency')) {
1098 $currency = get_woocommerce_currency();
1099 }
1100
1101 // Default to USD
1102 if (empty($currency)) {
1103 $currency = 'USD';
1104 }
1105
1106 $offers['priceCurrency'] = $currency;
1107
1108 // Get availability
1109 $availability = get_post_meta($post->ID, '_thinkrank_product_availability', true);
1110
1111 // Try WooCommerce if available
1112 if (empty($availability) && function_exists('wc_get_product')) {
1113 $product = wc_get_product($post->ID);
1114 if ($product) {
1115 $availability = $product->is_in_stock() ? 'InStock' : 'OutOfStock';
1116 }
1117 }
1118
1119 // Default to InStock
1120 if (empty($availability)) {
1121 $availability = 'InStock';
1122 }
1123
1124 // Ensure proper schema.org URL format
1125 if (strpos($availability, 'https://schema.org/') !== 0) {
1126 $offers['availability'] = 'https://schema.org/' . $availability;
1127 } else {
1128 $offers['availability'] = $availability;
1129 }
1130
1131 // Add price valid until if available
1132 $price_valid_until = get_post_meta($post->ID, '_thinkrank_product_price_valid_until', true);
1133 if (!empty($price_valid_until)) {
1134 $offers['priceValidUntil'] = $price_valid_until;
1135 }
1136
1137 return $offers;
1138 }
1139
1140 /**
1141 * Get product aggregate rating
1142 *
1143 * @since 1.0.0
1144 * @param \WP_Post $post WordPress post object
1145 * @return array Product rating data
1146 */
1147 private function get_product_rating(\WP_Post $post): array {
1148 $rating = [];
1149
1150 // Try custom meta fields
1151 $rating_value = get_post_meta($post->ID, '_thinkrank_product_rating_value', true);
1152 $rating_count = get_post_meta($post->ID, '_thinkrank_product_rating_count', true);
1153
1154 // Try WooCommerce if available
1155 if ((empty($rating_value) || empty($rating_count)) && function_exists('wc_get_product')) {
1156 $product = wc_get_product($post->ID);
1157 if ($product) {
1158 $wc_rating_count = $product->get_rating_count();
1159 $wc_average = $product->get_average_rating();
1160
1161 if ($wc_rating_count > 0 && $wc_average > 0) {
1162 $rating_value = $wc_average;
1163 $rating_count = $wc_rating_count;
1164 }
1165 }
1166 }
1167
1168 // Only return rating if we have both value and count
1169 if (!empty($rating_value) && !empty($rating_count)) {
1170 $rating = [
1171 '@type' => 'AggregateRating',
1172 'ratingValue' => (string) $rating_value,
1173 'reviewCount' => (int) $rating_count,
1174 'bestRating' => '5',
1175 ];
1176 }
1177
1178 return $rating;
1179 }
1180
1181 /**
1182 * Get product reviews
1183 *
1184 * @since 1.0.0
1185 * @param \WP_Post $post WordPress post object
1186 * @return array Product reviews data
1187 */
1188 private function get_product_reviews(\WP_Post $post): array {
1189 $reviews = [];
1190
1191 // Try WooCommerce reviews if available
1192 if (function_exists('wc_get_product')) {
1193 $product = wc_get_product($post->ID);
1194 if ($product) {
1195 $comments = get_comments([
1196 'post_id' => $post->ID,
1197 'status' => 'approve',
1198 'type' => 'review',
1199 'number' => 5, // Limit to 5 most recent reviews
1200 ]);
1201
1202 foreach ($comments as $comment) {
1203 $rating = get_comment_meta($comment->comment_ID, 'rating', true);
1204
1205 if (!empty($rating)) {
1206 $reviews[] = [
1207 '@type' => 'Review',
1208 'reviewRating' => [
1209 '@type' => 'Rating',
1210 'ratingValue' => (string) $rating,
1211 'bestRating' => '5',
1212 ],
1213 'author' => [
1214 '@type' => 'Person',
1215 'name' => $comment->comment_author,
1216 ],
1217 'reviewBody' => wp_strip_all_tags($comment->comment_content),
1218 'datePublished' => get_comment_date('c', $comment),
1219 ];
1220 }
1221 }
1222 }
1223 }
1224
1225 // Try custom meta field for manual reviews
1226 if (empty($reviews)) {
1227 $custom_reviews = get_post_meta($post->ID, '_thinkrank_product_reviews', true);
1228 if (!empty($custom_reviews) && is_array($custom_reviews)) {
1229 $reviews = $custom_reviews;
1230 }
1231 }
1232
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;
1376 }
1377
1378 /**
1379 * Register generated schema with the request's schema graph.
1380 *
1381 * Replaces the direct echo this class used to do: the graph arbitrates
1382 * between this post-type-wide schema and the Schema Manager's per-post
1383 * deployment, then emits one linked @graph (#355).
1384 *
1385 * @since 1.32.0
1386 * @param array $schema Schema markup array
1387 * @param string $schema_type Schema @type
1388 * @param string $source Producer key used for precedence
1389 * @return void
1390 */
1391 private function register_schema(array $schema, string $schema_type, string $source): void {
1392 if (empty($schema)) {
1393 return;
1394 }
1395
1396 if (!class_exists('ThinkRank\\Frontend\\Schema_Graph')) {
1397 require_once THINKRANK_PLUGIN_DIR . 'includes/frontend/class-schema-graph.php';
1398 }
1399
1400 Schema_Graph::instance()->add_primary($schema, $schema_type, $source);
1401 }
1402 }
1403