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
← All changes | includes/seo/class-faq-content.php +1012 -1 2.12.0 → 2.14.0 View file →
@@ -71,8 +71,158 @@
71 71 */
72 72 public const SOURCE_BEAVER = 'beaver';
73 73
74 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 + /**
75 225 * Gutenberg FAQ block name.
76 226 *
77 227 * @var string
78 228 */
@@ -126,13 +276,77 @@
126 276
127 277 $groups = array_merge($groups, self::elementor_groups($post));
128 278 $groups = array_merge($groups, self::bricks_groups($post));
129 279 $groups = array_merge($groups, self::beaver_groups($post));
280 + $groups = array_merge($groups, self::accordion_groups($post));
130 281
131 282 return $groups;
132 283 }
133 284
134 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 + /**
135 349 * Every question/answer pair on a post, flattened and normalised.
136 350 *
137 351 * Rows with no question or no answer are dropped: they are a half-filled
138 352 * repeater row in the editor, not an FAQ entry, and reporting them as one
@@ -176,11 +390,20 @@
176 390 * `_elementor_edit_mode` for a page it owns, Beaver Builder flags
177 391 * `_fl_builder_enabled`, and Bricks is asked through the resolver that
178 392 * already knows when it supersedes `post_content`.
179 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 + *
180 402 * @since 2.10.1
403 + * @since 2.14.0 Detects Oxygen and Breakdance.
181 404 * @param int $post_id Post ID.
182 - * @return string One of the SOURCE_* builder names, or '' for the block editor.
405 + * @return string One of {@see self::builders()}, or '' for the block editor.
183 406 */
184 407 public static function builder(int $post_id): string {
185 408 self::load_builder_content();
186 409
@@ -195,8 +418,126 @@
195 418 if (!empty(get_post_meta($post_id, '_fl_builder_enabled', true))) {
196 419 return self::SOURCE_BEAVER;
197 420 }
198 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 +
199 540 return '';
200 541 }
201 542
202 543 /**
@@ -276,8 +617,678 @@
276 617 }
277 618 }
278 619
279 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);
280 1291 }
281 1292
282 1293 /**
283 1294 * FAQ widgets in a post's Elementor tree.