PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / 2.1.1
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO v2.1.1
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.1.1, at includes/frontend/class-schema-graph.php

1,233 lines 41.2 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 * Third-party Elementor widgets that publish their own FAQPage.
102 *
103 * Maps widgetType to the setting whose 'yes' arms that widget's FAQ schema,
104 * so an accordion used purely as an accordion never suppresses ours.
105 *
106 * @since 2.1.0
107 * @var array<string,string>
108 */
109 private const FOREIGN_FAQ_WIDGETS = [
110 // Essential Addons for Elementor — Advanced Accordion.
111 'eael-adv-accordion' => 'eael_adv_accordion_faq_schema_show',
112 ];
113
114 /**
115 * Singleton instance.
116 *
117 * @var self|null
118 */
119 private static ?self $instance = null;
120
121 /**
122 * Competing page-level entities: ['rank' => int, 'schema' => array, 'type' => string].
123 *
124 * @var array<int,array>
125 */
126 private array $primary_candidates = [];
127
128 /**
129 * Non-competing nodes (Organization, WebSite, BreadcrumbList, HowTo, …).
130 *
131 * @var array<int,array>
132 */
133 private array $supporting = [];
134
135 /**
136 * Merged FAQ questions, keyed by normalized question text.
137 *
138 * @var array<string,array>
139 */
140 private array $faq_entities = [];
141
142 /**
143 * Memoized answer to "should this request emit a FAQPage at all?".
144 *
145 * @since 2.1.0
146 * @var bool|null
147 */
148 private ?bool $emit_faqpage = null;
149
150 /**
151 * Whether FAQ content was taken from the rendered post body (block/widget),
152 * meaning those producers must not emit their own duplicate script.
153 *
154 * @var bool
155 */
156 private bool $absorbed_content_faq = false;
157
158 /**
159 * Guards against collecting the post's FAQ content more than once.
160 *
161 * @var bool
162 */
163 private bool $faq_collected = false;
164
165 /**
166 * Whether a producer has committed to rendering this graph on the request.
167 *
168 * Lazy FAQ collection is gated on it: absorbing a block's questions into a
169 * graph that will never be emitted would silence the block and publish
170 * nothing in its place.
171 *
172 * @var bool
173 */
174 private bool $render_scheduled = false;
175
176 /**
177 * Guards against a second render on the same request.
178 *
179 * @var bool
180 */
181 private bool $rendered = false;
182
183 /**
184 * Get the shared instance.
185 *
186 * @since 1.32.0
187 * @return self
188 */
189 public static function instance(): self {
190 if (null === self::$instance) {
191 self::$instance = new self();
192 }
193
194 return self::$instance;
195 }
196
197 /**
198 * Discard the shared instance. Test seam.
199 *
200 * @since 1.32.0
201 * @return void
202 */
203 public static function reset(): void {
204 self::$instance = null;
205 }
206
207 /**
208 * Register a candidate for the page's single page-level entity.
209 *
210 * A FAQPage is never a candidate in its own right — its questions are merged
211 * into the one FAQ node instead, so a deployed FAQPage and an FAQ block can
212 * never become two competing FAQPage entities.
213 *
214 * @since 1.32.0
215 * @param array $schema Schema array.
216 * @param string $type Schema @type.
217 * @param string $source Producer key from PRIMARY_PRECEDENCE.
218 * @return void
219 */
220 public function add_primary(array $schema, string $type, string $source): void {
221 if (empty($schema)) {
222 return;
223 }
224
225 $type = $this->effective_type($schema, $type);
226
227 if ('FAQPage' === $type && $this->should_emit_faqpage()) {
228 $this->add_faq_entities($schema['mainEntity'] ?? []);
229 return;
230 }
231
232 // A third party owns the page's FAQPage, so ours must not be emitted
233 // (#494). Demote rather than drop: a FAQPage is still the page, and
234 // returning here would leave the URL with no page-level entity at all.
235 if ('FAQPage' === $type) {
236 $schema['@type'] = 'WebPage';
237 unset($schema['mainEntity']);
238 $type = 'WebPage';
239 }
240
241 // A per-post deployment can be something that isn't what the page is
242 // about (an Organization, say). Letting it win the slot would drop the
243 // page's real entity, so it joins the graph as a supporting node.
244 if (!in_array($type, self::PAGE_LEVEL_TYPES, true)) {
245 $this->supporting[] = $schema;
246 return;
247 }
248
249 $this->primary_candidates[] = [
250 'rank' => self::PRIMARY_PRECEDENCE[$source] ?? PHP_INT_MAX,
251 'schema' => $schema,
252 'type' => $type,
253 ];
254 }
255
256 /**
257 * Resolve what a schema actually is, not what it was configured as.
258 *
259 * The two differ whenever a generator falls back — a post type configured
260 * as FAQPage emits a WebPage when the page has no genuine Q&A. Trusting the
261 * configured label there would route a WebPage into FAQ merging and drop it.
262 *
263 * @since 1.32.0
264 * @param array $schema Schema array.
265 * @param string $declared Type the producer declared.
266 * @return string
267 */
268 private function effective_type(array $schema, string $declared): string {
269 $actual = $schema['@type'] ?? '';
270
271 return (is_string($actual) && $actual !== '') ? $actual : $declared;
272 }
273
274 /**
275 * Register a node that does not compete for the page-level slot.
276 *
277 * @since 1.32.0
278 * @param array $schema Schema array.
279 * @param string $type Schema @type.
280 * @return void
281 */
282 public function add_supporting(array $schema, string $type): void {
283 if (empty($schema)) {
284 return;
285 }
286
287 $effective_type = $this->effective_type($schema, $type);
288
289 // A supporting FAQPage never survives as its own node: its questions
290 // merge into the graph's single FAQ node, or are dropped when a third
291 // party already owns the page's FAQPage (#494). Unlike the primary
292 // slot there is nothing to preserve here, so demotion would only add a
293 // second page-level entity beside the real one.
294 if ('FAQPage' === $effective_type) {
295 if ($this->should_emit_faqpage()) {
296 $this->add_faq_entities($schema['mainEntity'] ?? []);
297 }
298 return;
299 }
300
301 // One breadcrumb trail per page. A deployed BreadcrumbList lands here
302 // and output_breadcrumb_schema() adds a second on its own wp_head hook,
303 // so pages ended up with #breadcrumb and #breadcrumb-2 — two conflicting
304 // trails, with the primary node linking to only one of them (#471).
305 // First writer wins.
306 if ('BreadcrumbList' === $effective_type && $this->has_supporting_type('BreadcrumbList')) {
307 return;
308 }
309
310 $this->supporting[] = $schema;
311 }
312
313 /**
314 * Whether a supporting node of the given type has already been collected.
315 *
316 * @since 1.16.0
317 *
318 * @param string $type Schema type.
319 * @return bool
320 */
321 private function has_supporting_type(string $type): bool {
322 foreach ($this->supporting as $node) {
323 if (($node['@type'] ?? '') === $type) {
324 return true;
325 }
326 }
327
328 return false;
329 }
330
331 /**
332 * Merge FAQ questions into the single FAQ node, deduped by question text.
333 *
334 * @since 1.32.0
335 * @param mixed $entities Candidate Question entities.
336 * @return void
337 */
338 public function add_faq_entities($entities): void {
339 if (!is_array($entities)) {
340 return;
341 }
342
343 foreach ($entities as $entity) {
344 if (!is_array($entity)) {
345 continue;
346 }
347
348 $question = isset($entity['name']) ? trim((string) $entity['name']) : '';
349 $answer = isset($entity['acceptedAnswer']['text'])
350 ? trim((string) $entity['acceptedAnswer']['text'])
351 : '';
352
353 if ($question === '' || $answer === '') {
354 continue;
355 }
356
357 $key = strtolower(preg_replace('/\s+/', ' ', $question) ?? $question);
358
359 // First writer wins, so the deliberate per-post deployment keeps its
360 // wording when the same question also appears in a block.
361 if (!isset($this->faq_entities[$key])) {
362 $this->faq_entities[$key] = $entity;
363 }
364 }
365 }
366
367 /**
368 * Whether FAQ content from the post body has been absorbed into the graph.
369 *
370 * The FAQ block and Elementor widget call this to decide whether to skip
371 * their own inline JSON-LD. False (nothing absorbed, or the graph never ran)
372 * leaves their original behaviour untouched.
373 *
374 * @since 1.32.0
375 * @return bool
376 */
377 public function absorbed_content_faq(): bool {
378 $this->maybe_collect_post_faq();
379
380 return $this->absorbed_content_faq;
381 }
382
383 /**
384 * Announce that this graph will be rendered on the current request.
385 *
386 * Called where the render hook is registered, so the graph can tell "I am
387 * about to be emitted" from "nothing will output me" without inspecting
388 * hooks it does not own.
389 *
390 * @since 1.32.0
391 * @return void
392 */
393 public function schedule_render(): void {
394 $this->render_scheduled = true;
395 }
396
397 /**
398 * Collect the queried post's FAQ content if nothing has yet.
399 *
400 * Block themes render the whole template — post content included — from
401 * `get_the_block_template_html()`, and on some flows that happens before
402 * `wp_head` fires. The FAQ block therefore asked whether it had been
403 * absorbed while the graph's own collection pass was still pending, read
404 * false, and emitted a second FAQPage beside the graph's. Collecting on
405 * first ask makes the answer independent of which side runs first; the
406 * result is identical either way, because collection reads `post_content`
407 * rather than anything the render produces.
408 *
409 * @since 1.32.0
410 * @return void
411 */
412 private function maybe_collect_post_faq(): void {
413 if ($this->faq_collected || $this->rendered || !$this->render_scheduled) {
414 return;
415 }
416
417 if (!function_exists('is_singular') || !is_singular()) {
418 return;
419 }
420
421 $post = get_post();
422 if ($post instanceof \WP_Post) {
423 $this->collect_post_faq($post);
424 }
425 }
426
427 /**
428 * Pull FAQ content out of a post's blocks and Elementor data.
429 *
430 * Runs during wp_head, before the body renders, so the block and widget can
431 * see that their content is already accounted for.
432 *
433 * @since 1.32.0
434 * @param \WP_Post $post Post being viewed.
435 * @return void
436 */
437 public function collect_post_faq(\WP_Post $post): void {
438 if ($this->faq_collected) {
439 return;
440 }
441
442 $this->faq_collected = true;
443
444 // Reading post_content directly bypasses the gate the render path gets
445 // for free: behind a password form the FAQ block never renders, so it
446 // never emitted schema. Without this check the graph would publish the
447 // questions and answers of protected content to anyone.
448 if (function_exists('post_password_required') && post_password_required($post)) {
449 return;
450 }
451
452 $this->collect_block_faq($post);
453 $this->collect_elementor_faq($post);
454 }
455
456 /**
457 * Record that a body FAQ producer's content is represented in the graph.
458 *
459 * Deliberately not keyed on the entity count growing: when a block asks the
460 * same question as the per-post deployment, dedup means nothing is added,
461 * but the block's content *is* covered and it must still stay quiet.
462 *
463 * @since 1.32.0
464 * @param array $entities Questions found on that producer.
465 * @return void
466 */
467 private function absorb_content_faq(array $entities): void {
468 if (empty($entities)) {
469 return;
470 }
471
472 $this->add_faq_entities($entities);
473 $this->absorbed_content_faq = true;
474 }
475
476 /**
477 * Collect FAQ questions from thinkrank/faq blocks, including nested ones.
478 *
479 * @since 1.32.0
480 * @param \WP_Post $post Post being viewed.
481 * @return void
482 */
483 private function collect_block_faq(\WP_Post $post): void {
484 if (!function_exists('parse_blocks') || !has_blocks($post->post_content)) {
485 return;
486 }
487
488 $this->walk_blocks(parse_blocks($post->post_content));
489 }
490
491 /**
492 * Recurse a parsed block tree collecting FAQ entries.
493 *
494 * @since 1.32.0
495 * @param array $blocks Parsed blocks.
496 * @return void
497 */
498 private function walk_blocks(array $blocks): void {
499 foreach ($blocks as $block) {
500 if (!is_array($block)) {
501 continue;
502 }
503
504 if (($block['blockName'] ?? '') === self::FAQ_BLOCK) {
505 $attrs = $block['attrs'] ?? [];
506
507 // Mirrors Blocks_Manager: schema is on unless explicitly disabled.
508 $disabled = array_key_exists('outputSchema', $attrs) && false === $attrs['outputSchema'];
509
510 if (!$disabled) {
511 $this->absorb_content_faq($this->questions_from_pairs($attrs['faqs'] ?? []));
512 }
513 }
514
515 if (!empty($block['innerBlocks']) && is_array($block['innerBlocks'])) {
516 $this->walk_blocks($block['innerBlocks']);
517 }
518 }
519 }
520
521 /**
522 * Collect FAQ questions from Elementor FAQ widgets.
523 *
524 * @since 1.32.0
525 * @param \WP_Post $post Post being viewed.
526 * @return void
527 */
528 private function collect_elementor_faq(\WP_Post $post): void {
529 $raw = get_post_meta($post->ID, '_elementor_data', true);
530 if (empty($raw) || !is_string($raw)) {
531 return;
532 }
533
534 $elements = json_decode($raw, true);
535 if (!is_array($elements)) {
536 return;
537 }
538
539 $this->walk_elementor($elements);
540 }
541
542 /**
543 * Recurse an Elementor element tree collecting FAQ entries.
544 *
545 * @since 1.32.0
546 * @param array $elements Elementor elements.
547 * @return void
548 */
549 private function walk_elementor(array $elements): void {
550 foreach ($elements as $element) {
551 if (!is_array($element)) {
552 continue;
553 }
554
555 if (($element['widgetType'] ?? '') === self::FAQ_WIDGET) {
556 $settings = $element['settings'] ?? [];
557
558 // Mirrors FAQ_Widget: schema unless the toggle is off.
559 if ('yes' === ($settings['output_schema'] ?? 'yes')) {
560 $this->absorb_content_faq($this->questions_from_pairs($settings['faqs'] ?? []));
561 }
562 }
563
564 if (!empty($element['elements']) && is_array($element['elements'])) {
565 $this->walk_elementor($element['elements']);
566 }
567 }
568 }
569
570 /**
571 * Turn stored question/answer pairs into Question entities.
572 *
573 * @since 1.32.0
574 * @param mixed $pairs Repeater rows with question/answer keys.
575 * @return array
576 */
577 private function questions_from_pairs($pairs): array {
578 if (!is_array($pairs)) {
579 return [];
580 }
581
582 $entities = [];
583
584 foreach ($pairs as $pair) {
585 if (!is_array($pair)) {
586 continue;
587 }
588
589 $question = isset($pair['question']) ? trim(wp_strip_all_tags((string) $pair['question'])) : '';
590 $answer = isset($pair['answer']) ? trim((string) $pair['answer']) : '';
591
592 if ($question === '' || $answer === '') {
593 continue;
594 }
595
596 $text = wp_kses_post($answer);
597
598 // Mirrors Blocks_Manager::build_faq_schema() by calling the same
599 // builder, so the two paths cannot drift — the per-item image is
600 // resolved from its attachment id, carries intrinsic dimensions,
601 // and disappears if the media was deleted (#418).
602 $text .= \ThinkRank\Editor\Blocks_Manager::faq_image_markup(is_array($pair) ? $pair : []);
603
604 $entities[] = [
605 '@type' => 'Question',
606 'name' => $question,
607 'acceptedAnswer' => [
608 '@type' => 'Answer',
609 'text' => $text,
610 ],
611 ];
612 }
613
614 return $entities;
615 }
616
617 /**
618 * Whether anything has been registered.
619 *
620 * @since 1.32.0
621 * @return bool
622 */
623 public function has_nodes(): bool {
624 return !empty($this->primary_candidates) || !empty($this->supporting) || !empty($this->faq_entities);
625 }
626
627 /**
628 * Assemble and emit the graph. Safe to call more than once.
629 *
630 * @since 1.32.0
631 * @return void
632 */
633 public function render(): void {
634 if ($this->rendered || !$this->has_nodes()) {
635 return;
636 }
637
638 // A 404 response represents no content, so there is nothing for
639 // structured data to describe. The page-level producers already skip
640 // this context, but the site-identity entity does not, so without this
641 // guard every miss — including crawlers probing URLs that never existed
642 // — emits a Person carrying email, telephone and birthDate (#481).
643 if (is_404()) {
644 return;
645 }
646
647 $this->rendered = true;
648
649 $graph = $this->build_graph();
650
651 /**
652 * Filter the assembled schema graph before output.
653 *
654 * Receives every node ThinkRank is about to emit, already deduped and
655 * linked, so add-ons can append or adjust nodes in one place.
656 *
657 * @since 1.32.0
658 *
659 * @param array $graph List of schema nodes ([] suppresses output).
660 */
661 $graph = apply_filters('thinkrank_schema_graph', $graph);
662
663 // Drop empty properties across every node. An empty string is worse
664 // than an absent one — "headline": "" fails Article validation harder
665 // than omitting it — and Schema_Builder::clean_schema_array(), which was
666 // written for exactly this, is never reached from the render path
667 // (#471). Runs after the filter so add-on nodes are cleaned too.
668 $graph = array_values(array_filter(array_map([$this, 'prune_empty_values'], $graph)));
669
670 if (empty($graph)) {
671 return;
672 }
673
674 $json = wp_json_encode(
675 ['@context' => self::SCHEMA_CONTEXT, '@graph' => array_values($graph)],
676 JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT
677 | JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT
678 );
679
680 if (false === $json) {
681 return;
682 }
683
684 echo "<!-- ThinkRank Schema Graph -->\n";
685 echo '<script type="application/ld+json">' . "\n";
686 echo $json . "\n"; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- wp_json_encode with JSON_HEX_* cannot break out of the script block.
687 echo '</script>' . "\n";
688 echo "<!-- /ThinkRank Schema Graph -->\n";
689 }
690
691 /**
692 * Replace an inline entity with an @id reference to an equivalent node.
693 *
694 * Matches on name so a post author is never silently collapsed into the
695 * site's Person entity, and vice versa (#471).
696 *
697 * @since 1.16.0
698 *
699 * @param mixed $inline The inline entity from the primary node.
700 * @param array $candidates Nodes already in the graph, each with an @id.
701 * @return array|null ['@id' => …] when a match is found, null otherwise.
702 */
703 private function link_to_node($inline, array $candidates): ?array {
704 if (!is_array($inline) || empty($candidates)) {
705 return null;
706 }
707
708 // Already a reference.
709 if (isset($inline['@id']) && !isset($inline['name'])) {
710 return null;
711 }
712
713 $inline_name = isset($inline['name']) ? trim((string) $inline['name']) : '';
714
715 if ('' === $inline_name) {
716 return null;
717 }
718
719 foreach ($candidates as $candidate) {
720 $candidate_name = isset($candidate['name']) ? trim((string) $candidate['name']) : '';
721
722 if ('' !== $candidate_name
723 && 0 === strcasecmp($candidate_name, $inline_name)
724 && !empty($candidate['@id'])
725 ) {
726 return ['@id' => $candidate['@id']];
727 }
728 }
729
730 return null;
731 }
732
733 /**
734 * Recursively drop empty properties from a schema node.
735 *
736 * Removes '', [], and null. Deliberately keeps numeric 0, boolean false and
737 * the structural keys, which are all meaningful values.
738 *
739 * @since 1.16.0
740 *
741 * @param mixed $value Node or property value.
742 * @return mixed Cleaned value.
743 */
744 private function prune_empty_values($value) {
745 if (!is_array($value)) {
746 return $value;
747 }
748
749 $cleaned = [];
750
751 foreach ($value as $key => $item) {
752 // Never prune the keys that give a node its identity.
753 if (in_array($key, ['@context', '@type', '@id'], true)) {
754 $cleaned[$key] = $item;
755 continue;
756 }
757
758 if (is_array($item)) {
759 $item = $this->prune_empty_values($item);
760
761 if ([] === $item) {
762 continue;
763 }
764
765 $cleaned[$key] = $item;
766 continue;
767 }
768
769 if (null === $item || '' === $item) {
770 continue;
771 }
772
773 $cleaned[$key] = $item;
774 }
775
776 return $cleaned;
777 }
778
779 /**
780 * Build the linked node list.
781 *
782 * @since 1.32.0
783 * @return array
784 */
785 private function build_graph(): array {
786 $selection = $this->select_primary_set();
787 $primary = $selection['winner'];
788 $siblings = $selection['siblings'];
789 $faq = $this->build_faq_node();
790 $base = $this->base_url($primary);
791
792 // With no other page-level entity, the FAQ node is the page.
793 if (null === $primary && null !== $faq) {
794 $primary = ['schema' => $faq, 'type' => 'FAQPage'];
795 $faq = null;
796 }
797
798 $nodes = [];
799 $primary_id = '';
800 $used_ids = [];
801
802 if (null !== $primary) {
803 $node = $primary['schema'];
804
805 // Key the @id off the node's resolved @type, not the configured one,
806 // so an "Article" setting that renders BlogPosting reads #blogposting.
807 $resolved_type = $this->effective_type($node, $primary['type']);
808
809 $node = $this->assign_id($node, $base . '#' . strtolower($resolved_type), $used_ids);
810 $primary_id = $node['@id'];
811 $nodes['primary'] = $node;
812 }
813
814 // Entities deployed alongside the winner (Pro's Multi-Schema lets a post
815 // carry an Article *and* a Recipe). They lost the page slot but were
816 // deliberately deployed, so they stay in the graph linked to the primary
817 // rather than being dropped.
818 foreach ($siblings as $index => $sibling) {
819 $node = $sibling['schema'];
820
821 $node = $this->assign_id(
822 $node,
823 $base . '#' . strtolower($this->effective_type($node, $sibling['type'])),
824 $used_ids
825 );
826
827 if ($primary_id !== '' && $node['@id'] !== $primary_id) {
828 $node['isPartOf'] = $node['isPartOf'] ?? ['@id' => $primary_id];
829 $node['mainEntityOfPage'] = $node['mainEntityOfPage'] ?? ['@id' => $primary_id];
830 }
831
832 $nodes['sibling_' . $index] = $node;
833 }
834
835 if (null !== $faq) {
836 $faq = $this->assign_id($faq, $base . '#faq', $used_ids);
837
838 if ($primary_id !== '') {
839 $faq['isPartOf'] = ['@id' => $primary_id];
840 $faq['mainEntityOfPage'] = ['@id' => $primary_id];
841 }
842
843 $nodes['faq'] = $faq;
844 }
845
846 $website_id = '';
847 $breadcrumb_id = '';
848 $organization_nodes = [];
849 $person_nodes = [];
850
851 foreach ($this->supporting as $index => $node) {
852 $type = $node['@type'] ?? '';
853
854 if ('BreadcrumbList' === $type) {
855 $node = $this->assign_id($node, $base . '#breadcrumb', $used_ids);
856 $breadcrumb_id = $node['@id'];
857 } elseif ('WebSite' === $type) {
858 $node = $this->assign_id($node, home_url('/#website'), $used_ids);
859 $website_id = $node['@id'];
860 } elseif ('Organization' === $type) {
861 $node = $this->assign_id($node, home_url('/#organization'), $used_ids);
862 $organization_nodes[] = $node;
863 } elseif (in_array($type, self::SITE_LEVEL_TYPES, true)) {
864 // Site-level entities describe the site, not the page, so their
865 // @id must be stable across URLs. Falling through to the
866 // page-scoped branch minted a fresh identity on every URL, so
867 // one business became N entities in a crawler's graph and
868 // nothing could reference it by @id (#471).
869 // One entity, emitted once. The site identity and a per-post
870 // deployment describe the same person or business, so both
871 // arrive here claiming the same @id. assign_id() would resolve
872 // that collision by minting "#person-2", turning a duplicate
873 // into two competing entities that split the identity a
874 // knowledge graph is meant to consolidate (#479).
875 $duplicate_key = $this->find_same_entity($nodes, $type, $node);
876
877 if (null !== $duplicate_key) {
878 $nodes[$duplicate_key] = $this->merge_entity($nodes[$duplicate_key], $node);
879 continue;
880 }
881
882 $node = $this->assign_id($node, home_url('/#' . strtolower($type)), $used_ids);
883
884 if ('Person' === $type) {
885 $person_nodes[] = $node;
886 }
887 } elseif (is_string($type) && $type !== '') {
888 $node = $this->assign_id($node, $base . '#' . strtolower($type), $used_ids);
889 }
890
891 $nodes['supporting_' . $index] = $node;
892 }
893
894 // Link the page entity to the site and its breadcrumb trail.
895 if (isset($nodes['primary'])) {
896 if ($website_id !== '' && !isset($nodes['primary']['isPartOf'])) {
897 $nodes['primary']['isPartOf'] = ['@id' => $website_id];
898 }
899 if ($breadcrumb_id !== '' && !isset($nodes['primary']['breadcrumb'])) {
900 $nodes['primary']['breadcrumb'] = ['@id' => $breadcrumb_id];
901 }
902
903 // Point publisher/author at the full nodes already in the graph.
904 // They were emitted inline with no @id, so the graph described the
905 // same publisher twice — and the richer node, the one carrying the
906 // logo Google needs for Article, was not the one publisher
907 // referenced (#471).
908 //
909 // Only collapse when the inline object names the SAME entity. A post
910 // author and the site's Person entity are frequently different
911 // people, so matching on position rather than identity would
912 // misattribute authorship.
913 if (isset($nodes['primary']['publisher'])) {
914 $linked = $this->link_to_node($nodes['primary']['publisher'], $organization_nodes);
915 if (null !== $linked) {
916 $nodes['primary']['publisher'] = $linked;
917 }
918 }
919
920 if (isset($nodes['primary']['author'])) {
921 $linked = $this->link_to_node($nodes['primary']['author'], $person_nodes);
922 if (null !== $linked) {
923 $nodes['primary']['author'] = $linked;
924 }
925 }
926 }
927
928 // The graph carries @context once; per-node copies are redundant.
929 foreach ($nodes as $key => $node) {
930 unset($node['@context']);
931 $nodes[$key] = $node;
932 }
933
934 return array_values($nodes);
935 }
936
937 /**
938 * Pick the page-level entity, plus any deployed alongside it.
939 *
940 * Precedence arbitrates between *sources*, not between entities: a per-post
941 * deployment beats the post-type-wide default, and the losing source is
942 * dropped so one URL stops claiming to be several unrelated things (#355).
943 *
944 * Within the winning source every entity is kept. Deploying more than one
945 * page-level schema on a post is exactly what Pro's Multi-Schema feature
946 * exists to do (an Article that is also a Recipe), and silently discarding
947 * the extras would delete markup the user deliberately published.
948 *
949 * @since 1.32.0
950 * @return array{winner: array|null, siblings: array<int,array>}
951 */
952 private function select_primary_set(): array {
953 if (empty($this->primary_candidates)) {
954 return ['winner' => null, 'siblings' => []];
955 }
956
957 $best = PHP_INT_MAX;
958 foreach ($this->primary_candidates as $candidate) {
959 if ($candidate['rank'] < $best) {
960 $best = $candidate['rank'];
961 }
962 }
963
964 $kept = [];
965 foreach ($this->primary_candidates as $candidate) {
966 if ($candidate['rank'] === $best) {
967 $kept[] = $candidate;
968 }
969 }
970
971 return ['winner' => array_shift($kept), 'siblings' => array_values($kept)];
972 }
973
974 /**
975 * Find an already-placed node describing the same entity as $node.
976 *
977 * Identity is `email` when both carry one — two people can share a name,
978 * but not a mailbox — and a case-insensitive `name` match otherwise. A node
979 * with neither never matches, so an unidentifiable entity is kept rather
980 * than folded into an unrelated one.
981 *
982 * @since 2.0.2
983 *
984 * @param array $nodes Nodes placed so far, keyed.
985 * @param string $type Schema type to match within.
986 * @param array $node Candidate node.
987 * @return string|null Key of the matching node, or null.
988 */
989 private function find_same_entity(array $nodes, string $type, array $node): ?string {
990 $email = isset($node['email']) ? strtolower(trim((string) $node['email'])) : '';
991 $name = isset($node['name']) ? trim((string) $node['name']) : '';
992
993 if ('' === $email && '' === $name) {
994 return null;
995 }
996
997 foreach ($nodes as $key => $placed) {
998 if (($placed['@type'] ?? '') !== $type) {
999 continue;
1000 }
1001
1002 $placed_email = isset($placed['email']) ? strtolower(trim((string) $placed['email'])) : '';
1003
1004 if ('' !== $email && '' !== $placed_email) {
1005 if ($email === $placed_email) {
1006 return (string) $key;
1007 }
1008 continue;
1009 }
1010
1011 $placed_name = isset($placed['name']) ? trim((string) $placed['name']) : '';
1012
1013 if ('' !== $name && '' !== $placed_name && 0 === strcasecmp($name, $placed_name)) {
1014 return (string) $key;
1015 }
1016 }
1017
1018 return null;
1019 }
1020
1021 /**
1022 * Fold a duplicate entity into the node already in the graph.
1023 *
1024 * Fills gaps only: a property the placed node already carries wins, so the
1025 * node that claimed the identity first keeps it, @id included. The
1026 * duplicate can still contribute properties the first copy lacked, which is
1027 * the point — between them they describe the entity more completely than
1028 * either does alone.
1029 *
1030 * @since 2.0.2
1031 *
1032 * @param array $placed Node already in the graph.
1033 * @param array $duplicate Node describing the same entity.
1034 * @return array Merged node.
1035 */
1036 private function merge_entity(array $placed, array $duplicate): array {
1037 foreach ($duplicate as $key => $value) {
1038 if ('@id' === $key || '@type' === $key || '@context' === $key) {
1039 continue;
1040 }
1041
1042 if (!isset($placed[$key]) || '' === $placed[$key] || [] === $placed[$key]) {
1043 $placed[$key] = $value;
1044 }
1045 }
1046
1047 return $placed;
1048 }
1049
1050 /**
1051 * Give a node a unique @id, keeping one it already carries.
1052 *
1053 * Two entities of the same type on one page (two deployed Articles, say)
1054 * would otherwise mint the same @id, which makes the graph ambiguous about
1055 * which node a reference points at.
1056 *
1057 * @since 1.32.0
1058 * @param array $node Node to stamp.
1059 * @param string $fallback @id to use when the node has none.
1060 * @param array $used Already-issued @id values, updated by reference.
1061 * @return array
1062 */
1063 private function assign_id(array $node, string $fallback, array &$used): array {
1064 $id = (isset($node['@id']) && is_string($node['@id']) && $node['@id'] !== '')
1065 ? $node['@id']
1066 : $fallback;
1067
1068 if (isset($used[$id])) {
1069 $suffix = 2;
1070 while (isset($used[$id . '-' . $suffix])) {
1071 $suffix++;
1072 }
1073 $id .= '-' . $suffix;
1074 }
1075
1076 $used[$id] = true;
1077 $node['@id'] = $id;
1078
1079 return $node;
1080 }
1081
1082 /**
1083 * Whether ThinkRank should emit a FAQPage on this request.
1084 *
1085 * ThinkRank emitted its FAQPage unconditionally, so a URL whose FAQ was
1086 * already published by another plugin carried two FAQPage entities — each
1087 * valid on its own, together ambiguous about which one describes the page
1088 * (#494).
1089 *
1090 * The answer cannot be read off the rendered page. Third-party FAQ schema
1091 * is typically printed in `wp_footer` from data its widget only gathers
1092 * while the body renders, which is long after this graph goes out in
1093 * `wp_head`; at the moment of the decision the foreign FAQPage does not
1094 * exist yet, in the buffer or anywhere else. Detection therefore inspects
1095 * the stored post content, the same way collect_elementor_faq() finds
1096 * ThinkRank's own widget.
1097 *
1098 * @since 2.1.0
1099 * @return bool
1100 */
1101 private function should_emit_faqpage(): bool {
1102 if (null !== $this->emit_faqpage) {
1103 return $this->emit_faqpage;
1104 }
1105
1106 $post = (function_exists('is_singular') && is_singular()) ? get_post() : null;
1107 if (!$post instanceof \WP_Post) {
1108 $post = null;
1109 }
1110
1111 $emit = !$this->has_foreign_faq_source($post);
1112
1113 /**
1114 * Filter whether ThinkRank emits its FAQPage entity.
1115 *
1116 * Return false from a plugin that publishes its own FAQPage on the same
1117 * URL and ThinkRank drops its FAQ node, leaving the page one
1118 * unambiguous FAQPage. ThinkRank already defaults this to false for the
1119 * FAQ sources it recognises, so the filter is for the ones it does not
1120 * — or for forcing its FAQPage back on.
1121 *
1122 * @since 2.1.0
1123 *
1124 * @param bool $emit Whether to emit the FAQPage node.
1125 * @param \WP_Post|null $post Post being viewed, or null when not singular.
1126 */
1127 $this->emit_faqpage = (bool) apply_filters('thinkrank_emit_faqpage', $emit, $post);
1128
1129 return $this->emit_faqpage;
1130 }
1131
1132 /**
1133 * Whether another plugin publishes a FAQPage for this post.
1134 *
1135 * @since 2.1.0
1136 * @param \WP_Post|null $post Post being viewed.
1137 * @return bool
1138 */
1139 private function has_foreign_faq_source(?\WP_Post $post): bool {
1140 if (!$post instanceof \WP_Post) {
1141 return false;
1142 }
1143
1144 $raw = get_post_meta($post->ID, '_elementor_data', true);
1145 if (empty($raw) || !is_string($raw)) {
1146 return false;
1147 }
1148
1149 $elements = json_decode($raw, true);
1150
1151 return is_array($elements) && $this->elements_have_foreign_faq($elements);
1152 }
1153
1154 /**
1155 * Recurse an Elementor element tree looking for a third-party FAQ producer.
1156 *
1157 * @since 2.1.0
1158 * @param array $elements Elementor elements.
1159 * @return bool
1160 */
1161 private function elements_have_foreign_faq(array $elements): bool {
1162 foreach ($elements as $element) {
1163 if (!is_array($element)) {
1164 continue;
1165 }
1166
1167 // Stored JSON, so nothing guarantees the shape: a non-string
1168 // widgetType would be an illegal array offset, not a miss.
1169 $widget = is_string($element['widgetType'] ?? null) ? $element['widgetType'] : '';
1170 $gate = self::FOREIGN_FAQ_WIDGETS[$widget] ?? '';
1171 $settings = is_array($element['settings'] ?? null) ? $element['settings'] : [];
1172
1173 if ($gate !== '' && 'yes' === ($settings[$gate] ?? '')) {
1174 return true;
1175 }
1176
1177 if (!empty($element['elements']) && is_array($element['elements'])
1178 && $this->elements_have_foreign_faq($element['elements'])) {
1179 return true;
1180 }
1181 }
1182
1183 return false;
1184 }
1185
1186 /**
1187 * Build the single FAQ node, if any questions were collected.
1188 *
1189 * Gated on should_emit_faqpage(): every FAQ source in the plugin — the
1190 * block, the Elementor widget, a deployed row and the post-type default —
1191 * funnels through here, so this is the one place that can hold the whole
1192 * plugin's FAQPage back (#494).
1193 *
1194 * @since 1.32.0
1195 * @return array|null
1196 */
1197 private function build_faq_node(): ?array {
1198 if (empty($this->faq_entities) || !$this->should_emit_faqpage()) {
1199 return null;
1200 }
1201
1202 return [
1203 '@type' => 'FAQPage',
1204 'mainEntity' => array_values($this->faq_entities),
1205 ];
1206 }
1207
1208 /**
1209 * Base URL for @id values.
1210 *
1211 * @since 1.32.0
1212 * @return string
1213 */
1214 private function base_url(?array $primary): string {
1215 if (is_singular()) {
1216 $permalink = get_permalink();
1217 if (is_string($permalink) && $permalink !== '') {
1218 return $permalink;
1219 }
1220 }
1221
1222 // Archives are not singular, so fall back to the URL the page entity
1223 // already resolved for itself. Without this every archive would mint the
1224 // same "<home>#collectionpage" @id and two categories would collide.
1225 $url = $primary['schema']['url'] ?? null;
1226 if (is_string($url) && $url !== '') {
1227 return $url;
1228 }
1229
1230 return home_url('/');
1231 }
1232 }
1233