PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / 2.4.0
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO v2.4.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 1.10.0 All 48 releases
thinkrank / includes / frontend / class-schema-graph.php

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

1,438 lines 48.6 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Schema Graph Collector
4 *
5 * Single assembly point for every piece of JSON-LD ThinkRank emits on a request.
6 *
7 * Four subsystems used to write structured data independently — the Schema
8 * Manager (deployed per-post rows), the post-type-wide Global SEO output, the
9 * Gutenberg FAQ block and the Elementor FAQ widget. Each echoed its own
10 * <script> tag, so one URL could carry several page-level entities that never
11 * referenced each other, including two FAQPage entities with different
12 * questions (#355).
13 *
14 * Producers now register here instead of echoing. One late wp_head pass picks
15 * the page-level entity by source precedence — dropping the losing source, but
16 * keeping entities deployed alongside the winner — merges every FAQ source into
17 * one FAQPage, assigns stable @id values, links the nodes together and emits a
18 * single @graph.
19 *
20 * @package ThinkRank\Frontend
21 * @subpackage SEO
22 * @since 1.32.0
23 */
24
25 declare(strict_types=1);
26
27 namespace ThinkRank\Frontend;
28
29 // Prevent direct access
30 if (!defined('ABSPATH')) {
31 exit;
32 }
33
34 /**
35 * Collects and emits ThinkRank's structured data as one linked @graph.
36 *
37 * @since 1.32.0
38 */
39 class Schema_Graph {
40
41 /**
42 * Schema context URL.
43 */
44 private const SCHEMA_CONTEXT = 'https://schema.org';
45
46 /**
47 * Entity types that describe the site rather than the current page.
48 *
49 * These get a home-scoped @id so the same entity keeps one identity on
50 * every URL. WebSite and Organization are handled explicitly alongside
51 * these because they also seed isPartOf/publisher links (#471).
52 *
53 * @since 1.16.0
54 * @var string[]
55 */
56 private const SITE_LEVEL_TYPES = ['LocalBusiness', 'Person'];
57
58 /**
59 * Which source wins when several subsystems describe the page.
60 *
61 * Lower wins. Per-post schema deployed from the editor's Schema tab is a
62 * deliberate per-post decision, so it outranks the post-type-wide default.
63 *
64 * @var array<string,int>
65 */
66 private const PRIMARY_PRECEDENCE = [
67 'schema_manager' => 10,
68 'global_seo' => 20,
69 ];
70
71 /**
72 * Types that can legitimately be *the* entity a URL is about.
73 *
74 * Anything outside this set — Organization, Person, WebSite, LocalBusiness,
75 * or a type a future release starts deploying — is emitted as a supporting
76 * node instead of competing. Deliberately an allowlist: an unrecognised type
77 * demoted to supporting merely adds a node, whereas letting a non-page-level
78 * type win the slot deletes the page's real entity.
79 *
80 * @var array<int,string>
81 */
82 private const PAGE_LEVEL_TYPES = [
83 'Article', 'BlogPosting', 'NewsArticle', 'ScholarlyArticle', 'TechArticle',
84 'TechnicalArticle', 'Report', 'WebPage', 'AboutPage', 'ContactPage',
85 'ProfilePage', 'ItemPage', 'FAQPage', 'QAPage', 'CollectionPage',
86 'Product', 'Event', 'Recipe', 'Course', 'JobPosting', 'SoftwareApplication',
87 'Book', 'Movie', 'Service', 'ImageObject', 'VideoObject',
88 ];
89
90 /**
91 * Gutenberg FAQ block name.
92 */
93 private const FAQ_BLOCK = 'thinkrank/faq';
94
95 /**
96 * Elementor FAQ widget name.
97 */
98 private const FAQ_WIDGET = 'thinkrank-faq';
99
100 /**
101 * Bricks FAQ element name.
102 *
103 * @since 2.3.1
104 */
105 private const FAQ_BRICKS_ELEMENT = 'thinkrank-faq';
106
107 /**
108 * Third-party Elementor widgets that publish their own FAQPage.
109 *
110 * Maps widgetType to the setting whose 'yes' arms that widget's FAQ schema,
111 * so an accordion used purely as an accordion never suppresses ours.
112 *
113 * @since 2.1.0
114 * @var array<string,string>
115 */
116 private const FOREIGN_FAQ_WIDGETS = [
117 // Essential Addons for Elementor — Advanced Accordion.
118 'eael-adv-accordion' => 'eael_adv_accordion_faq_schema_show',
119 ];
120
121 /**
122 * Bricks elements that publish their own FAQPage.
123 *
124 * Bricks is a theme, not a plugin, and its accordions are core elements
125 * rather than a third-party add-on — so unlike FOREIGN_FAQ_WIDGETS this is
126 * a plain list: they share one gate, the `faqSchema` setting, and the
127 * per-element part of the check is whether the element has usable items
128 * (see bricks_element_publishes_faq()).
129 *
130 * @since 2.3.1
131 * @var string[]
132 */
133 private const FOREIGN_FAQ_BRICKS_ELEMENTS = ['accordion', 'accordion-nested'];
134
135 /**
136 * Singleton instance.
137 *
138 * @var self|null
139 */
140 private static ?self $instance = null;
141
142 /**
143 * Competing page-level entities: ['rank' => int, 'schema' => array, 'type' => string].
144 *
145 * @var array<int,array>
146 */
147 private array $primary_candidates = [];
148
149 /**
150 * Non-competing nodes (Organization, WebSite, BreadcrumbList, HowTo, …).
151 *
152 * @var array<int,array>
153 */
154 private array $supporting = [];
155
156 /**
157 * Merged FAQ questions, keyed by normalized question text.
158 *
159 * @var array<string,array>
160 */
161 private array $faq_entities = [];
162
163 /**
164 * Memoized answer to "should this request emit a FAQPage at all?".
165 *
166 * @since 2.1.0
167 * @var bool|null
168 */
169 private ?bool $emit_faqpage = null;
170
171 /**
172 * Whether FAQ content was taken from the rendered post body (block/widget),
173 * meaning those producers must not emit their own duplicate script.
174 *
175 * @var bool
176 */
177 private bool $absorbed_content_faq = false;
178
179 /**
180 * Guards against collecting the post's FAQ content more than once.
181 *
182 * @var bool
183 */
184 private bool $faq_collected = false;
185
186 /**
187 * Whether a producer has committed to rendering this graph on the request.
188 *
189 * Lazy FAQ collection is gated on it: absorbing a block's questions into a
190 * graph that will never be emitted would silence the block and publish
191 * nothing in its place.
192 *
193 * @var bool
194 */
195 private bool $render_scheduled = false;
196
197 /**
198 * Guards against a second render on the same request.
199 *
200 * @var bool
201 */
202 private bool $rendered = false;
203
204 /**
205 * Get the shared instance.
206 *
207 * @since 1.32.0
208 * @return self
209 */
210 public static function instance(): self {
211 if (null === self::$instance) {
212 self::$instance = new self();
213 }
214
215 return self::$instance;
216 }
217
218 /**
219 * Discard the shared instance. Test seam.
220 *
221 * @since 1.32.0
222 * @return void
223 */
224 public static function reset(): void {
225 self::$instance = null;
226 }
227
228 /**
229 * Register a candidate for the page's single page-level entity.
230 *
231 * A FAQPage is never a candidate in its own right — its questions are merged
232 * into the one FAQ node instead, so a deployed FAQPage and an FAQ block can
233 * never become two competing FAQPage entities.
234 *
235 * @since 1.32.0
236 * @param array $schema Schema array.
237 * @param string $type Schema @type.
238 * @param string $source Producer key from PRIMARY_PRECEDENCE.
239 * @return void
240 */
241 public function add_primary(array $schema, string $type, string $source): void {
242 if (empty($schema)) {
243 return;
244 }
245
246 $type = $this->effective_type($schema, $type);
247
248 if ('FAQPage' === $type && $this->should_emit_faqpage()) {
249 $this->add_faq_entities($schema['mainEntity'] ?? []);
250 return;
251 }
252
253 // A third party owns the page's FAQPage, so ours must not be emitted
254 // (#494). Demote rather than drop: a FAQPage is still the page, and
255 // returning here would leave the URL with no page-level entity at all.
256 if ('FAQPage' === $type) {
257 $schema['@type'] = 'WebPage';
258 unset($schema['mainEntity']);
259 $type = 'WebPage';
260 }
261
262 // A per-post deployment can be something that isn't what the page is
263 // about (an Organization, say). Letting it win the slot would drop the
264 // page's real entity, so it joins the graph as a supporting node.
265 if (!in_array($type, self::PAGE_LEVEL_TYPES, true)) {
266 $this->supporting[] = $schema;
267 return;
268 }
269
270 $this->primary_candidates[] = [
271 'rank' => self::PRIMARY_PRECEDENCE[$source] ?? PHP_INT_MAX,
272 'schema' => $schema,
273 'type' => $type,
274 ];
275 }
276
277 /**
278 * Resolve what a schema actually is, not what it was configured as.
279 *
280 * The two differ whenever a generator falls back — a post type configured
281 * as FAQPage emits a WebPage when the page has no genuine Q&A. Trusting the
282 * configured label there would route a WebPage into FAQ merging and drop it.
283 *
284 * @since 1.32.0
285 * @param array $schema Schema array.
286 * @param string $declared Type the producer declared.
287 * @return string
288 */
289 private function effective_type(array $schema, string $declared): string {
290 $actual = $schema['@type'] ?? '';
291
292 return (is_string($actual) && $actual !== '') ? $actual : $declared;
293 }
294
295 /**
296 * Register a node that does not compete for the page-level slot.
297 *
298 * @since 1.32.0
299 * @param array $schema Schema array.
300 * @param string $type Schema @type.
301 * @return void
302 */
303 public function add_supporting(array $schema, string $type): void {
304 if (empty($schema)) {
305 return;
306 }
307
308 $effective_type = $this->effective_type($schema, $type);
309
310 // A supporting FAQPage never survives as its own node: its questions
311 // merge into the graph's single FAQ node, or are dropped when a third
312 // party already owns the page's FAQPage (#494). Unlike the primary
313 // slot there is nothing to preserve here, so demotion would only add a
314 // second page-level entity beside the real one.
315 if ('FAQPage' === $effective_type) {
316 if ($this->should_emit_faqpage()) {
317 $this->add_faq_entities($schema['mainEntity'] ?? []);
318 }
319 return;
320 }
321
322 // One breadcrumb trail per page. A deployed BreadcrumbList lands here
323 // and output_breadcrumb_schema() adds a second on its own wp_head hook,
324 // so pages ended up with #breadcrumb and #breadcrumb-2 — two conflicting
325 // trails, with the primary node linking to only one of them (#471).
326 // First writer wins.
327 if ('BreadcrumbList' === $effective_type && $this->has_supporting_type('BreadcrumbList')) {
328 return;
329 }
330
331 $this->supporting[] = $schema;
332 }
333
334 /**
335 * Whether a supporting node of the given type has already been collected.
336 *
337 * @since 1.16.0
338 *
339 * @param string $type Schema type.
340 * @return bool
341 */
342 private function has_supporting_type(string $type): bool {
343 foreach ($this->supporting as $node) {
344 if (($node['@type'] ?? '') === $type) {
345 return true;
346 }
347 }
348
349 return false;
350 }
351
352 /**
353 * Merge FAQ questions into the single FAQ node, deduped by question text.
354 *
355 * @since 1.32.0
356 * @param mixed $entities Candidate Question entities.
357 * @return void
358 */
359 public function add_faq_entities($entities): void {
360 if (!is_array($entities)) {
361 return;
362 }
363
364 foreach ($entities as $entity) {
365 if (!is_array($entity)) {
366 continue;
367 }
368
369 $question = isset($entity['name']) ? trim((string) $entity['name']) : '';
370 $answer = isset($entity['acceptedAnswer']['text'])
371 ? trim((string) $entity['acceptedAnswer']['text'])
372 : '';
373
374 if ($question === '' || $answer === '') {
375 continue;
376 }
377
378 $key = strtolower(preg_replace('/\s+/', ' ', $question) ?? $question);
379
380 // First writer wins, so the deliberate per-post deployment keeps its
381 // wording when the same question also appears in a block.
382 if (!isset($this->faq_entities[$key])) {
383 $this->faq_entities[$key] = $entity;
384 }
385 }
386 }
387
388 /**
389 * Whether FAQ content from the post body has been absorbed into the graph.
390 *
391 * The FAQ block and Elementor widget call this to decide whether to skip
392 * their own inline JSON-LD. False (nothing absorbed, or the graph never ran)
393 * leaves their original behaviour untouched.
394 *
395 * @since 1.32.0
396 * @return bool
397 */
398 public function absorbed_content_faq(): bool {
399 $this->maybe_collect_post_faq();
400
401 return $this->absorbed_content_faq;
402 }
403
404 /**
405 * Announce that this graph will be rendered on the current request.
406 *
407 * Called where the render hook is registered, so the graph can tell "I am
408 * about to be emitted" from "nothing will output me" without inspecting
409 * hooks it does not own.
410 *
411 * @since 1.32.0
412 * @return void
413 */
414 public function schedule_render(): void {
415 $this->render_scheduled = true;
416 }
417
418 /**
419 * Collect the queried post's FAQ content if nothing has yet.
420 *
421 * Block themes render the whole template — post content included — from
422 * `get_the_block_template_html()`, and on some flows that happens before
423 * `wp_head` fires. The FAQ block therefore asked whether it had been
424 * absorbed while the graph's own collection pass was still pending, read
425 * false, and emitted a second FAQPage beside the graph's. Collecting on
426 * first ask makes the answer independent of which side runs first; the
427 * result is identical either way, because collection reads `post_content`
428 * rather than anything the render produces.
429 *
430 * @since 1.32.0
431 * @return void
432 */
433 private function maybe_collect_post_faq(): void {
434 if ($this->faq_collected || $this->rendered || !$this->render_scheduled) {
435 return;
436 }
437
438 if (!function_exists('is_singular') || !is_singular()) {
439 return;
440 }
441
442 $post = get_post();
443 if ($post instanceof \WP_Post) {
444 $this->collect_post_faq($post);
445 }
446 }
447
448 /**
449 * Pull FAQ content out of a post's blocks and Elementor data.
450 *
451 * Runs during wp_head, before the body renders, so the block and widget can
452 * see that their content is already accounted for.
453 *
454 * @since 1.32.0
455 * @param \WP_Post $post Post being viewed.
456 * @return void
457 */
458 public function collect_post_faq(\WP_Post $post): void {
459 if ($this->faq_collected) {
460 return;
461 }
462
463 $this->faq_collected = true;
464
465 // Reading post_content directly bypasses the gate the render path gets
466 // for free: behind a password form the FAQ block never renders, so it
467 // never emitted schema. Without this check the graph would publish the
468 // questions and answers of protected content to anyone.
469 if (function_exists('post_password_required') && post_password_required($post)) {
470 return;
471 }
472
473 // The same gate, for the same reason, with a different cause: a Bricks
474 // page throws `post_content` away, so a FAQ block left there when the
475 // page was switched over never renders. Publishing its questions would
476 // put schema on the page for content no visitor can see — which Google
477 // treats as a violation, not merely a duplicate (#650).
478 if (!$this->bricks_supersedes_post_content((int) $post->ID)) {
479 $this->collect_block_faq($post);
480 }
481
482 $this->collect_elementor_faq($post);
483 $this->collect_bricks_faq($post);
484 }
485
486 /**
487 * Whether Bricks renders this post and discards its `post_content`.
488 *
489 * @since 2.3.1
490 * @param int $post_id Post being viewed.
491 * @return bool
492 */
493 private function bricks_supersedes_post_content(int $post_id): bool {
494 if (!class_exists('ThinkRank\\SEO\\Builder_Content')) {
495 $file = THINKRANK_PLUGIN_DIR . 'includes/seo/class-builder-content.php';
496 if (!file_exists($file)) {
497 return false;
498 }
499 require_once $file;
500 }
501
502 return \ThinkRank\SEO\Builder_Content::bricks_supersedes_post_content($post_id);
503 }
504
505 /**
506 * Record that a body FAQ producer's content is represented in the graph.
507 *
508 * Deliberately not keyed on the entity count growing: when a block asks the
509 * same question as the per-post deployment, dedup means nothing is added,
510 * but the block's content *is* covered and it must still stay quiet.
511 *
512 * @since 1.32.0
513 * @param array $entities Questions found on that producer.
514 * @return void
515 */
516 private function absorb_content_faq(array $entities): void {
517 if (empty($entities)) {
518 return;
519 }
520
521 $this->add_faq_entities($entities);
522 $this->absorbed_content_faq = true;
523 }
524
525 /**
526 * Collect FAQ questions from thinkrank/faq blocks, including nested ones.
527 *
528 * @since 1.32.0
529 * @param \WP_Post $post Post being viewed.
530 * @return void
531 */
532 private function collect_block_faq(\WP_Post $post): void {
533 if (!function_exists('parse_blocks') || !has_blocks($post->post_content)) {
534 return;
535 }
536
537 $this->walk_blocks(parse_blocks($post->post_content));
538 }
539
540 /**
541 * Recurse a parsed block tree collecting FAQ entries.
542 *
543 * @since 1.32.0
544 * @param array $blocks Parsed blocks.
545 * @return void
546 */
547 private function walk_blocks(array $blocks): void {
548 foreach ($blocks as $block) {
549 if (!is_array($block)) {
550 continue;
551 }
552
553 if (($block['blockName'] ?? '') === self::FAQ_BLOCK) {
554 $attrs = $block['attrs'] ?? [];
555
556 // Mirrors Blocks_Manager: schema is on unless explicitly disabled.
557 $disabled = array_key_exists('outputSchema', $attrs) && false === $attrs['outputSchema'];
558
559 if (!$disabled) {
560 $this->absorb_content_faq($this->questions_from_pairs($attrs['faqs'] ?? []));
561 }
562 }
563
564 if (!empty($block['innerBlocks']) && is_array($block['innerBlocks'])) {
565 $this->walk_blocks($block['innerBlocks']);
566 }
567 }
568 }
569
570 /**
571 * Collect FAQ questions from Elementor FAQ widgets.
572 *
573 * @since 1.32.0
574 * @param \WP_Post $post Post being viewed.
575 * @return void
576 */
577 private function collect_elementor_faq(\WP_Post $post): void {
578 $raw = get_post_meta($post->ID, '_elementor_data', true);
579 if (empty($raw) || !is_string($raw)) {
580 return;
581 }
582
583 $elements = json_decode($raw, true);
584 if (!is_array($elements)) {
585 return;
586 }
587
588 $this->walk_elementor($elements);
589 }
590
591 /**
592 * Collect FAQ questions from Bricks FAQ elements.
593 *
594 * Reads the tree Bricks will actually render — resolved through
595 * `Builder_Content`, so a page whose content lives on a content template or
596 * inside a component is covered, and one switched back to the block editor
597 * is not.
598 *
599 * Unlike the block, this is not gated on Bricks owning `post_content`: a
600 * Bricks element is on the page whenever Bricks renders the page, which is
601 * exactly what resolving the tree already establishes (#626).
602 *
603 * @since 2.3.1
604 * @param \WP_Post $post Post being viewed.
605 * @return void
606 */
607 private function collect_bricks_faq(\WP_Post $post): void {
608 $this->walk_bricks($this->bricks_tree((int) $post->ID));
609 }
610
611 /**
612 * Collect FAQ entries from a resolved Bricks tree.
613 *
614 * The tree is flat, so no recursion: `Builder_Content::bricks_tree()`
615 * splices component definitions into the same list.
616 *
617 * The element's own settings are read here rather than through
618 * `FAQ_Element`, whose class extends `Bricks\Element` and so cannot even be
619 * loaded when the theme is inactive — which is exactly the case that still
620 * has a stored tree, on a site that has since switched themes. The repeater
621 * uses the same `question` / `answer` keys as the block, so the shared
622 * builder below already understands it.
623 *
624 * @since 2.3.1
625 * @param array $elements Bricks elements.
626 * @return void
627 */
628 private function walk_bricks(array $elements): void {
629 foreach ($elements as $element) {
630 if (!is_array($element) || ($element['name'] ?? '') !== self::FAQ_BRICKS_ELEMENT) {
631 continue;
632 }
633
634 $settings = is_array($element['settings'] ?? null) ? $element['settings'] : [];
635
636 // Mirrors FAQ_Element: a cleared Bricks checkbox loses its key.
637 if (empty($settings['outputSchema'])) {
638 continue;
639 }
640
641 $this->absorb_content_faq($this->questions_from_pairs($settings['faqs'] ?? []));
642 }
643 }
644
645 /**
646 * Recurse an Elementor element tree collecting FAQ entries.
647 *
648 * @since 1.32.0
649 * @param array $elements Elementor elements.
650 * @return void
651 */
652 private function walk_elementor(array $elements): void {
653 foreach ($elements as $element) {
654 if (!is_array($element)) {
655 continue;
656 }
657
658 if (($element['widgetType'] ?? '') === self::FAQ_WIDGET) {
659 $settings = $element['settings'] ?? [];
660
661 // Mirrors FAQ_Widget: schema unless the toggle is off.
662 if ('yes' === ($settings['output_schema'] ?? 'yes')) {
663 $this->absorb_content_faq($this->questions_from_pairs($settings['faqs'] ?? []));
664 }
665 }
666
667 if (!empty($element['elements']) && is_array($element['elements'])) {
668 $this->walk_elementor($element['elements']);
669 }
670 }
671 }
672
673 /**
674 * Turn stored question/answer pairs into Question entities.
675 *
676 * @since 1.32.0
677 * @param mixed $pairs Repeater rows with question/answer keys.
678 * @return array
679 */
680 private function questions_from_pairs($pairs): array {
681 if (!is_array($pairs)) {
682 return [];
683 }
684
685 $entities = [];
686
687 foreach ($pairs as $pair) {
688 if (!is_array($pair)) {
689 continue;
690 }
691
692 $question = isset($pair['question']) ? trim(wp_strip_all_tags((string) $pair['question'])) : '';
693 $answer = isset($pair['answer']) ? trim((string) $pair['answer']) : '';
694
695 if ($question === '' || $answer === '') {
696 continue;
697 }
698
699 $text = wp_kses_post($answer);
700
701 // Mirrors Blocks_Manager::build_faq_schema() by calling the same
702 // builder, so the two paths cannot drift — the per-item image is
703 // resolved from its attachment id, carries intrinsic dimensions,
704 // and disappears if the media was deleted (#418).
705 $text .= \ThinkRank\Editor\Blocks_Manager::faq_image_markup(is_array($pair) ? $pair : []);
706
707 $entities[] = [
708 '@type' => 'Question',
709 'name' => $question,
710 'acceptedAnswer' => [
711 '@type' => 'Answer',
712 'text' => $text,
713 ],
714 ];
715 }
716
717 return $entities;
718 }
719
720 /**
721 * Whether anything has been registered.
722 *
723 * @since 1.32.0
724 * @return bool
725 */
726 public function has_nodes(): bool {
727 return !empty($this->primary_candidates) || !empty($this->supporting) || !empty($this->faq_entities);
728 }
729
730 /**
731 * Assemble and emit the graph. Safe to call more than once.
732 *
733 * @since 1.32.0
734 * @return void
735 */
736 public function render(): void {
737 if ($this->rendered || !$this->has_nodes()) {
738 return;
739 }
740
741 // A 404 response represents no content, so there is nothing for
742 // structured data to describe. The page-level producers already skip
743 // this context, but the site-identity entity does not, so without this
744 // guard every miss — including crawlers probing URLs that never existed
745 // — emits a Person carrying email, telephone and birthDate (#481).
746 if (is_404()) {
747 return;
748 }
749
750 $this->rendered = true;
751
752 $graph = $this->build_graph();
753
754 /**
755 * Filter the assembled schema graph before output.
756 *
757 * Receives every node ThinkRank is about to emit, already deduped and
758 * linked, so add-ons can append or adjust nodes in one place.
759 *
760 * @since 1.32.0
761 *
762 * @param array $graph List of schema nodes ([] suppresses output).
763 */
764 $graph = apply_filters('thinkrank_schema_graph', $graph);
765
766 // Drop empty properties across every node. An empty string is worse
767 // than an absent one — "headline": "" fails Article validation harder
768 // than omitting it — and Schema_Builder::clean_schema_array(), which was
769 // written for exactly this, is never reached from the render path
770 // (#471). Runs after the filter so add-on nodes are cleaned too.
771 $graph = array_values(array_filter(array_map([$this, 'prune_empty_values'], $graph)));
772
773 if (empty($graph)) {
774 return;
775 }
776
777 $json = wp_json_encode(
778 ['@context' => self::SCHEMA_CONTEXT, '@graph' => array_values($graph)],
779 JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT
780 | JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT
781 );
782
783 if (false === $json) {
784 return;
785 }
786
787 echo "<!-- ThinkRank Schema Graph -->\n";
788 echo '<script type="application/ld+json">' . "\n";
789 echo $json . "\n"; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- wp_json_encode with JSON_HEX_* cannot break out of the script block.
790 echo '</script>' . "\n";
791 echo "<!-- /ThinkRank Schema Graph -->\n";
792 }
793
794 /**
795 * Replace an inline entity with an @id reference to an equivalent node.
796 *
797 * Matches on name so a post author is never silently collapsed into the
798 * site's Person entity, and vice versa (#471).
799 *
800 * @since 1.16.0
801 *
802 * @param mixed $inline The inline entity from the primary node.
803 * @param array $candidates Nodes already in the graph, each with an @id.
804 * @return array|null ['@id' => …] when a match is found, null otherwise.
805 */
806 private function link_to_node($inline, array $candidates): ?array {
807 if (!is_array($inline) || empty($candidates)) {
808 return null;
809 }
810
811 // Already a reference.
812 if (isset($inline['@id']) && !isset($inline['name'])) {
813 return null;
814 }
815
816 $inline_name = isset($inline['name']) ? trim((string) $inline['name']) : '';
817
818 if ('' === $inline_name) {
819 return null;
820 }
821
822 foreach ($candidates as $candidate) {
823 $candidate_name = isset($candidate['name']) ? trim((string) $candidate['name']) : '';
824
825 if ('' !== $candidate_name
826 && 0 === strcasecmp($candidate_name, $inline_name)
827 && !empty($candidate['@id'])
828 ) {
829 return ['@id' => $candidate['@id']];
830 }
831 }
832
833 return null;
834 }
835
836 /**
837 * Recursively drop empty properties from a schema node.
838 *
839 * Removes '', [], and null. Deliberately keeps numeric 0, boolean false and
840 * the structural keys, which are all meaningful values.
841 *
842 * @since 1.16.0
843 *
844 * @param mixed $value Node or property value.
845 * @return mixed Cleaned value.
846 */
847 private function prune_empty_values($value) {
848 if (!is_array($value)) {
849 return $value;
850 }
851
852 $cleaned = [];
853
854 foreach ($value as $key => $item) {
855 // Never prune the keys that give a node its identity.
856 if (in_array($key, ['@context', '@type', '@id'], true)) {
857 $cleaned[$key] = $item;
858 continue;
859 }
860
861 if (is_array($item)) {
862 $item = $this->prune_empty_values($item);
863
864 if ([] === $item) {
865 continue;
866 }
867
868 $cleaned[$key] = $item;
869 continue;
870 }
871
872 if (null === $item || '' === $item) {
873 continue;
874 }
875
876 $cleaned[$key] = $item;
877 }
878
879 return $cleaned;
880 }
881
882 /**
883 * Build the linked node list.
884 *
885 * @since 1.32.0
886 * @return array
887 */
888 private function build_graph(): array {
889 $selection = $this->select_primary_set();
890 $primary = $selection['winner'];
891 $siblings = $selection['siblings'];
892 $faq = $this->build_faq_node();
893 $base = $this->base_url($primary);
894
895 // With no other page-level entity, the FAQ node is the page.
896 if (null === $primary && null !== $faq) {
897 $primary = ['schema' => $faq, 'type' => 'FAQPage'];
898 $faq = null;
899 }
900
901 $nodes = [];
902 $primary_id = '';
903 $used_ids = [];
904
905 if (null !== $primary) {
906 $node = $primary['schema'];
907
908 // Key the @id off the node's resolved @type, not the configured one,
909 // so an "Article" setting that renders BlogPosting reads #blogposting.
910 $resolved_type = $this->effective_type($node, $primary['type']);
911
912 $node = $this->assign_id($node, $base . '#' . strtolower($resolved_type), $used_ids);
913 $primary_id = $node['@id'];
914 $nodes['primary'] = $node;
915 }
916
917 // Entities deployed alongside the winner (Pro's Multi-Schema lets a post
918 // carry an Article *and* a Recipe). They lost the page slot but were
919 // deliberately deployed, so they stay in the graph linked to the primary
920 // rather than being dropped.
921 foreach ($siblings as $index => $sibling) {
922 $node = $sibling['schema'];
923
924 $node = $this->assign_id(
925 $node,
926 $base . '#' . strtolower($this->effective_type($node, $sibling['type'])),
927 $used_ids
928 );
929
930 if ($primary_id !== '' && $node['@id'] !== $primary_id) {
931 $node['isPartOf'] = $node['isPartOf'] ?? ['@id' => $primary_id];
932 $node['mainEntityOfPage'] = $node['mainEntityOfPage'] ?? ['@id' => $primary_id];
933 }
934
935 $nodes['sibling_' . $index] = $node;
936 }
937
938 if (null !== $faq) {
939 $faq = $this->assign_id($faq, $base . '#faq', $used_ids);
940
941 if ($primary_id !== '') {
942 $faq['isPartOf'] = ['@id' => $primary_id];
943 $faq['mainEntityOfPage'] = ['@id' => $primary_id];
944 }
945
946 $nodes['faq'] = $faq;
947 }
948
949 $website_id = '';
950 $breadcrumb_id = '';
951 $organization_nodes = [];
952 $person_nodes = [];
953
954 foreach ($this->supporting as $index => $node) {
955 $type = $node['@type'] ?? '';
956
957 if ('BreadcrumbList' === $type) {
958 $node = $this->assign_id($node, $base . '#breadcrumb', $used_ids);
959 $breadcrumb_id = $node['@id'];
960 } elseif ('WebSite' === $type) {
961 $node = $this->assign_id($node, home_url('/#website'), $used_ids);
962 $website_id = $node['@id'];
963 } elseif ('Organization' === $type) {
964 $node = $this->assign_id($node, home_url('/#organization'), $used_ids);
965 $organization_nodes[] = $node;
966 } elseif (in_array($type, self::SITE_LEVEL_TYPES, true)) {
967 // Site-level entities describe the site, not the page, so their
968 // @id must be stable across URLs. Falling through to the
969 // page-scoped branch minted a fresh identity on every URL, so
970 // one business became N entities in a crawler's graph and
971 // nothing could reference it by @id (#471).
972 // One entity, emitted once. The site identity and a per-post
973 // deployment describe the same person or business, so both
974 // arrive here claiming the same @id. assign_id() would resolve
975 // that collision by minting "#person-2", turning a duplicate
976 // into two competing entities that split the identity a
977 // knowledge graph is meant to consolidate (#479).
978 $duplicate_key = $this->find_same_entity($nodes, $type, $node);
979
980 if (null !== $duplicate_key) {
981 $nodes[$duplicate_key] = $this->merge_entity($nodes[$duplicate_key], $node);
982 continue;
983 }
984
985 $node = $this->assign_id($node, home_url('/#' . strtolower($type)), $used_ids);
986
987 if ('Person' === $type) {
988 $person_nodes[] = $node;
989 }
990 } elseif (is_string($type) && $type !== '') {
991 $node = $this->assign_id($node, $base . '#' . strtolower($type), $used_ids);
992 }
993
994 $nodes['supporting_' . $index] = $node;
995 }
996
997 // Link the page entity to the site and its breadcrumb trail.
998 if (isset($nodes['primary'])) {
999 if ($website_id !== '' && !isset($nodes['primary']['isPartOf'])) {
1000 $nodes['primary']['isPartOf'] = ['@id' => $website_id];
1001 }
1002 if ($breadcrumb_id !== '' && !isset($nodes['primary']['breadcrumb'])) {
1003 $nodes['primary']['breadcrumb'] = ['@id' => $breadcrumb_id];
1004 }
1005
1006 // Point publisher/author at the full nodes already in the graph.
1007 // They were emitted inline with no @id, so the graph described the
1008 // same publisher twice — and the richer node, the one carrying the
1009 // logo Google needs for Article, was not the one publisher
1010 // referenced (#471).
1011 //
1012 // Only collapse when the inline object names the SAME entity. A post
1013 // author and the site's Person entity are frequently different
1014 // people, so matching on position rather than identity would
1015 // misattribute authorship.
1016 if (isset($nodes['primary']['publisher'])) {
1017 $linked = $this->link_to_node($nodes['primary']['publisher'], $organization_nodes);
1018 if (null !== $linked) {
1019 $nodes['primary']['publisher'] = $linked;
1020 }
1021 }
1022
1023 if (isset($nodes['primary']['author'])) {
1024 $linked = $this->link_to_node($nodes['primary']['author'], $person_nodes);
1025 if (null !== $linked) {
1026 $nodes['primary']['author'] = $linked;
1027 }
1028 }
1029 }
1030
1031 // The graph carries @context once; per-node copies are redundant.
1032 foreach ($nodes as $key => $node) {
1033 unset($node['@context']);
1034 $nodes[$key] = $node;
1035 }
1036
1037 return array_values($nodes);
1038 }
1039
1040 /**
1041 * Pick the page-level entity, plus any deployed alongside it.
1042 *
1043 * Precedence arbitrates between *sources*, not between entities: a per-post
1044 * deployment beats the post-type-wide default, and the losing source is
1045 * dropped so one URL stops claiming to be several unrelated things (#355).
1046 *
1047 * Within the winning source every entity is kept. Deploying more than one
1048 * page-level schema on a post is exactly what Pro's Multi-Schema feature
1049 * exists to do (an Article that is also a Recipe), and silently discarding
1050 * the extras would delete markup the user deliberately published.
1051 *
1052 * @since 1.32.0
1053 * @return array{winner: array|null, siblings: array<int,array>}
1054 */
1055 private function select_primary_set(): array {
1056 if (empty($this->primary_candidates)) {
1057 return ['winner' => null, 'siblings' => []];
1058 }
1059
1060 $best = PHP_INT_MAX;
1061 foreach ($this->primary_candidates as $candidate) {
1062 if ($candidate['rank'] < $best) {
1063 $best = $candidate['rank'];
1064 }
1065 }
1066
1067 $kept = [];
1068 foreach ($this->primary_candidates as $candidate) {
1069 if ($candidate['rank'] === $best) {
1070 $kept[] = $candidate;
1071 }
1072 }
1073
1074 return ['winner' => array_shift($kept), 'siblings' => array_values($kept)];
1075 }
1076
1077 /**
1078 * Find an already-placed node describing the same entity as $node.
1079 *
1080 * Identity is `email` when both carry one — two people can share a name,
1081 * but not a mailbox — and a case-insensitive `name` match otherwise. A node
1082 * with neither never matches, so an unidentifiable entity is kept rather
1083 * than folded into an unrelated one.
1084 *
1085 * @since 2.0.2
1086 *
1087 * @param array $nodes Nodes placed so far, keyed.
1088 * @param string $type Schema type to match within.
1089 * @param array $node Candidate node.
1090 * @return string|null Key of the matching node, or null.
1091 */
1092 private function find_same_entity(array $nodes, string $type, array $node): ?string {
1093 $email = isset($node['email']) ? strtolower(trim((string) $node['email'])) : '';
1094 $name = isset($node['name']) ? trim((string) $node['name']) : '';
1095
1096 if ('' === $email && '' === $name) {
1097 return null;
1098 }
1099
1100 foreach ($nodes as $key => $placed) {
1101 if (($placed['@type'] ?? '') !== $type) {
1102 continue;
1103 }
1104
1105 $placed_email = isset($placed['email']) ? strtolower(trim((string) $placed['email'])) : '';
1106
1107 if ('' !== $email && '' !== $placed_email) {
1108 if ($email === $placed_email) {
1109 return (string) $key;
1110 }
1111 continue;
1112 }
1113
1114 $placed_name = isset($placed['name']) ? trim((string) $placed['name']) : '';
1115
1116 if ('' !== $name && '' !== $placed_name && 0 === strcasecmp($name, $placed_name)) {
1117 return (string) $key;
1118 }
1119 }
1120
1121 return null;
1122 }
1123
1124 /**
1125 * Fold a duplicate entity into the node already in the graph.
1126 *
1127 * Fills gaps only: a property the placed node already carries wins, so the
1128 * node that claimed the identity first keeps it, @id included. The
1129 * duplicate can still contribute properties the first copy lacked, which is
1130 * the point — between them they describe the entity more completely than
1131 * either does alone.
1132 *
1133 * @since 2.0.2
1134 *
1135 * @param array $placed Node already in the graph.
1136 * @param array $duplicate Node describing the same entity.
1137 * @return array Merged node.
1138 */
1139 private function merge_entity(array $placed, array $duplicate): array {
1140 foreach ($duplicate as $key => $value) {
1141 if ('@id' === $key || '@type' === $key || '@context' === $key) {
1142 continue;
1143 }
1144
1145 if (!isset($placed[$key]) || '' === $placed[$key] || [] === $placed[$key]) {
1146 $placed[$key] = $value;
1147 }
1148 }
1149
1150 return $placed;
1151 }
1152
1153 /**
1154 * Give a node a unique @id, keeping one it already carries.
1155 *
1156 * Two entities of the same type on one page (two deployed Articles, say)
1157 * would otherwise mint the same @id, which makes the graph ambiguous about
1158 * which node a reference points at.
1159 *
1160 * @since 1.32.0
1161 * @param array $node Node to stamp.
1162 * @param string $fallback @id to use when the node has none.
1163 * @param array $used Already-issued @id values, updated by reference.
1164 * @return array
1165 */
1166 private function assign_id(array $node, string $fallback, array &$used): array {
1167 $id = (isset($node['@id']) && is_string($node['@id']) && $node['@id'] !== '')
1168 ? $node['@id']
1169 : $fallback;
1170
1171 if (isset($used[$id])) {
1172 $suffix = 2;
1173 while (isset($used[$id . '-' . $suffix])) {
1174 $suffix++;
1175 }
1176 $id .= '-' . $suffix;
1177 }
1178
1179 $used[$id] = true;
1180 $node['@id'] = $id;
1181
1182 return $node;
1183 }
1184
1185 /**
1186 * Whether ThinkRank should emit a FAQPage on this request.
1187 *
1188 * ThinkRank emitted its FAQPage unconditionally, so a URL whose FAQ was
1189 * already published by another plugin carried two FAQPage entities — each
1190 * valid on its own, together ambiguous about which one describes the page
1191 * (#494).
1192 *
1193 * The answer cannot be read off the rendered page. Third-party FAQ schema
1194 * is typically printed in `wp_footer` from data its widget only gathers
1195 * while the body renders, which is long after this graph goes out in
1196 * `wp_head`; at the moment of the decision the foreign FAQPage does not
1197 * exist yet, in the buffer or anywhere else. Detection therefore inspects
1198 * the stored post content, the same way collect_elementor_faq() finds
1199 * ThinkRank's own widget.
1200 *
1201 * @since 2.1.0
1202 * @return bool
1203 */
1204 private function should_emit_faqpage(): bool {
1205 if (null !== $this->emit_faqpage) {
1206 return $this->emit_faqpage;
1207 }
1208
1209 $post = (function_exists('is_singular') && is_singular()) ? get_post() : null;
1210 if (!$post instanceof \WP_Post) {
1211 $post = null;
1212 }
1213
1214 $emit = !$this->has_foreign_faq_source($post);
1215
1216 /**
1217 * Filter whether ThinkRank emits its FAQPage entity.
1218 *
1219 * Return false from a plugin that publishes its own FAQPage on the same
1220 * URL and ThinkRank drops its FAQ node, leaving the page one
1221 * unambiguous FAQPage. ThinkRank already defaults this to false for the
1222 * FAQ sources it recognises, so the filter is for the ones it does not
1223 * — or for forcing its FAQPage back on.
1224 *
1225 * @since 2.1.0
1226 *
1227 * @param bool $emit Whether to emit the FAQPage node.
1228 * @param \WP_Post|null $post Post being viewed, or null when not singular.
1229 */
1230 $this->emit_faqpage = (bool) apply_filters('thinkrank_emit_faqpage', $emit, $post);
1231
1232 return $this->emit_faqpage;
1233 }
1234
1235 /**
1236 * Whether another plugin publishes a FAQPage for this post.
1237 *
1238 * @since 2.1.0
1239 * @param \WP_Post|null $post Post being viewed.
1240 * @return bool
1241 */
1242 private function has_foreign_faq_source(?\WP_Post $post): bool {
1243 if (!$post instanceof \WP_Post) {
1244 return false;
1245 }
1246
1247 return $this->has_foreign_elementor_faq($post) || $this->has_foreign_bricks_faq($post);
1248 }
1249
1250 /**
1251 * Whether an Elementor widget on this post publishes a FAQPage.
1252 *
1253 * @since 2.1.0
1254 * @param \WP_Post $post Post being viewed.
1255 * @return bool
1256 */
1257 private function has_foreign_elementor_faq(\WP_Post $post): bool {
1258 $raw = get_post_meta($post->ID, '_elementor_data', true);
1259 if (empty($raw) || !is_string($raw)) {
1260 return false;
1261 }
1262
1263 $elements = json_decode($raw, true);
1264
1265 return is_array($elements) && $this->elements_have_foreign_faq($elements);
1266 }
1267
1268 /**
1269 * Whether a Bricks element on this post publishes a FAQPage.
1270 *
1271 * Bricks' accordions emit their FAQPage from the body render, so — exactly
1272 * as with EA's accordion — the stored tree is the only signal available at
1273 * `wp_head`, where this decision has to be made.
1274 *
1275 * The tree comes from Builder_Content rather than a direct meta read: a
1276 * Bricks page's content can live on a content template, be assembled from
1277 * components, or be stored but not rendered because the post was switched
1278 * back to the block editor. Reading the meta key here would get all three
1279 * wrong (#649).
1280 *
1281 * @since 2.3.1
1282 * @param \WP_Post $post Post being viewed.
1283 * @return bool
1284 */
1285 private function has_foreign_bricks_faq(\WP_Post $post): bool {
1286 foreach ($this->bricks_tree((int) $post->ID) as $element) {
1287 if (is_array($element) && $this->bricks_element_publishes_faq($element)) {
1288 return true;
1289 }
1290 }
1291
1292 return false;
1293 }
1294
1295 /**
1296 * Whether one Bricks element will put a FAQPage on the page.
1297 *
1298 * Mirrors Bricks' own emission condition rather than trusting the toggle:
1299 * `accordion` records a question only for an item that has BOTH a title and
1300 * content, so an armed but empty accordion publishes nothing and must not
1301 * cost the page ThinkRank's FAQ node. `accordion-nested` builds its items
1302 * from child elements instead of a repeater, so having children is the
1303 * equivalent test there.
1304 *
1305 * @since 2.3.1
1306 * @param array $element One Bricks element.
1307 * @return bool
1308 */
1309 private function bricks_element_publishes_faq(array $element): bool {
1310 $name = is_string($element['name'] ?? null) ? $element['name'] : '';
1311 if (!in_array($name, self::FOREIGN_FAQ_BRICKS_ELEMENTS, true)) {
1312 return false;
1313 }
1314
1315 $settings = is_array($element['settings'] ?? null) ? $element['settings'] : [];
1316
1317 // Bricks writes a checkbox as `true`, and clears it by removing the key.
1318 if (empty($settings['faqSchema'])) {
1319 return false;
1320 }
1321
1322 if ('accordion-nested' === $name) {
1323 return !empty($element['children']) && is_array($element['children']);
1324 }
1325
1326 $items = is_array($settings['accordions'] ?? null) ? $settings['accordions'] : [];
1327
1328 foreach ($items as $item) {
1329 if (is_array($item)
1330 && '' !== trim((string) ($item['title'] ?? ''))
1331 && '' !== trim((string) ($item['content'] ?? ''))
1332 ) {
1333 return true;
1334 }
1335 }
1336
1337 return false;
1338 }
1339
1340 /**
1341 * The Bricks element tree that renders for a post.
1342 *
1343 * @since 2.3.1
1344 * @param int $post_id Post being viewed.
1345 * @return array<int,mixed>
1346 */
1347 private function bricks_tree(int $post_id): array {
1348 if (!class_exists('ThinkRank\\SEO\\Builder_Content')) {
1349 $file = THINKRANK_PLUGIN_DIR . 'includes/seo/class-builder-content.php';
1350 if (!file_exists($file)) {
1351 return [];
1352 }
1353 require_once $file;
1354 }
1355
1356 return \ThinkRank\SEO\Builder_Content::bricks_tree($post_id);
1357 }
1358
1359 /**
1360 * Recurse an Elementor element tree looking for a third-party FAQ producer.
1361 *
1362 * @since 2.1.0
1363 * @param array $elements Elementor elements.
1364 * @return bool
1365 */
1366 private function elements_have_foreign_faq(array $elements): bool {
1367 foreach ($elements as $element) {
1368 if (!is_array($element)) {
1369 continue;
1370 }
1371
1372 // Stored JSON, so nothing guarantees the shape: a non-string
1373 // widgetType would be an illegal array offset, not a miss.
1374 $widget = is_string($element['widgetType'] ?? null) ? $element['widgetType'] : '';
1375 $gate = self::FOREIGN_FAQ_WIDGETS[$widget] ?? '';
1376 $settings = is_array($element['settings'] ?? null) ? $element['settings'] : [];
1377
1378 if ($gate !== '' && 'yes' === ($settings[$gate] ?? '')) {
1379 return true;
1380 }
1381
1382 if (!empty($element['elements']) && is_array($element['elements'])
1383 && $this->elements_have_foreign_faq($element['elements'])) {
1384 return true;
1385 }
1386 }
1387
1388 return false;
1389 }
1390
1391 /**
1392 * Build the single FAQ node, if any questions were collected.
1393 *
1394 * Gated on should_emit_faqpage(): every FAQ source in the plugin — the
1395 * block, the Elementor widget, a deployed row and the post-type default —
1396 * funnels through here, so this is the one place that can hold the whole
1397 * plugin's FAQPage back (#494).
1398 *
1399 * @since 1.32.0
1400 * @return array|null
1401 */
1402 private function build_faq_node(): ?array {
1403 if (empty($this->faq_entities) || !$this->should_emit_faqpage()) {
1404 return null;
1405 }
1406
1407 return [
1408 '@type' => 'FAQPage',
1409 'mainEntity' => array_values($this->faq_entities),
1410 ];
1411 }
1412
1413 /**
1414 * Base URL for @id values.
1415 *
1416 * @since 1.32.0
1417 * @return string
1418 */
1419 private function base_url(?array $primary): string {
1420 if (is_singular()) {
1421 $permalink = get_permalink();
1422 if (is_string($permalink) && $permalink !== '') {
1423 return $permalink;
1424 }
1425 }
1426
1427 // Archives are not singular, so fall back to the URL the page entity
1428 // already resolved for itself. Without this every archive would mint the
1429 // same "<home>#collectionpage" @id and two categories would collide.
1430 $url = $primary['schema']['url'] ?? null;
1431 if (is_string($url) && $url !== '') {
1432 return $url;
1433 }
1434
1435 return home_url('/');
1436 }
1437 }
1438