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

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