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

816 lines 25.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 * Which source wins when several subsystems describe the page.
48 *
49 * Lower wins. Per-post schema deployed from the editor's Schema tab is a
50 * deliberate per-post decision, so it outranks the post-type-wide default.
51 *
52 * @var array<string,int>
53 */
54 private const PRIMARY_PRECEDENCE = [
55 'schema_manager' => 10,
56 'global_seo' => 20,
57 ];
58
59 /**
60 * Types that can legitimately be *the* entity a URL is about.
61 *
62 * Anything outside this set — Organization, Person, WebSite, LocalBusiness,
63 * or a type a future release starts deploying — is emitted as a supporting
64 * node instead of competing. Deliberately an allowlist: an unrecognised type
65 * demoted to supporting merely adds a node, whereas letting a non-page-level
66 * type win the slot deletes the page's real entity.
67 *
68 * @var array<int,string>
69 */
70 private const PAGE_LEVEL_TYPES = [
71 'Article', 'BlogPosting', 'NewsArticle', 'ScholarlyArticle', 'TechArticle',
72 'TechnicalArticle', 'Report', 'WebPage', 'AboutPage', 'ContactPage',
73 'ProfilePage', 'ItemPage', 'FAQPage', 'QAPage', 'CollectionPage',
74 'Product', 'Event', 'Recipe', 'Course', 'JobPosting', 'SoftwareApplication',
75 'Book', 'Movie', 'Service', 'ImageObject', 'VideoObject',
76 ];
77
78 /**
79 * Gutenberg FAQ block name.
80 */
81 private const FAQ_BLOCK = 'thinkrank/faq';
82
83 /**
84 * Elementor FAQ widget name.
85 */
86 private const FAQ_WIDGET = 'thinkrank-faq';
87
88 /**
89 * Singleton instance.
90 *
91 * @var self|null
92 */
93 private static ?self $instance = null;
94
95 /**
96 * Competing page-level entities: ['rank' => int, 'schema' => array, 'type' => string].
97 *
98 * @var array<int,array>
99 */
100 private array $primary_candidates = [];
101
102 /**
103 * Non-competing nodes (Organization, WebSite, BreadcrumbList, HowTo, …).
104 *
105 * @var array<int,array>
106 */
107 private array $supporting = [];
108
109 /**
110 * Merged FAQ questions, keyed by normalized question text.
111 *
112 * @var array<string,array>
113 */
114 private array $faq_entities = [];
115
116 /**
117 * Whether FAQ content was taken from the rendered post body (block/widget),
118 * meaning those producers must not emit their own duplicate script.
119 *
120 * @var bool
121 */
122 private bool $absorbed_content_faq = false;
123
124 /**
125 * Guards against collecting the post's FAQ content more than once.
126 *
127 * @var bool
128 */
129 private bool $faq_collected = false;
130
131 /**
132 * Whether a producer has committed to rendering this graph on the request.
133 *
134 * Lazy FAQ collection is gated on it: absorbing a block's questions into a
135 * graph that will never be emitted would silence the block and publish
136 * nothing in its place.
137 *
138 * @var bool
139 */
140 private bool $render_scheduled = false;
141
142 /**
143 * Guards against a second render on the same request.
144 *
145 * @var bool
146 */
147 private bool $rendered = false;
148
149 /**
150 * Get the shared instance.
151 *
152 * @since 1.32.0
153 * @return self
154 */
155 public static function instance(): self {
156 if (null === self::$instance) {
157 self::$instance = new self();
158 }
159
160 return self::$instance;
161 }
162
163 /**
164 * Discard the shared instance. Test seam.
165 *
166 * @since 1.32.0
167 * @return void
168 */
169 public static function reset(): void {
170 self::$instance = null;
171 }
172
173 /**
174 * Register a candidate for the page's single page-level entity.
175 *
176 * A FAQPage is never a candidate in its own right — its questions are merged
177 * into the one FAQ node instead, so a deployed FAQPage and an FAQ block can
178 * never become two competing FAQPage entities.
179 *
180 * @since 1.32.0
181 * @param array $schema Schema array.
182 * @param string $type Schema @type.
183 * @param string $source Producer key from PRIMARY_PRECEDENCE.
184 * @return void
185 */
186 public function add_primary(array $schema, string $type, string $source): void {
187 if (empty($schema)) {
188 return;
189 }
190
191 $type = $this->effective_type($schema, $type);
192
193 if ('FAQPage' === $type) {
194 $this->add_faq_entities($schema['mainEntity'] ?? []);
195 return;
196 }
197
198 // A per-post deployment can be something that isn't what the page is
199 // about (an Organization, say). Letting it win the slot would drop the
200 // page's real entity, so it joins the graph as a supporting node.
201 if (!in_array($type, self::PAGE_LEVEL_TYPES, true)) {
202 $this->supporting[] = $schema;
203 return;
204 }
205
206 $this->primary_candidates[] = [
207 'rank' => self::PRIMARY_PRECEDENCE[$source] ?? PHP_INT_MAX,
208 'schema' => $schema,
209 'type' => $type,
210 ];
211 }
212
213 /**
214 * Resolve what a schema actually is, not what it was configured as.
215 *
216 * The two differ whenever a generator falls back — a post type configured
217 * as FAQPage emits a WebPage when the page has no genuine Q&A. Trusting the
218 * configured label there would route a WebPage into FAQ merging and drop it.
219 *
220 * @since 1.32.0
221 * @param array $schema Schema array.
222 * @param string $declared Type the producer declared.
223 * @return string
224 */
225 private function effective_type(array $schema, string $declared): string {
226 $actual = $schema['@type'] ?? '';
227
228 return (is_string($actual) && $actual !== '') ? $actual : $declared;
229 }
230
231 /**
232 * Register a node that does not compete for the page-level slot.
233 *
234 * @since 1.32.0
235 * @param array $schema Schema array.
236 * @param string $type Schema @type.
237 * @return void
238 */
239 public function add_supporting(array $schema, string $type): void {
240 if (empty($schema)) {
241 return;
242 }
243
244 if ('FAQPage' === $this->effective_type($schema, $type)) {
245 $this->add_faq_entities($schema['mainEntity'] ?? []);
246 return;
247 }
248
249 $this->supporting[] = $schema;
250 }
251
252 /**
253 * Merge FAQ questions into the single FAQ node, deduped by question text.
254 *
255 * @since 1.32.0
256 * @param mixed $entities Candidate Question entities.
257 * @return void
258 */
259 public function add_faq_entities($entities): void {
260 if (!is_array($entities)) {
261 return;
262 }
263
264 foreach ($entities as $entity) {
265 if (!is_array($entity)) {
266 continue;
267 }
268
269 $question = isset($entity['name']) ? trim((string) $entity['name']) : '';
270 $answer = isset($entity['acceptedAnswer']['text'])
271 ? trim((string) $entity['acceptedAnswer']['text'])
272 : '';
273
274 if ($question === '' || $answer === '') {
275 continue;
276 }
277
278 $key = strtolower(preg_replace('/\s+/', ' ', $question) ?? $question);
279
280 // First writer wins, so the deliberate per-post deployment keeps its
281 // wording when the same question also appears in a block.
282 if (!isset($this->faq_entities[$key])) {
283 $this->faq_entities[$key] = $entity;
284 }
285 }
286 }
287
288 /**
289 * Whether FAQ content from the post body has been absorbed into the graph.
290 *
291 * The FAQ block and Elementor widget call this to decide whether to skip
292 * their own inline JSON-LD. False (nothing absorbed, or the graph never ran)
293 * leaves their original behaviour untouched.
294 *
295 * @since 1.32.0
296 * @return bool
297 */
298 public function absorbed_content_faq(): bool {
299 $this->maybe_collect_post_faq();
300
301 return $this->absorbed_content_faq;
302 }
303
304 /**
305 * Announce that this graph will be rendered on the current request.
306 *
307 * Called where the render hook is registered, so the graph can tell "I am
308 * about to be emitted" from "nothing will output me" without inspecting
309 * hooks it does not own.
310 *
311 * @since 1.32.0
312 * @return void
313 */
314 public function schedule_render(): void {
315 $this->render_scheduled = true;
316 }
317
318 /**
319 * Collect the queried post's FAQ content if nothing has yet.
320 *
321 * Block themes render the whole template — post content included — from
322 * `get_the_block_template_html()`, and on some flows that happens before
323 * `wp_head` fires. The FAQ block therefore asked whether it had been
324 * absorbed while the graph's own collection pass was still pending, read
325 * false, and emitted a second FAQPage beside the graph's. Collecting on
326 * first ask makes the answer independent of which side runs first; the
327 * result is identical either way, because collection reads `post_content`
328 * rather than anything the render produces.
329 *
330 * @since 1.32.0
331 * @return void
332 */
333 private function maybe_collect_post_faq(): void {
334 if ($this->faq_collected || $this->rendered || !$this->render_scheduled) {
335 return;
336 }
337
338 if (!function_exists('is_singular') || !is_singular()) {
339 return;
340 }
341
342 $post = get_post();
343 if ($post instanceof \WP_Post) {
344 $this->collect_post_faq($post);
345 }
346 }
347
348 /**
349 * Pull FAQ content out of a post's blocks and Elementor data.
350 *
351 * Runs during wp_head, before the body renders, so the block and widget can
352 * see that their content is already accounted for.
353 *
354 * @since 1.32.0
355 * @param \WP_Post $post Post being viewed.
356 * @return void
357 */
358 public function collect_post_faq(\WP_Post $post): void {
359 if ($this->faq_collected) {
360 return;
361 }
362
363 $this->faq_collected = true;
364
365 // Reading post_content directly bypasses the gate the render path gets
366 // for free: behind a password form the FAQ block never renders, so it
367 // never emitted schema. Without this check the graph would publish the
368 // questions and answers of protected content to anyone.
369 if (function_exists('post_password_required') && post_password_required($post)) {
370 return;
371 }
372
373 $this->collect_block_faq($post);
374 $this->collect_elementor_faq($post);
375 }
376
377 /**
378 * Record that a body FAQ producer's content is represented in the graph.
379 *
380 * Deliberately not keyed on the entity count growing: when a block asks the
381 * same question as the per-post deployment, dedup means nothing is added,
382 * but the block's content *is* covered and it must still stay quiet.
383 *
384 * @since 1.32.0
385 * @param array $entities Questions found on that producer.
386 * @return void
387 */
388 private function absorb_content_faq(array $entities): void {
389 if (empty($entities)) {
390 return;
391 }
392
393 $this->add_faq_entities($entities);
394 $this->absorbed_content_faq = true;
395 }
396
397 /**
398 * Collect FAQ questions from thinkrank/faq blocks, including nested ones.
399 *
400 * @since 1.32.0
401 * @param \WP_Post $post Post being viewed.
402 * @return void
403 */
404 private function collect_block_faq(\WP_Post $post): void {
405 if (!function_exists('parse_blocks') || !has_blocks($post->post_content)) {
406 return;
407 }
408
409 $this->walk_blocks(parse_blocks($post->post_content));
410 }
411
412 /**
413 * Recurse a parsed block tree collecting FAQ entries.
414 *
415 * @since 1.32.0
416 * @param array $blocks Parsed blocks.
417 * @return void
418 */
419 private function walk_blocks(array $blocks): void {
420 foreach ($blocks as $block) {
421 if (!is_array($block)) {
422 continue;
423 }
424
425 if (($block['blockName'] ?? '') === self::FAQ_BLOCK) {
426 $attrs = $block['attrs'] ?? [];
427
428 // Mirrors Blocks_Manager: schema is on unless explicitly disabled.
429 $disabled = array_key_exists('outputSchema', $attrs) && false === $attrs['outputSchema'];
430
431 if (!$disabled) {
432 $this->absorb_content_faq($this->questions_from_pairs($attrs['faqs'] ?? []));
433 }
434 }
435
436 if (!empty($block['innerBlocks']) && is_array($block['innerBlocks'])) {
437 $this->walk_blocks($block['innerBlocks']);
438 }
439 }
440 }
441
442 /**
443 * Collect FAQ questions from Elementor FAQ widgets.
444 *
445 * @since 1.32.0
446 * @param \WP_Post $post Post being viewed.
447 * @return void
448 */
449 private function collect_elementor_faq(\WP_Post $post): void {
450 $raw = get_post_meta($post->ID, '_elementor_data', true);
451 if (empty($raw) || !is_string($raw)) {
452 return;
453 }
454
455 $elements = json_decode($raw, true);
456 if (!is_array($elements)) {
457 return;
458 }
459
460 $this->walk_elementor($elements);
461 }
462
463 /**
464 * Recurse an Elementor element tree collecting FAQ entries.
465 *
466 * @since 1.32.0
467 * @param array $elements Elementor elements.
468 * @return void
469 */
470 private function walk_elementor(array $elements): void {
471 foreach ($elements as $element) {
472 if (!is_array($element)) {
473 continue;
474 }
475
476 if (($element['widgetType'] ?? '') === self::FAQ_WIDGET) {
477 $settings = $element['settings'] ?? [];
478
479 // Mirrors FAQ_Widget: schema unless the toggle is off.
480 if ('yes' === ($settings['output_schema'] ?? 'yes')) {
481 $this->absorb_content_faq($this->questions_from_pairs($settings['faqs'] ?? []));
482 }
483 }
484
485 if (!empty($element['elements']) && is_array($element['elements'])) {
486 $this->walk_elementor($element['elements']);
487 }
488 }
489 }
490
491 /**
492 * Turn stored question/answer pairs into Question entities.
493 *
494 * @since 1.32.0
495 * @param mixed $pairs Repeater rows with question/answer keys.
496 * @return array
497 */
498 private function questions_from_pairs($pairs): array {
499 if (!is_array($pairs)) {
500 return [];
501 }
502
503 $entities = [];
504
505 foreach ($pairs as $pair) {
506 if (!is_array($pair)) {
507 continue;
508 }
509
510 $question = isset($pair['question']) ? trim(wp_strip_all_tags((string) $pair['question'])) : '';
511 $answer = isset($pair['answer']) ? trim((string) $pair['answer']) : '';
512
513 if ($question === '' || $answer === '') {
514 continue;
515 }
516
517 $text = wp_kses_post($answer);
518
519 // Mirrors Blocks_Manager::build_faq_schema(): a per-item image is
520 // carried inside the answer HTML (Yoast-style).
521 $image_url = isset($pair['imageUrl']) ? esc_url((string) $pair['imageUrl']) : '';
522 if ($image_url !== '') {
523 $image_alt = isset($pair['imageAlt']) ? esc_attr((string) $pair['imageAlt']) : '';
524 $text .= ' <img src="' . $image_url . '" alt="' . $image_alt . '" />';
525 }
526
527 $entities[] = [
528 '@type' => 'Question',
529 'name' => $question,
530 'acceptedAnswer' => [
531 '@type' => 'Answer',
532 'text' => $text,
533 ],
534 ];
535 }
536
537 return $entities;
538 }
539
540 /**
541 * Whether anything has been registered.
542 *
543 * @since 1.32.0
544 * @return bool
545 */
546 public function has_nodes(): bool {
547 return !empty($this->primary_candidates) || !empty($this->supporting) || !empty($this->faq_entities);
548 }
549
550 /**
551 * Assemble and emit the graph. Safe to call more than once.
552 *
553 * @since 1.32.0
554 * @return void
555 */
556 public function render(): void {
557 if ($this->rendered || !$this->has_nodes()) {
558 return;
559 }
560
561 $this->rendered = true;
562
563 $graph = $this->build_graph();
564
565 /**
566 * Filter the assembled schema graph before output.
567 *
568 * Receives every node ThinkRank is about to emit, already deduped and
569 * linked, so add-ons can append or adjust nodes in one place.
570 *
571 * @since 1.32.0
572 *
573 * @param array $graph List of schema nodes ([] suppresses output).
574 */
575 $graph = apply_filters('thinkrank_schema_graph', $graph);
576
577 if (empty($graph)) {
578 return;
579 }
580
581 $json = wp_json_encode(
582 ['@context' => self::SCHEMA_CONTEXT, '@graph' => array_values($graph)],
583 JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT
584 | JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT
585 );
586
587 if (false === $json) {
588 return;
589 }
590
591 echo "<!-- ThinkRank Schema Graph -->\n";
592 echo '<script type="application/ld+json">' . "\n";
593 echo $json . "\n"; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- wp_json_encode with JSON_HEX_* cannot break out of the script block.
594 echo '</script>' . "\n";
595 echo "<!-- /ThinkRank Schema Graph -->\n";
596 }
597
598 /**
599 * Build the linked node list.
600 *
601 * @since 1.32.0
602 * @return array
603 */
604 private function build_graph(): array {
605 $selection = $this->select_primary_set();
606 $primary = $selection['winner'];
607 $siblings = $selection['siblings'];
608 $faq = $this->build_faq_node();
609 $base = $this->base_url($primary);
610
611 // With no other page-level entity, the FAQ node is the page.
612 if (null === $primary && null !== $faq) {
613 $primary = ['schema' => $faq, 'type' => 'FAQPage'];
614 $faq = null;
615 }
616
617 $nodes = [];
618 $primary_id = '';
619 $used_ids = [];
620
621 if (null !== $primary) {
622 $node = $primary['schema'];
623
624 // Key the @id off the node's resolved @type, not the configured one,
625 // so an "Article" setting that renders BlogPosting reads #blogposting.
626 $resolved_type = $this->effective_type($node, $primary['type']);
627
628 $node = $this->assign_id($node, $base . '#' . strtolower($resolved_type), $used_ids);
629 $primary_id = $node['@id'];
630 $nodes['primary'] = $node;
631 }
632
633 // Entities deployed alongside the winner (Pro's Multi-Schema lets a post
634 // carry an Article *and* a Recipe). They lost the page slot but were
635 // deliberately deployed, so they stay in the graph linked to the primary
636 // rather than being dropped.
637 foreach ($siblings as $index => $sibling) {
638 $node = $sibling['schema'];
639
640 $node = $this->assign_id(
641 $node,
642 $base . '#' . strtolower($this->effective_type($node, $sibling['type'])),
643 $used_ids
644 );
645
646 if ($primary_id !== '' && $node['@id'] !== $primary_id) {
647 $node['isPartOf'] = $node['isPartOf'] ?? ['@id' => $primary_id];
648 $node['mainEntityOfPage'] = $node['mainEntityOfPage'] ?? ['@id' => $primary_id];
649 }
650
651 $nodes['sibling_' . $index] = $node;
652 }
653
654 if (null !== $faq) {
655 $faq = $this->assign_id($faq, $base . '#faq', $used_ids);
656
657 if ($primary_id !== '') {
658 $faq['isPartOf'] = ['@id' => $primary_id];
659 $faq['mainEntityOfPage'] = ['@id' => $primary_id];
660 }
661
662 $nodes['faq'] = $faq;
663 }
664
665 $website_id = '';
666 $breadcrumb_id = '';
667
668 foreach ($this->supporting as $index => $node) {
669 $type = $node['@type'] ?? '';
670
671 if ('BreadcrumbList' === $type) {
672 $node = $this->assign_id($node, $base . '#breadcrumb', $used_ids);
673 $breadcrumb_id = $node['@id'];
674 } elseif ('WebSite' === $type) {
675 $node = $this->assign_id($node, home_url('/#website'), $used_ids);
676 $website_id = $node['@id'];
677 } elseif ('Organization' === $type) {
678 $node = $this->assign_id($node, home_url('/#organization'), $used_ids);
679 } elseif (is_string($type) && $type !== '') {
680 $node = $this->assign_id($node, $base . '#' . strtolower($type), $used_ids);
681 }
682
683 $nodes['supporting_' . $index] = $node;
684 }
685
686 // Link the page entity to the site and its breadcrumb trail.
687 if (isset($nodes['primary'])) {
688 if ($website_id !== '' && !isset($nodes['primary']['isPartOf'])) {
689 $nodes['primary']['isPartOf'] = ['@id' => $website_id];
690 }
691 if ($breadcrumb_id !== '' && !isset($nodes['primary']['breadcrumb'])) {
692 $nodes['primary']['breadcrumb'] = ['@id' => $breadcrumb_id];
693 }
694 }
695
696 // The graph carries @context once; per-node copies are redundant.
697 foreach ($nodes as $key => $node) {
698 unset($node['@context']);
699 $nodes[$key] = $node;
700 }
701
702 return array_values($nodes);
703 }
704
705 /**
706 * Pick the page-level entity, plus any deployed alongside it.
707 *
708 * Precedence arbitrates between *sources*, not between entities: a per-post
709 * deployment beats the post-type-wide default, and the losing source is
710 * dropped so one URL stops claiming to be several unrelated things (#355).
711 *
712 * Within the winning source every entity is kept. Deploying more than one
713 * page-level schema on a post is exactly what Pro's Multi-Schema feature
714 * exists to do (an Article that is also a Recipe), and silently discarding
715 * the extras would delete markup the user deliberately published.
716 *
717 * @since 1.32.0
718 * @return array{winner: array|null, siblings: array<int,array>}
719 */
720 private function select_primary_set(): array {
721 if (empty($this->primary_candidates)) {
722 return ['winner' => null, 'siblings' => []];
723 }
724
725 $best = PHP_INT_MAX;
726 foreach ($this->primary_candidates as $candidate) {
727 if ($candidate['rank'] < $best) {
728 $best = $candidate['rank'];
729 }
730 }
731
732 $kept = [];
733 foreach ($this->primary_candidates as $candidate) {
734 if ($candidate['rank'] === $best) {
735 $kept[] = $candidate;
736 }
737 }
738
739 return ['winner' => array_shift($kept), 'siblings' => array_values($kept)];
740 }
741
742 /**
743 * Give a node a unique @id, keeping one it already carries.
744 *
745 * Two entities of the same type on one page (two deployed Articles, say)
746 * would otherwise mint the same @id, which makes the graph ambiguous about
747 * which node a reference points at.
748 *
749 * @since 1.32.0
750 * @param array $node Node to stamp.
751 * @param string $fallback @id to use when the node has none.
752 * @param array $used Already-issued @id values, updated by reference.
753 * @return array
754 */
755 private function assign_id(array $node, string $fallback, array &$used): array {
756 $id = (isset($node['@id']) && is_string($node['@id']) && $node['@id'] !== '')
757 ? $node['@id']
758 : $fallback;
759
760 if (isset($used[$id])) {
761 $suffix = 2;
762 while (isset($used[$id . '-' . $suffix])) {
763 $suffix++;
764 }
765 $id .= '-' . $suffix;
766 }
767
768 $used[$id] = true;
769 $node['@id'] = $id;
770
771 return $node;
772 }
773
774 /**
775 * Build the single FAQ node, if any questions were collected.
776 *
777 * @since 1.32.0
778 * @return array|null
779 */
780 private function build_faq_node(): ?array {
781 if (empty($this->faq_entities)) {
782 return null;
783 }
784
785 return [
786 '@type' => 'FAQPage',
787 'mainEntity' => array_values($this->faq_entities),
788 ];
789 }
790
791 /**
792 * Base URL for @id values.
793 *
794 * @since 1.32.0
795 * @return string
796 */
797 private function base_url(?array $primary): string {
798 if (is_singular()) {
799 $permalink = get_permalink();
800 if (is_string($permalink) && $permalink !== '') {
801 return $permalink;
802 }
803 }
804
805 // Archives are not singular, so fall back to the URL the page entity
806 // already resolved for itself. Without this every archive would mint the
807 // same "<home>#collectionpage" @id and two categories would collide.
808 $url = $primary['schema']['url'] ?? null;
809 if (is_string($url) && $url !== '') {
810 return $url;
811 }
812
813 return home_url('/');
814 }
815 }
816