PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / 1.32.0
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO v1.32.0
2.7.0 2.6.0 2.5.0 2.4.0 2.3.0 2.2.0 2.1.1 2.1.0 2.0.2 2.0.1 2.0.0 1.32.0 1.31.0 1.30.0 1.29.0 1.28.0 1.27.0 1.26.0 1.25.0 trunk 1.0.0 1.0.1 1.0.2 1.1.0 1.10.0 All 48 releases
thinkrank / includes / frontend / class-schema-graph.php

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

806 lines 25.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 * 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 $entities[] = [
518 '@type' => 'Question',
519 'name' => $question,
520 'acceptedAnswer' => [
521 '@type' => 'Answer',
522 'text' => wp_kses_post($answer),
523 ],
524 ];
525 }
526
527 return $entities;
528 }
529
530 /**
531 * Whether anything has been registered.
532 *
533 * @since 1.32.0
534 * @return bool
535 */
536 public function has_nodes(): bool {
537 return !empty($this->primary_candidates) || !empty($this->supporting) || !empty($this->faq_entities);
538 }
539
540 /**
541 * Assemble and emit the graph. Safe to call more than once.
542 *
543 * @since 1.32.0
544 * @return void
545 */
546 public function render(): void {
547 if ($this->rendered || !$this->has_nodes()) {
548 return;
549 }
550
551 $this->rendered = true;
552
553 $graph = $this->build_graph();
554
555 /**
556 * Filter the assembled schema graph before output.
557 *
558 * Receives every node ThinkRank is about to emit, already deduped and
559 * linked, so add-ons can append or adjust nodes in one place.
560 *
561 * @since 1.32.0
562 *
563 * @param array $graph List of schema nodes ([] suppresses output).
564 */
565 $graph = apply_filters('thinkrank_schema_graph', $graph);
566
567 if (empty($graph)) {
568 return;
569 }
570
571 $json = wp_json_encode(
572 ['@context' => self::SCHEMA_CONTEXT, '@graph' => array_values($graph)],
573 JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT
574 | JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT
575 );
576
577 if (false === $json) {
578 return;
579 }
580
581 echo "<!-- ThinkRank Schema Graph -->\n";
582 echo '<script type="application/ld+json">' . "\n";
583 echo $json . "\n"; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- wp_json_encode with JSON_HEX_* cannot break out of the script block.
584 echo '</script>' . "\n";
585 echo "<!-- /ThinkRank Schema Graph -->\n";
586 }
587
588 /**
589 * Build the linked node list.
590 *
591 * @since 1.32.0
592 * @return array
593 */
594 private function build_graph(): array {
595 $selection = $this->select_primary_set();
596 $primary = $selection['winner'];
597 $siblings = $selection['siblings'];
598 $faq = $this->build_faq_node();
599 $base = $this->base_url($primary);
600
601 // With no other page-level entity, the FAQ node is the page.
602 if (null === $primary && null !== $faq) {
603 $primary = ['schema' => $faq, 'type' => 'FAQPage'];
604 $faq = null;
605 }
606
607 $nodes = [];
608 $primary_id = '';
609 $used_ids = [];
610
611 if (null !== $primary) {
612 $node = $primary['schema'];
613
614 // Key the @id off the node's resolved @type, not the configured one,
615 // so an "Article" setting that renders BlogPosting reads #blogposting.
616 $resolved_type = $this->effective_type($node, $primary['type']);
617
618 $node = $this->assign_id($node, $base . '#' . strtolower($resolved_type), $used_ids);
619 $primary_id = $node['@id'];
620 $nodes['primary'] = $node;
621 }
622
623 // Entities deployed alongside the winner (Pro's Multi-Schema lets a post
624 // carry an Article *and* a Recipe). They lost the page slot but were
625 // deliberately deployed, so they stay in the graph linked to the primary
626 // rather than being dropped.
627 foreach ($siblings as $index => $sibling) {
628 $node = $sibling['schema'];
629
630 $node = $this->assign_id(
631 $node,
632 $base . '#' . strtolower($this->effective_type($node, $sibling['type'])),
633 $used_ids
634 );
635
636 if ($primary_id !== '' && $node['@id'] !== $primary_id) {
637 $node['isPartOf'] = $node['isPartOf'] ?? ['@id' => $primary_id];
638 $node['mainEntityOfPage'] = $node['mainEntityOfPage'] ?? ['@id' => $primary_id];
639 }
640
641 $nodes['sibling_' . $index] = $node;
642 }
643
644 if (null !== $faq) {
645 $faq = $this->assign_id($faq, $base . '#faq', $used_ids);
646
647 if ($primary_id !== '') {
648 $faq['isPartOf'] = ['@id' => $primary_id];
649 $faq['mainEntityOfPage'] = ['@id' => $primary_id];
650 }
651
652 $nodes['faq'] = $faq;
653 }
654
655 $website_id = '';
656 $breadcrumb_id = '';
657
658 foreach ($this->supporting as $index => $node) {
659 $type = $node['@type'] ?? '';
660
661 if ('BreadcrumbList' === $type) {
662 $node = $this->assign_id($node, $base . '#breadcrumb', $used_ids);
663 $breadcrumb_id = $node['@id'];
664 } elseif ('WebSite' === $type) {
665 $node = $this->assign_id($node, home_url('/#website'), $used_ids);
666 $website_id = $node['@id'];
667 } elseif ('Organization' === $type) {
668 $node = $this->assign_id($node, home_url('/#organization'), $used_ids);
669 } elseif (is_string($type) && $type !== '') {
670 $node = $this->assign_id($node, $base . '#' . strtolower($type), $used_ids);
671 }
672
673 $nodes['supporting_' . $index] = $node;
674 }
675
676 // Link the page entity to the site and its breadcrumb trail.
677 if (isset($nodes['primary'])) {
678 if ($website_id !== '' && !isset($nodes['primary']['isPartOf'])) {
679 $nodes['primary']['isPartOf'] = ['@id' => $website_id];
680 }
681 if ($breadcrumb_id !== '' && !isset($nodes['primary']['breadcrumb'])) {
682 $nodes['primary']['breadcrumb'] = ['@id' => $breadcrumb_id];
683 }
684 }
685
686 // The graph carries @context once; per-node copies are redundant.
687 foreach ($nodes as $key => $node) {
688 unset($node['@context']);
689 $nodes[$key] = $node;
690 }
691
692 return array_values($nodes);
693 }
694
695 /**
696 * Pick the page-level entity, plus any deployed alongside it.
697 *
698 * Precedence arbitrates between *sources*, not between entities: a per-post
699 * deployment beats the post-type-wide default, and the losing source is
700 * dropped so one URL stops claiming to be several unrelated things (#355).
701 *
702 * Within the winning source every entity is kept. Deploying more than one
703 * page-level schema on a post is exactly what Pro's Multi-Schema feature
704 * exists to do (an Article that is also a Recipe), and silently discarding
705 * the extras would delete markup the user deliberately published.
706 *
707 * @since 1.32.0
708 * @return array{winner: array|null, siblings: array<int,array>}
709 */
710 private function select_primary_set(): array {
711 if (empty($this->primary_candidates)) {
712 return ['winner' => null, 'siblings' => []];
713 }
714
715 $best = PHP_INT_MAX;
716 foreach ($this->primary_candidates as $candidate) {
717 if ($candidate['rank'] < $best) {
718 $best = $candidate['rank'];
719 }
720 }
721
722 $kept = [];
723 foreach ($this->primary_candidates as $candidate) {
724 if ($candidate['rank'] === $best) {
725 $kept[] = $candidate;
726 }
727 }
728
729 return ['winner' => array_shift($kept), 'siblings' => array_values($kept)];
730 }
731
732 /**
733 * Give a node a unique @id, keeping one it already carries.
734 *
735 * Two entities of the same type on one page (two deployed Articles, say)
736 * would otherwise mint the same @id, which makes the graph ambiguous about
737 * which node a reference points at.
738 *
739 * @since 1.32.0
740 * @param array $node Node to stamp.
741 * @param string $fallback @id to use when the node has none.
742 * @param array $used Already-issued @id values, updated by reference.
743 * @return array
744 */
745 private function assign_id(array $node, string $fallback, array &$used): array {
746 $id = (isset($node['@id']) && is_string($node['@id']) && $node['@id'] !== '')
747 ? $node['@id']
748 : $fallback;
749
750 if (isset($used[$id])) {
751 $suffix = 2;
752 while (isset($used[$id . '-' . $suffix])) {
753 $suffix++;
754 }
755 $id .= '-' . $suffix;
756 }
757
758 $used[$id] = true;
759 $node['@id'] = $id;
760
761 return $node;
762 }
763
764 /**
765 * Build the single FAQ node, if any questions were collected.
766 *
767 * @since 1.32.0
768 * @return array|null
769 */
770 private function build_faq_node(): ?array {
771 if (empty($this->faq_entities)) {
772 return null;
773 }
774
775 return [
776 '@type' => 'FAQPage',
777 'mainEntity' => array_values($this->faq_entities),
778 ];
779 }
780
781 /**
782 * Base URL for @id values.
783 *
784 * @since 1.32.0
785 * @return string
786 */
787 private function base_url(?array $primary): string {
788 if (is_singular()) {
789 $permalink = get_permalink();
790 if (is_string($permalink) && $permalink !== '') {
791 return $permalink;
792 }
793 }
794
795 // Archives are not singular, so fall back to the URL the page entity
796 // already resolved for itself. Without this every archive would mint the
797 // same "<home>#collectionpage" @id and two categories would collide.
798 $url = $primary['schema']['url'] ?? null;
799 if (is_string($url) && $url !== '') {
800 return $url;
801 }
802
803 return home_url('/');
804 }
805 }
806