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

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