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

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