PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / 2.7.0
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO v2.7.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 1.0.2 1.1.0 All 49 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.7.0, at includes/frontend/class-global-seo-schema-output.php

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