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

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