PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / 2.14.0
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO v2.14.0
2.14.0 2.13.0 2.12.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 All 55 releases
thinkrank / includes / seo / class-faq-content.php

class-faq-content.php in ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO 2.14.0, at includes/seo/class-faq-content.php

1,463 lines 51.7 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 /**
4 * Where a post's FAQ question/answer pairs actually live.
5 *
6 * Four surfaces can hold them — the `thinkrank/faq` block, and the Elementor
7 * widget, Bricks element and Beaver module that mirror it — and each stores its
8 * repeater in a different place, in a different shape, behind a differently
9 * spelled schema toggle. `Schema_Graph` grew a walker per surface so it could
10 * merge them into one FAQPage.
11 *
12 * Nothing else could reach them. Adding the `get-faq` / `update-faq` abilities
13 * (#767) meant either a second copy of all four walkers, which is exactly the
14 * drift the FAQ surfaces have already produced twice, or one reader both sides
15 * share. This is that reader: `Schema_Graph` asks it where the pairs are and
16 * turns them into Question entities, and the abilities ask it the same question
17 * and report them to an agent.
18 *
19 * It answers what is *stored*, not what is *published*. The schema toggle is
20 * reported rather than applied, because a block with schema switched off is
21 * still visible FAQ content that a caller needs to know about; the password
22 * gate is left to `Schema_Graph`, because an editor asking what is on a post is
23 * not the same question as what a visitor may be shown.
24 *
25 * @package ThinkRank
26 * @subpackage SEO
27 * @since 2.10.1
28 */
29
30 declare(strict_types=1);
31
32 namespace ThinkRank\SEO;
33
34 // Prevent direct access
35 if (!defined('ABSPATH')) {
36 exit;
37 }
38
39 /**
40 * Reads FAQ pairs out of every surface that can hold them.
41 *
42 * @since 2.10.1
43 */
44 class FAQ_Content {
45
46 /**
47 * The Gutenberg block.
48 *
49 * @var string
50 */
51 public const SOURCE_BLOCK = 'block';
52
53 /**
54 * The Elementor widget.
55 *
56 * @var string
57 */
58 public const SOURCE_ELEMENTOR = 'elementor';
59
60 /**
61 * The Bricks element.
62 *
63 * @var string
64 */
65 public const SOURCE_BRICKS = 'bricks';
66
67 /**
68 * The Beaver Builder module.
69 *
70 * @var string
71 */
72 public const SOURCE_BEAVER = 'beaver';
73
74 /**
75 * Oxygen, in every generation before Oxygen 6.
76 *
77 * A builder name rather than a SOURCE_*: ThinkRank ships no Oxygen FAQ
78 * module, so nothing is ever read out of an Oxygen tree and no item can
79 * carry this as its `source`. It exists because `builder()` has to be able
80 * to say "Oxygen renders this post" even though the FAQ reader cannot
81 * follow it there (#831).
82 *
83 * @since 2.14.0
84 * @var string
85 */
86 public const BUILDER_OXYGEN = 'oxygen';
87
88 /**
89 * Breakdance, which is also what Oxygen 6 is built on.
90 *
91 * Named separately from Oxygen because the two are sold as different
92 * products and `get-post-content` already reports them apart; a caller
93 * told "Breakdance" should not have to know it is looking at Oxygen 6.
94 *
95 * @since 2.14.0
96 * @var string
97 */
98 public const BUILDER_BREAKDANCE = 'breakdance';
99
100 /**
101 * Builder post meta keys whose presence alone names the builder.
102 *
103 * Every key here is one of {@see Builder_Content::builder_meta_keys()}, and
104 * the labels match the ones `get-post-content` reports for the same keys so
105 * the two abilities cannot disagree about the same page. Oxygen spans four
106 * of them: Oxygen 6 is Breakdance under the hood, classic 4.x writes a JSON
107 * tree beside its shortcodes, and 4.8.3 prefixed every `ct_*` key with an
108 * underscore.
109 *
110 * `FaqAbilitiesTest` asserts that every key `Builder_Content` resolves
111 * through appears either here or in {@see self::FLAGGED_BUILDER_META_KEYS},
112 * so teaching `Builder_Content` about a new builder fails the build until
113 * the FAQ abilities have been told what to do with it.
114 *
115 * @since 2.14.0
116 * @var array<string, string>
117 */
118 private const META_BUILDERS = [
119 '_breakdance_data' => self::BUILDER_BREAKDANCE,
120 '_oxygen_data' => self::BUILDER_OXYGEN,
121 '_ct_builder_json' => self::BUILDER_OXYGEN,
122 'ct_builder_json' => self::BUILDER_OXYGEN,
123 '_ct_builder_shortcodes' => self::BUILDER_OXYGEN,
124 'ct_builder_shortcodes' => self::BUILDER_OXYGEN,
125 ];
126
127 /**
128 * Builder meta keys that must NOT be read as "this builder renders the post".
129 *
130 * Elementor and Beaver Builder both keep stored data on a post they no
131 * longer render: `_elementor_data` survives switching a page back to the
132 * block editor, and `_fl_builder_draft` holds changes that were never
133 * published. Each has its own editor flag, which `builder()` tests instead,
134 * and reading the data key would take `writable` away from a post the block
135 * editor really does render.
136 *
137 * @since 2.14.0
138 * @var string[]
139 */
140 private const FLAGGED_BUILDER_META_KEYS = [
141 '_elementor_data',
142 '_fl_builder_data',
143 '_fl_builder_draft',
144 ];
145
146 /**
147 * The schema setting that turns builder accordions into FAQPage content.
148 *
149 * @since 2.14.0
150 * @var string
151 */
152 public const ACCORDION_SETTING = 'enable_accordion_faq_schema';
153
154 /**
155 * Resolved value of {@see self::ACCORDION_SETTING} for this request.
156 *
157 * @since 2.14.0
158 * @var bool|null
159 */
160 private static ?bool $accordion_schema = null;
161
162 /**
163 * Element-name fragments that mark a subtree as an accordion.
164 *
165 * Matched as a fragment of the element's own name because each builder
166 * spells it differently and renames it between releases: Oxygen classic has
167 * `oxy_pro_accordion`, Breakdance `EssentialElements\Accordion`, and both
168 * ship toggle variants. Matching the fragment survives a rename that an
169 * exact list would not, and the cost of a false positive is bounded — a
170 * subtree only contributes if title/body pairs are actually found in it.
171 *
172 * @since 2.14.0
173 * @var string[]
174 */
175 private const ACCORDION_NAME_FRAGMENTS = ['accordion', 'toggle', 'faq'];
176
177 /**
178 * Tree keys that hold an element's own name or type.
179 *
180 * @since 2.14.0
181 * @var string[]
182 */
183 private const NODE_NAME_KEYS = ['name', 'type', 'tag', 'slug', 'element', 'widgettype', 'widget_type', 'eltype'];
184
185 /**
186 * Key fragments whose string value reads as a question.
187 *
188 * Fragments rather than exact keys, for the same reason as the element
189 * names: Oxygen prefixes a composite element's fields with its own slug, so
190 * the title of an accordion item is `pro_accordion_item_title` on one
191 * release and something adjacent on the next.
192 *
193 * @since 2.14.0
194 * @var string[]
195 */
196 private const QUESTION_KEY_FRAGMENTS = ['question', 'title', 'heading', 'header', 'label', 'tab'];
197
198 /**
199 * Key fragments whose string value reads as an answer.
200 *
201 * `title` is deliberately absent and `text` deliberately present. Both
202 * builders keep an item's answer in a child element whose copy is at
203 * `content.content.text`, and an item that keeps the two together is read
204 * the same way.
205 *
206 * @since 2.14.0
207 * @var string[]
208 */
209 private const ANSWER_KEY_FRAGMENTS = ['answer', 'text', 'content', 'body', 'description', 'editor', 'html'];
210
211 /**
212 * How deep a builder tree is walked before the walk gives up.
213 *
214 * Builder trees are a few dozen levels at worst. A bound keeps a corrupt or
215 * self-referential stored tree from exhausting the stack during a page
216 * render, which is the kind of failure that takes a whole site down rather
217 * than one FAQ.
218 *
219 * @since 2.14.0
220 * @var int
221 */
222 private const MAX_TREE_DEPTH = 64;
223
224 /**
225 * Gutenberg FAQ block name.
226 *
227 * @var string
228 */
229 public const FAQ_BLOCK = 'thinkrank/faq';
230
231 /**
232 * Elementor FAQ widget name.
233 *
234 * @var string
235 */
236 public const FAQ_WIDGET = 'thinkrank-faq';
237
238 /**
239 * Bricks FAQ element name.
240 *
241 * @var string
242 */
243 public const FAQ_BRICKS_ELEMENT = 'thinkrank-faq';
244
245 /**
246 * The Beaver Builder FAQ module's slug, as stored in its layout nodes.
247 *
248 * Matches `ThinkRank_Beaver_FAQ_Module::SLUG`. Duplicated as a literal
249 * rather than referenced, because that class extends `FLBuilderModule` and
250 * so cannot be loaded at all when Beaver Builder is inactive — which is
251 * exactly the site that still has a stored layout, after a builder switch.
252 *
253 * @var string
254 */
255 public const FAQ_BEAVER_MODULE = 'thinkrank-faq';
256
257 /**
258 * Every FAQ producer found on a post, in collection order.
259 *
260 * @since 2.10.1
261 * @param \WP_Post $post Post to read.
262 * @return array<int, array{source: string, schema: bool, pairs: array}>
263 */
264 public static function groups(\WP_Post $post): array {
265 $groups = [];
266
267 // A Bricks page throws `post_content` away, so a FAQ block left there
268 // when the page was switched over never renders. Reporting its
269 // questions would describe content no visitor can see, which Google
270 // treats as a violation rather than merely a duplicate (#650).
271 self::load_builder_content();
272
273 if (!Builder_Content::bricks_supersedes_post_content((int) $post->ID)) {
274 $groups = array_merge($groups, self::block_groups($post));
275 }
276
277 $groups = array_merge($groups, self::elementor_groups($post));
278 $groups = array_merge($groups, self::bricks_groups($post));
279 $groups = array_merge($groups, self::beaver_groups($post));
280 $groups = array_merge($groups, self::accordion_groups($post));
281
282 return $groups;
283 }
284
285 /**
286 * Whether page-builder accordions may contribute to the FAQPage.
287 *
288 * Off unless the site says otherwise, and that default is the whole point.
289 * The other four producers are ThinkRank's own FAQ surfaces: an author who
290 * dropped one on the page asked for FAQ markup, and the block even carries a
291 * per-instance toggle. An Oxygen accordion carries no such intent — it may
292 * hold questions, or product specifications, or a changelog — so reading one
293 * as a FAQPage is the site owner's call to make. Defaulting it on would
294 * change what existing sites publish on upgrade, unasked.
295 *
296 * Reported either way, because the abilities describe what is on a page
297 * rather than what is published: with the setting off the questions are
298 * still returned by `get-faq`, and `Schema_Graph` skips the group exactly as
299 * it skips a block whose own schema toggle is off.
300 *
301 * Resolved once per request. The switch is site-wide so it cannot change
302 * mid-request, and `Schema_Management_System` builds a cache manager and
303 * registers listeners in its constructor, which is not something to spin up
304 * per accordion.
305 *
306 * @since 2.14.0
307 * @return bool
308 */
309 public static function accordion_schema_enabled(): bool {
310 if (null === self::$accordion_schema) {
311 self::$accordion_schema = false;
312
313 if (class_exists('ThinkRank\\SEO\\Schema_Management_System')) {
314 $settings = (new Schema_Management_System())->get_settings('site', null);
315
316 // Absent means off here, unlike the schema master switch:
317 // this publishes markup a site was not publishing before, so
318 // it has to be asked for rather than merely not refused.
319 self::$accordion_schema = !empty($settings[self::ACCORDION_SETTING]);
320 }
321 }
322
323 /**
324 * Filter whether a builder accordion contributes to the FAQPage.
325 *
326 * The stored switch is site-wide, which is the right grain for "are our
327 * accordions FAQs?" but the wrong one for a site where some are and
328 * some are not. This runs on every call rather than being memoized with
329 * the stored read, so it can answer per post.
330 *
331 * @since 2.14.0
332 *
333 * @param bool $enabled Whether accordion questions reach the FAQPage.
334 */
335 return (bool) apply_filters('thinkrank_faq_accordion_schema', self::$accordion_schema);
336 }
337
338 /**
339 * Discard the resolved accordion switch. Test seam.
340 *
341 * @since 2.14.0
342 * @return void
343 */
344 public static function flush_accordion_schema_cache(): void {
345 self::$accordion_schema = null;
346 }
347
348 /**
349 * Every question/answer pair on a post, flattened and normalised.
350 *
351 * Rows with no question or no answer are dropped: they are a half-filled
352 * repeater row in the editor, not an FAQ entry, and reporting them as one
353 * would have an agent "fixing" content the author is still writing.
354 *
355 * @since 2.10.1
356 * @param \WP_Post $post Post to read.
357 * @return array<int, array{question: string, answer: string, source: string, schema_enabled: bool, image_id: int, image_url: string, image_alt: string}>
358 */
359 public static function items(\WP_Post $post): array {
360 $items = [];
361
362 foreach (self::groups($post) as $group) {
363 foreach (self::rows($group['pairs']) as $row) {
364 $question = trim(wp_strip_all_tags((string) ($row['question'] ?? '')));
365 $answer = trim((string) ($row['answer'] ?? ''));
366
367 if ('' === $question || '' === $answer) {
368 continue;
369 }
370
371 $items[] = [
372 'question' => $question,
373 'answer' => $answer,
374 'source' => $group['source'],
375 'schema_enabled' => $group['schema'],
376 'image_id' => (int) ($row['imageId'] ?? $row['image_id'] ?? 0),
377 'image_url' => (string) ($row['imageUrl'] ?? $row['image_url'] ?? ''),
378 'image_alt' => (string) ($row['imageAlt'] ?? $row['image_alt'] ?? ''),
379 ];
380 }
381 }
382
383 return $items;
384 }
385
386 /**
387 * Which page builder renders this post, if any.
388 *
389 * Each test is the builder's own: Elementor stores `builder` in
390 * `_elementor_edit_mode` for a page it owns, Beaver Builder flags
391 * `_fl_builder_enabled`, and Bricks is asked through the resolver that
392 * already knows when it supersedes `post_content`.
393 *
394 * Oxygen and Breakdance have no such flag, so they are recognised by their
395 * stored tree instead. They were missing entirely, and because '' is also
396 * what the block editor returns, an Oxygen page was reported as having no
397 * builder at all: `get-faq` called it writable and `update-faq` stored a
398 * block Oxygen never renders, which is the one outcome both were written to
399 * prevent (#831). The keys come from `Builder_Content`, which has detected
400 * both since 1.23.0 for scoring, so nothing new has to be learned here.
401 *
402 * @since 2.10.1
403 * @since 2.14.0 Detects Oxygen and Breakdance.
404 * @param int $post_id Post ID.
405 * @return string One of {@see self::builders()}, or '' for the block editor.
406 */
407 public static function builder(int $post_id): string {
408 self::load_builder_content();
409
410 if ('builder' === (string) get_post_meta($post_id, '_elementor_edit_mode', true)) {
411 return self::SOURCE_ELEMENTOR;
412 }
413
414 if (Builder_Content::bricks_supersedes_post_content($post_id)) {
415 return self::SOURCE_BRICKS;
416 }
417
418 if (!empty(get_post_meta($post_id, '_fl_builder_enabled', true))) {
419 return self::SOURCE_BEAVER;
420 }
421
422 return self::builder_from_meta($post_id);
423 }
424
425 /**
426 * Every builder name {@see self::builder()} can report.
427 *
428 * Derived rather than listed, so an ability's enum cannot fall behind the
429 * detection: the three flagged builders, then whatever `META_BUILDERS`
430 * names, deduplicated because several keys share one builder.
431 *
432 * @since 2.14.0
433 * @return string[]
434 */
435 public static function builders(): array {
436 return array_values(array_unique(array_merge(
437 [self::SOURCE_ELEMENTOR, self::SOURCE_BRICKS, self::SOURCE_BEAVER],
438 array_values(self::META_BUILDERS)
439 )));
440 }
441
442 /**
443 * The builders ThinkRank can read FAQ questions out of.
444 *
445 * Oxygen and Breakdance are read through their own accordion rather than
446 * through a ThinkRank module, so they are readable without being writable
447 * and without appearing in {@see self::module_builders()}.
448 *
449 * @since 2.14.0
450 * @return string[]
451 */
452 public static function readable_builders(): array {
453 return array_merge(
454 self::module_builders(),
455 [self::BUILDER_OXYGEN, self::BUILDER_BREAKDANCE]
456 );
457 }
458
459 /**
460 * The builders that have a ThinkRank FAQ module of their own.
461 *
462 * The distinction an agent needs, and the one the first fix did not have.
463 * A post built with one of these is refused by `update-faq`, but there is
464 * somewhere to send its author: the ThinkRank FAQ module for that builder,
465 * which carries its own schema toggle. A readable builder outside this list
466 * has no module to point at, so its author is sent to the builder's own
467 * accordion instead.
468 *
469 * @since 2.14.0
470 * @return string[]
471 */
472 public static function module_builders(): array {
473 return [self::SOURCE_ELEMENTOR, self::SOURCE_BRICKS, self::SOURCE_BEAVER];
474 }
475
476 /**
477 * Whether ThinkRank can read FAQ questions out of a builder's storage.
478 *
479 * @since 2.14.0
480 * @param string $builder Builder name, or '' for the block editor.
481 * @return bool True for the block editor and every builder with a reader.
482 */
483 public static function builder_is_readable(string $builder): bool {
484 return '' === $builder || in_array($builder, self::readable_builders(), true);
485 }
486
487 /**
488 * Whether a builder has a ThinkRank FAQ module an author can be sent to.
489 *
490 * @since 2.14.0
491 * @param string $builder Builder name, or '' for the block editor.
492 * @return bool
493 */
494 public static function builder_has_module(string $builder): bool {
495 return in_array($builder, self::module_builders(), true);
496 }
497
498 /**
499 * Builder meta keys this class has an answer for. Test seam.
500 *
501 * The drift guard in `FaqAbilitiesTest` compares this against
502 * {@see Builder_Content::builder_meta_keys()}: a key in neither set is a
503 * builder whose pages would silently be reported as writable.
504 *
505 * @since 2.14.0
506 * @return string[]
507 */
508 public static function classified_builder_meta_keys(): array {
509 return array_merge(array_keys(self::META_BUILDERS), self::FLAGGED_BUILDER_META_KEYS);
510 }
511
512 /**
513 * The builder named by a post's stored tree, for builders with no flag.
514 *
515 * Presence is the whole test, so the value is checked for emptiness in both
516 * shapes it arrives in: Breakdance and Oxygen classic 4.x store JSON
517 * strings, while a filtered or already-decoded value can be an array.
518 *
519 * @since 2.14.0
520 * @param int $post_id Post ID.
521 * @return string Builder name, or '' when no builder tree is stored.
522 */
523 private static function builder_from_meta(int $post_id): string {
524 foreach (self::META_BUILDERS as $meta_key => $builder) {
525 $stored = get_post_meta($post_id, $meta_key, true);
526
527 if (is_array($stored)) {
528 if ([] !== $stored) {
529 return $builder;
530 }
531
532 continue;
533 }
534
535 if (is_string($stored) && '' !== trim($stored)) {
536 return $builder;
537 }
538 }
539
540 return '';
541 }
542
543 /**
544 * Load Builder_Content, which resolves the Bricks half of the answer.
545 *
546 * Required rather than autoloaded for the same reason Schema_Graph used to
547 * require it: this runs in contexts where the plugin autoloader is not
548 * guaranteed to be registered.
549 *
550 * @return void
551 */
552 private static function load_builder_content(): void {
553 if (class_exists('ThinkRank\\SEO\\Builder_Content')) {
554 return;
555 }
556
557 $file = THINKRANK_PLUGIN_DIR . 'includes/seo/class-builder-content.php';
558 if (file_exists($file)) {
559 require_once $file;
560 }
561 }
562
563 /**
564 * FAQ blocks in a post's content, including nested ones.
565 *
566 * @param \WP_Post $post Post to read.
567 * @return array<int, array{source: string, schema: bool, pairs: array}>
568 */
569 private static function block_groups(\WP_Post $post): array {
570 if (!function_exists('parse_blocks') || !has_blocks($post->post_content)) {
571 return [];
572 }
573
574 return self::walk_blocks(parse_blocks($post->post_content));
575 }
576
577 /**
578 * Recurse a parsed block tree.
579 *
580 * @param array $blocks Parsed blocks.
581 * @return array<int, array{source: string, schema: bool, pairs: array}>
582 */
583 private static function walk_blocks(array $blocks): array {
584 $groups = [];
585
586 foreach ($blocks as $block) {
587 if (!is_array($block)) {
588 continue;
589 }
590
591 $block_name = (string) ($block['blockName'] ?? '');
592 $attrs = is_array($block['attrs'] ?? null) ? $block['attrs'] : [];
593
594 // A leftover Rank Math FAQ block is absorbed as if it were ours, so
595 // an unmigrated post contributes its questions to the single
596 // FAQPage rather than to nothing at all (#777). The fallback stays
597 // silent while Rank Math is active and still emitting its own.
598 if (\ThinkRank\Integrations\Rank_Math_Blocks::is_source_block($block_name)) {
599 $fallback = \ThinkRank\Integrations\Rank_Math_Blocks::schema_fallback($block_name, $attrs);
600 if (null !== $fallback) {
601 $block_name = $fallback['name'];
602 $attrs = $fallback['attrs'];
603 }
604 }
605
606 if ($block_name === self::FAQ_BLOCK) {
607 $groups[] = [
608 'source' => self::SOURCE_BLOCK,
609 // Mirrors Blocks_Manager: schema is on unless explicitly disabled.
610 'schema' => !(array_key_exists('outputSchema', $attrs) && false === $attrs['outputSchema']),
611 'pairs' => self::rows($attrs['faqs'] ?? []),
612 ];
613 }
614
615 if (!empty($block['innerBlocks']) && is_array($block['innerBlocks'])) {
616 $groups = array_merge($groups, self::walk_blocks($block['innerBlocks']));
617 }
618 }
619
620 return $groups;
621 }
622
623 /**
624 * Question/answer pairs from an Oxygen or Breakdance accordion.
625 *
626 * ThinkRank ships no FAQ module for either builder, so unlike the other four
627 * producers there is no element of ours to look for. What there is instead is
628 * the builder's own accordion, which is visible FAQ content that was being
629 * reported as nothing at all: `get-faq` returned `total: 0` on a page with a
630 * working FAQ on it, and the page published no FAQPage (#831).
631 *
632 * Read from the stored tree rather than by rendering, for the reasons
633 * `Builder_Content` already documents: rendering an Oxygen page outside a
634 * front-end request is slow, stateful and can fatal in admin context, while
635 * the stored tree is cheap and side-effect free.
636 *
637 * The shapes are taken from the builders' own element definitions rather
638 * than inferred: `Advanced_Accordion/element.php` and
639 * `Accordion_Content/element.php` in `breakdance-elements` declare the
640 * accordion as an element per item, each item holding its question at
641 * `content.content.title` and its answer in its own child elements. Both
642 * declare `availableIn() === ['breakdance', 'oxygen']`, so Oxygen 6 is the
643 * same tree under a different product name.
644 *
645 * Oxygen classic keeps two copies of the same page, a JSON tree and a
646 * shortcode string, and neither is reliably the richer one: a composite
647 * element's copy is base64-encoded inside `ct_options` in the shortcode form,
648 * while a key missing from the JSON walker loses it there. `from_oxygen_classic()`
649 * resolves that by reading both and keeping whichever yielded more; this does
650 * the same, keeping whichever yielded more pairs.
651 *
652 * @since 2.14.0
653 * @param \WP_Post $post Post to read.
654 * @return array<int, array{source: string, schema: bool, pairs: array}>
655 */
656 private static function accordion_groups(\WP_Post $post): array {
657 $builder = self::builder((int) $post->ID);
658
659 // Only the builders with no FAQ module of their own. Elementor, Bricks
660 // and Beaver have one, and sweeping their trees for accordions as well
661 // would publish a second FAQPage source behind the back of the module's
662 // own schema toggle.
663 if (!in_array($builder, [self::BUILDER_OXYGEN, self::BUILDER_BREAKDANCE], true)) {
664 return [];
665 }
666
667 $pairs = self::accordion_pairs((int) $post->ID);
668
669 /**
670 * Filter the question/answer pairs read out of a builder accordion.
671 *
672 * The shapes below cover Oxygen classic, Oxygen 6 and Breakdance as they
673 * store an accordion today. A builder release that moves its fields, or
674 * a third-party accordion element, can be taught here instead of waiting
675 * for the walker to learn it.
676 *
677 * @since 2.14.0
678 *
679 * @param array<int, array{question: string, answer: string}> $pairs Pairs found.
680 * @param \WP_Post $post Post being read.
681 * @param string $builder Builder that renders it.
682 */
683 $pairs = apply_filters('thinkrank_faq_builder_accordions', $pairs, $post, $builder);
684
685 if (!is_array($pairs) || [] === $pairs) {
686 return [];
687 }
688
689 return [[
690 'source' => $builder,
691 'schema' => self::accordion_schema_enabled(),
692 'pairs' => self::rows($pairs),
693 ]];
694 }
695
696 /**
697 * Accordion pairs from whichever storage this post has.
698 *
699 * @since 2.14.0
700 * @param int $post_id Post ID.
701 * @return array<int, array{question: string, answer: string}>
702 */
703 private static function accordion_pairs(int $post_id): array {
704 $best = [];
705
706 foreach (self::classified_builder_meta_keys() as $meta_key) {
707 if (in_array($meta_key, self::FLAGGED_BUILDER_META_KEYS, true)) {
708 continue; // Elementor and Beaver, which have their own module.
709 }
710
711 $stored = get_post_meta($post_id, $meta_key, true);
712 $pairs = self::accordion_pairs_from_stored($stored);
713
714 if (count($pairs) > count($best)) {
715 $best = $pairs;
716 }
717 }
718
719 return $best;
720 }
721
722 /**
723 * Accordion pairs from one stored builder value, in either storage form.
724 *
725 * @since 2.14.0
726 * @param mixed $stored Raw meta value.
727 * @return array<int, array{question: string, answer: string}>
728 */
729 private static function accordion_pairs_from_stored($stored): array {
730 if (is_array($stored)) {
731 return self::accordion_pairs_from_tree($stored);
732 }
733
734 if (!is_string($stored) || '' === trim($stored)) {
735 return [];
736 }
737
738 $decoded = json_decode($stored, true);
739 if (is_array($decoded)) {
740 return self::accordion_pairs_from_tree($decoded);
741 }
742
743 return self::accordion_pairs_from_shortcodes($stored);
744 }
745
746 /**
747 * Walk a decoded builder tree and collect accordion pairs.
748 *
749 * @since 2.14.0
750 * @param array<mixed> $tree Decoded tree.
751 * @return array<int, array{question: string, answer: string}>
752 */
753 private static function accordion_pairs_from_tree(array $tree): array {
754 $pairs = [];
755
756 $walk = static function ($node, int $depth) use (&$walk, &$pairs): void {
757 if ($depth > self::MAX_TREE_DEPTH) {
758 return;
759 }
760
761 $node = self::as_tree_node($node);
762 if (null === $node) {
763 return;
764 }
765
766 if (self::node_is_accordion($node)) {
767 $pairs = array_merge($pairs, self::pairs_in_subtree($node));
768
769 // Not descended into again: pairs_in_subtree() has already read
770 // the whole thing, and a nested accordion would be collected
771 // twice.
772 return;
773 }
774
775 foreach ($node as $child) {
776 $walk($child, $depth + 1);
777 }
778 };
779
780 $walk($tree, 0);
781
782 return $pairs;
783 }
784
785 /**
786 * A tree node as an array of children, unwrapping Breakdance's inner JSON.
787 *
788 * Breakdance stores the whole page as a JSON *string* under one key of the
789 * outer object, so a walker that only descends arrays stops at the door.
790 * Decoding a string that parses as a JSON object is what lets the same
791 * walker reach Oxygen 6 content, and it is bounded to strings that look like
792 * JSON so an answer's prose is never parsed as a tree.
793 *
794 * @since 2.14.0
795 * @param mixed $node Node to normalise.
796 * @return array<mixed>|null Children, or null for a leaf.
797 */
798 private static function as_tree_node($node): ?array {
799 if (is_object($node)) {
800 $node = get_object_vars($node);
801 }
802
803 if (is_array($node)) {
804 return $node;
805 }
806
807 if (!is_string($node)) {
808 return null;
809 }
810
811 $trimmed = trim($node);
812
813 if ('' === $trimmed || ('{' !== $trimmed[0] && '[' !== $trimmed[0])) {
814 return null;
815 }
816
817 $decoded = json_decode($trimmed, true);
818
819 return is_array($decoded) ? $decoded : null;
820 }
821
822 /**
823 * An element's own name, wherever the builder keeps it.
824 *
825 * Two layouts, because the builders differ in where the name sits relative
826 * to the children. Oxygen classic puts it on the node itself
827 * (`{name: 'oxy_pro_accordion', children: [...]}`), while Breakdance wraps
828 * it a level down (`{data: {type: 'EssentialElements\AdvancedAccordion'},
829 * children: [...]}`), so the name is a sibling of the children rather than
830 * their parent.
831 *
832 * Reading only the node's own keys is what broke the first version: the
833 * walk matched Breakdance's `data` object, which holds the type but none of
834 * the children, and so handed an accordion with every item stripped off it
835 * to the pair reader. A real Breakdance page yielded nothing at all.
836 *
837 * @since 2.14.0
838 * @param array<mixed> $node Tree node.
839 * @return string Element name, or '' when the node names nothing.
840 */
841 private static function node_name(array $node): string {
842 $name = self::name_on_node($node);
843
844 if ('' !== $name) {
845 return $name;
846 }
847
848 // Breakdance and Oxygen 6: `{id, data: {type, properties}, children}`.
849 $data = self::as_tree_node($node['data'] ?? null);
850
851 return null === $data ? '' : self::name_on_node($data);
852 }
853
854 /**
855 * The element name carried by a node's own keys.
856 *
857 * @since 2.14.0
858 * @param array<mixed> $node Tree node.
859 * @return string
860 */
861 private static function name_on_node(array $node): string {
862 foreach ($node as $key => $value) {
863 if (!is_string($key) || !is_string($value)) {
864 continue;
865 }
866
867 if (in_array(strtolower($key), self::NODE_NAME_KEYS, true)) {
868 return $value;
869 }
870 }
871
872 return '';
873 }
874
875 /**
876 * Whether an element's name marks it as an accordion or one of its items.
877 *
878 * @since 2.14.0
879 * @param string $name Element name.
880 * @return bool
881 */
882 private static function name_is_accordion(string $name): bool {
883 $name = strtolower($name);
884
885 foreach (self::ACCORDION_NAME_FRAGMENTS as $fragment) {
886 if (false !== strpos($name, $fragment)) {
887 return true;
888 }
889 }
890
891 return false;
892 }
893
894 /**
895 * Whether a node names itself as an accordion.
896 *
897 * @since 2.14.0
898 * @param array<mixed> $node Tree node.
899 * @return bool
900 */
901 private static function node_is_accordion(array $node): bool {
902 return self::name_is_accordion(self::node_name($node));
903 }
904
905 /**
906 * Collect question/answer pairs from inside one accordion element.
907 *
908 * The shape both builders actually use is an element per item rather than a
909 * repeater: Breakdance nests an `AccordionContent` under the accordion,
910 * carrying the question at `content.content.title`, and the answer lives in
911 * that item's own child elements (a Text, a RichText, a Heading), each
912 * keeping its copy at `content.content.text`. Oxygen classic arranges an
913 * `oxy_pro_accordion_item` the same way. So an item's question is read from
914 * the item's own properties and its answer from its children, which is what
915 * keeps a nested element's unrelated `title` — a Video's own name, say —
916 * from being read as the next question.
917 *
918 * A repeater is still handled, for a builder that stores one and for the
919 * accordion whose items carry no element name of their own: when no named
920 * item is found, every node is offered to the pair reader and a node
921 * carrying both halves is taken whole.
922 *
923 * @since 2.14.0
924 * @param array<mixed> $accordion Accordion element node.
925 * @return array<int, array{question: string, answer: string}>
926 */
927 private static function pairs_in_subtree(array $accordion): array {
928 $items = self::item_nodes($accordion);
929
930 if ([] !== $items) {
931 $pairs = [];
932
933 foreach ($items as $item) {
934 $question = self::deep_fragment_value($item, self::QUESTION_KEY_FRAGMENTS, true);
935
936 if ('' === $question) {
937 continue;
938 }
939
940 $children = self::as_tree_node($item['children'] ?? null);
941 $answer = null === $children
942 ? ''
943 : self::deep_fragment_value($children, self::ANSWER_KEY_FRAGMENTS, false);
944
945 // An item that keeps its answer beside its question rather than
946 // in a child element.
947 if ('' === $answer) {
948 $answer = self::deep_fragment_value($item, self::ANSWER_KEY_FRAGMENTS, true);
949 }
950
951 if ('' !== $answer) {
952 $pairs[] = [
953 'question' => $question,
954 'answer' => $answer,
955 ];
956 }
957 }
958
959 return $pairs;
960 }
961
962 return self::repeater_pairs($accordion);
963 }
964
965 /**
966 * The item elements directly describing one accordion's entries.
967 *
968 * An item names itself an accordion too (`AccordionContent`,
969 * `oxy_pro_accordion_item`), so the accordion is told from its items by
970 * being the node the search started at rather than by its name.
971 *
972 * @since 2.14.0
973 * @param array<mixed> $accordion Accordion element node.
974 * @return array<int, array<mixed>> Item nodes.
975 */
976 private static function item_nodes(array $accordion): array {
977 $items = [];
978
979 $walk = static function ($node, int $depth, bool $is_root) use (&$walk, &$items): void {
980 if ($depth > self::MAX_TREE_DEPTH) {
981 return;
982 }
983
984 $node = self::as_tree_node($node);
985 if (null === $node) {
986 return;
987 }
988
989 if (!$is_root && self::node_is_accordion($node)) {
990 $items[] = $node;
991
992 // Not descended into: a nested accordion inside an item is read
993 // when the walk reaches it on its own, and descending here
994 // would collect its items as siblings of this one's.
995 return;
996 }
997
998 foreach ($node as $child) {
999 $walk($child, $depth + 1, false);
1000 }
1001 };
1002
1003 $walk($accordion, 0, true);
1004
1005 return $items;
1006 }
1007
1008 /**
1009 * Pair a repeater's rows, or zip loose questions and answers in order.
1010 *
1011 * The fallback for an accordion whose items are not elements of their own. A
1012 * row carrying both halves pairs directly; otherwise a question is held
1013 * until an answer follows it, and a second question arriving first replaces
1014 * the held one rather than pairing with a later answer, so an item with no
1015 * answer drops out instead of stealing the next item's.
1016 *
1017 * @since 2.14.0
1018 * @param array<mixed> $accordion Accordion element node.
1019 * @return array<int, array{question: string, answer: string}>
1020 */
1021 private static function repeater_pairs(array $accordion): array {
1022 $pairs = [];
1023 $pending = '';
1024
1025 $walk = static function ($node, int $depth) use (&$walk, &$pairs, &$pending): void {
1026 if ($depth > self::MAX_TREE_DEPTH) {
1027 return;
1028 }
1029
1030 $node = self::as_tree_node($node);
1031 if (null === $node) {
1032 return;
1033 }
1034
1035 $question = self::first_fragment_value($node, self::QUESTION_KEY_FRAGMENTS);
1036 $answer = self::first_fragment_value($node, self::ANSWER_KEY_FRAGMENTS);
1037
1038 if ('' !== $question && '' !== $answer) {
1039 $pairs[] = [
1040 'question' => $question,
1041 'answer' => $answer,
1042 ];
1043 $pending = '';
1044
1045 return;
1046 }
1047
1048 if ('' !== $question) {
1049 $pending = $question;
1050 } elseif ('' !== $answer && '' !== $pending) {
1051 $pairs[] = [
1052 'question' => $pending,
1053 'answer' => $answer,
1054 ];
1055 $pending = '';
1056 }
1057
1058 foreach ($node as $child) {
1059 $walk($child, $depth + 1);
1060 }
1061 };
1062
1063 $walk($accordion, 0);
1064
1065 return $pairs;
1066 }
1067
1068 /**
1069 * The first matching string anywhere under a node.
1070 *
1071 * @since 2.14.0
1072 * @param array<mixed> $node Tree node.
1073 * @param string[] $fragments Key fragments to match.
1074 * @param bool $skip_children Whether to stay out of `children`,
1075 * which holds other elements rather than
1076 * this one's own fields.
1077 * @return string
1078 */
1079 private static function deep_fragment_value(array $node, array $fragments, bool $skip_children): string {
1080 $found = '';
1081
1082 $walk = static function ($current, int $depth) use (&$walk, &$found, $fragments, $skip_children): void {
1083 if ('' !== $found || $depth > self::MAX_TREE_DEPTH) {
1084 return;
1085 }
1086
1087 $current = self::as_tree_node($current);
1088 if (null === $current) {
1089 return;
1090 }
1091
1092 $value = self::first_fragment_value($current, $fragments);
1093 if ('' !== $value) {
1094 $found = $value;
1095
1096 return;
1097 }
1098
1099 foreach ($current as $key => $child) {
1100 if ($skip_children && is_string($key) && 'children' === strtolower($key)) {
1101 continue;
1102 }
1103
1104 $walk($child, $depth + 1);
1105 }
1106 };
1107
1108 $walk($node, 0);
1109
1110 return $found;
1111 }
1112
1113 /**
1114 * The first string on a node whose key matches one of the fragments.
1115 *
1116 * An exact key wins over a fragment of one, so an `AccordionContent`
1117 * carrying `title` beside `title_tag` yields the question rather than the
1118 * heading level it is rendered at. Among fragment matches the node's own key
1119 * order decides, so a builder that writes `question` and `title` yields
1120 * whichever it wrote first rather than whichever this class prefers.
1121 *
1122 * @since 2.14.0
1123 * @param array<mixed> $node Tree node.
1124 * @param string[] $fragments Key fragments to match.
1125 * @return string
1126 */
1127 private static function first_fragment_value(array $node, array $fragments): string {
1128 $fallback = '';
1129
1130 foreach ($node as $key => $value) {
1131 if (!is_string($key) || !is_string($value) || '' === trim($value)) {
1132 continue;
1133 }
1134
1135 $lower = strtolower($key);
1136
1137 if (in_array($lower, $fragments, true)) {
1138 return trim($value);
1139 }
1140
1141 if ('' !== $fallback) {
1142 continue;
1143 }
1144
1145 foreach ($fragments as $fragment) {
1146 if (false !== strpos($lower, $fragment)) {
1147 $fallback = trim($value);
1148
1149 break;
1150 }
1151 }
1152 }
1153
1154 return $fallback;
1155 }
1156
1157 /**
1158 * Collect accordion pairs from an Oxygen classic shortcode tree.
1159 *
1160 * Parsed rather than rendered. `do_shortcode()` depends on Oxygen having
1161 * registered its `ct_*` handlers in the current request, which it has not
1162 * during bulk analysis, the post-list column, cron, REST or MCP — the same
1163 * trap that once had raw shortcode source counted as a page's prose (#776).
1164 *
1165 * `ct_options` is deliberately not read, which means a composite element's
1166 * accordion contributes nothing from this form. Oxygen base64-encodes a
1167 * composite element's field values inside that blob, so walking it would
1168 * match the encoded string as a question and publish base64 as an FAQ.
1169 * `Builder_Content` reached the same conclusion for word counting: the JSON
1170 * tree is the only readable source for a composite element, and an Oxygen
1171 * classic 4.x site stores one beside its shortcodes. Reporting nothing is
1172 * the right answer for a 3.x site that stores only shortcodes.
1173 *
1174 * @since 2.14.0
1175 * @param string $stored Stored shortcode string.
1176 * @return array<int, array{question: string, answer: string}>
1177 */
1178 private static function accordion_pairs_from_shortcodes(string $stored): array {
1179 if (false === strpos($stored, '[')) {
1180 return [];
1181 }
1182
1183 $fragments = implode('|', array_map('preg_quote', self::ACCORDION_NAME_FRAGMENTS));
1184 $pattern = '/\[([a-z0-9_]*(?:' . $fragments . ')[a-z0-9_]*)\b([^\]]*)\](.*?)\[\/\1\]/is';
1185
1186 if (!preg_match_all($pattern, $stored, $regions, PREG_SET_ORDER)) {
1187 return [];
1188 }
1189
1190 $pairs = [];
1191
1192 foreach ($regions as $region) {
1193 // An item tag is itself an accordion-named tag on most releases
1194 // (`oxy_pro_accordion_item`), so the outermost match is the whole
1195 // accordion and its items are matched again inside it.
1196 $inner = (string) ($region[3] ?? '');
1197
1198 $pairs = array_merge($pairs, self::shortcode_items($inner));
1199 }
1200
1201 return $pairs;
1202 }
1203
1204 /**
1205 * Question/answer pairs from the item tags inside an accordion.
1206 *
1207 * @since 2.14.0
1208 * @param string $inner Shortcode string inside the accordion tag.
1209 * @return array<int, array{question: string, answer: string}>
1210 */
1211 private static function shortcode_items(string $inner): array {
1212 if (!preg_match_all('/\[([a-z0-9_]+)\b([^\]]*)\](.*?)\[\/\1\]/is', $inner, $items, PREG_SET_ORDER)) {
1213 return [];
1214 }
1215
1216 $pairs = [];
1217
1218 foreach ($items as $item) {
1219 $attributes = self::shortcode_attributes((string) ($item[2] ?? ''));
1220 $question = self::first_fragment_value($attributes, self::QUESTION_KEY_FRAGMENTS);
1221 $answer = trim(wp_strip_all_tags(self::strip_shortcode_tags((string) ($item[3] ?? ''))));
1222
1223 if ('' !== $question && '' !== $answer) {
1224 $pairs[] = [
1225 'question' => $question,
1226 'answer' => $answer,
1227 ];
1228
1229 continue;
1230 }
1231 }
1232
1233 return $pairs;
1234 }
1235
1236 /**
1237 * Parse a shortcode tag's attributes into a name => value map.
1238 *
1239 * Parsed into a map rather than probed with one regex per attribute name, so
1240 * the same fragment matching the tree walker uses applies here too. Probing
1241 * by name cannot do that: `\b` does not match inside `accordion_title`,
1242 * because the underscore before it is a word character, so a pattern built
1243 * for `title` silently found nothing on the one attribute Oxygen writes.
1244 *
1245 * `shortcode_parse_atts()` is not used. It arrives with the shortcode API
1246 * rather than being always available, and it folds positional attributes
1247 * into numeric keys that would then be matched as content.
1248 *
1249 * @since 2.14.0
1250 * @param string $attributes Raw attribute string from a shortcode tag.
1251 * @return array<string, string>
1252 */
1253 private static function shortcode_attributes(string $attributes): array {
1254 $pattern = '/([a-z0-9_:-]+)\s*=\s*(?:"([^"]*)"|\'([^\']*)\')/i';
1255
1256 if (!preg_match_all($pattern, $attributes, $matches, PREG_SET_ORDER)) {
1257 return [];
1258 }
1259
1260 $parsed = [];
1261
1262 foreach ($matches as $match) {
1263 $name = strtolower((string) $match[1]);
1264
1265 // First wins, so a repeated attribute cannot have its value
1266 // replaced by a later empty one.
1267 if (isset($parsed[$name])) {
1268 continue;
1269 }
1270
1271 $value = '' !== ($match[2] ?? '') ? $match[2] : ($match[3] ?? '');
1272
1273 $parsed[$name] = trim(wp_specialchars_decode((string) $value, ENT_QUOTES));
1274 }
1275
1276 return $parsed;
1277 }
1278
1279 /**
1280 * Remove shortcode tags while keeping the text between them.
1281 *
1282 * `strip_shortcodes()` is no help: it only knows shortcodes registered in
1283 * the current request, and Oxygen registers none outside a front-end view.
1284 *
1285 * @since 2.14.0
1286 * @param string $content Shortcode string.
1287 * @return string
1288 */
1289 private static function strip_shortcode_tags(string $content): string {
1290 return (string) preg_replace('/\[\/?[a-z0-9_]+\b[^\]]*\]/i', ' ', $content);
1291 }
1292
1293 /**
1294 * FAQ widgets in a post's Elementor tree.
1295 *
1296 * @param \WP_Post $post Post to read.
1297 * @return array<int, array{source: string, schema: bool, pairs: array}>
1298 */
1299 private static function elementor_groups(\WP_Post $post): array {
1300 $raw = get_post_meta($post->ID, '_elementor_data', true);
1301 if (empty($raw) || !is_string($raw)) {
1302 return [];
1303 }
1304
1305 $elements = json_decode($raw, true);
1306 if (!is_array($elements)) {
1307 return [];
1308 }
1309
1310 return self::walk_elementor($elements);
1311 }
1312
1313 /**
1314 * Recurse an Elementor element tree.
1315 *
1316 * @param array $elements Elementor elements.
1317 * @return array<int, array{source: string, schema: bool, pairs: array}>
1318 */
1319 private static function walk_elementor(array $elements): array {
1320 $groups = [];
1321
1322 foreach ($elements as $element) {
1323 if (!is_array($element)) {
1324 continue;
1325 }
1326
1327 if (($element['widgetType'] ?? '') === self::FAQ_WIDGET) {
1328 $settings = is_array($element['settings'] ?? null) ? $element['settings'] : [];
1329
1330 $groups[] = [
1331 'source' => self::SOURCE_ELEMENTOR,
1332 // Mirrors FAQ_Widget: schema unless the toggle is off.
1333 'schema' => 'yes' === ($settings['output_schema'] ?? 'yes'),
1334 'pairs' => self::rows($settings['faqs'] ?? []),
1335 ];
1336 }
1337
1338 if (!empty($element['elements']) && is_array($element['elements'])) {
1339 $groups = array_merge($groups, self::walk_elementor($element['elements']));
1340 }
1341 }
1342
1343 return $groups;
1344 }
1345
1346 /**
1347 * FAQ elements in a post's Bricks tree.
1348 *
1349 * Reads the tree Bricks will actually render — resolved through
1350 * `Builder_Content`, so a page whose content lives on a content template or
1351 * inside a component is covered, and one switched back to the block editor
1352 * is not.
1353 *
1354 * Unlike the block, this is not gated on Bricks owning `post_content`: a
1355 * Bricks element is on the page whenever Bricks renders the page, which is
1356 * exactly what resolving the tree already establishes (#626).
1357 *
1358 * The element's own settings are read here rather than through
1359 * `FAQ_Element`, whose class extends `Bricks\Element` and so cannot even be
1360 * loaded when the theme is inactive — which is exactly the case that still
1361 * has a stored tree, on a site that has since switched themes.
1362 *
1363 * The tree is flat, so no recursion: `Builder_Content::bricks_tree()`
1364 * splices component definitions into the same list.
1365 *
1366 * @param \WP_Post $post Post to read.
1367 * @return array<int, array{source: string, schema: bool, pairs: array}>
1368 */
1369 private static function bricks_groups(\WP_Post $post): array {
1370 $groups = [];
1371
1372 foreach (Builder_Content::bricks_tree((int) $post->ID) as $element) {
1373 if (!is_array($element) || ($element['name'] ?? '') !== self::FAQ_BRICKS_ELEMENT) {
1374 continue;
1375 }
1376
1377 $settings = is_array($element['settings'] ?? null) ? $element['settings'] : [];
1378
1379 $groups[] = [
1380 'source' => self::SOURCE_BRICKS,
1381 // Mirrors FAQ_Element: a cleared Bricks checkbox loses its key.
1382 'schema' => !empty($settings['outputSchema']),
1383 'pairs' => self::rows($settings['faqs'] ?? []),
1384 ];
1385 }
1386
1387 return $groups;
1388 }
1389
1390 /**
1391 * FAQ modules in a post's Beaver Builder layout.
1392 *
1393 * Beaver Builder keeps its layout in postmeta as a map of node objects and
1394 * leaves `post_content` alone, so — unlike Bricks — there is no
1395 * "supersedes post_content" gate to apply: a block FAQ left in the body and
1396 * a module FAQ in the layout can both genuinely be on the page, and both
1397 * belong in the one FAQPage.
1398 *
1399 * The published layout is preferred over the draft for the same reason the
1400 * rest of the plugin prefers it: a draft holds edits no visitor has been
1401 * served yet, and schema must describe the page as delivered.
1402 *
1403 * @param \WP_Post $post Post to read.
1404 * @return array<int, array{source: string, schema: bool, pairs: array}>
1405 */
1406 private static function beaver_groups(\WP_Post $post): array {
1407 $layout = get_post_meta($post->ID, '_fl_builder_data', true);
1408
1409 if (!is_array($layout) || empty($layout)) {
1410 return [];
1411 }
1412
1413 $groups = [];
1414
1415 foreach ($layout as $node) {
1416 $settings = is_object($node) ? ($node->settings ?? null) : ($node['settings'] ?? null);
1417 $settings = is_object($settings) ? get_object_vars($settings) : $settings;
1418
1419 if (!is_array($settings) || ($settings['type'] ?? '') !== self::FAQ_BEAVER_MODULE) {
1420 continue;
1421 }
1422
1423 $groups[] = [
1424 'source' => self::SOURCE_BEAVER,
1425 // Mirrors ThinkRank_Beaver_FAQ_Module::schema_enabled(): Beaver
1426 // Builder stores a cleared toggle as the string '0'.
1427 'schema' => !empty($settings['output_schema']),
1428 'pairs' => self::rows($settings['faqs'] ?? []),
1429 ];
1430 }
1431
1432 return $groups;
1433 }
1434
1435 /**
1436 * Normalise a repeater to a list of arrays.
1437 *
1438 * Beaver Builder stores its rows as stdClass, everything else as arrays.
1439 *
1440 * @param mixed $rows Stored repeater.
1441 * @return array<int, array<string, mixed>>
1442 */
1443 private static function rows($rows): array {
1444 if (!is_array($rows)) {
1445 return [];
1446 }
1447
1448 $normalised = [];
1449
1450 foreach ($rows as $row) {
1451 if (is_object($row)) {
1452 $row = get_object_vars($row);
1453 }
1454
1455 if (is_array($row)) {
1456 $normalised[] = $row;
1457 }
1458 }
1459
1460 return $normalised;
1461 }
1462 }
1463