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 / 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.11.0, at includes/seo/class-faq-content.php

452 lines 15.2 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 * Gutenberg FAQ block name.
76 *
77 * @var string
78 */
79 public const FAQ_BLOCK = 'thinkrank/faq';
80
81 /**
82 * Elementor FAQ widget name.
83 *
84 * @var string
85 */
86 public const FAQ_WIDGET = 'thinkrank-faq';
87
88 /**
89 * Bricks FAQ element name.
90 *
91 * @var string
92 */
93 public const FAQ_BRICKS_ELEMENT = 'thinkrank-faq';
94
95 /**
96 * The Beaver Builder FAQ module's slug, as stored in its layout nodes.
97 *
98 * Matches `ThinkRank_Beaver_FAQ_Module::SLUG`. Duplicated as a literal
99 * rather than referenced, because that class extends `FLBuilderModule` and
100 * so cannot be loaded at all when Beaver Builder is inactive — which is
101 * exactly the site that still has a stored layout, after a builder switch.
102 *
103 * @var string
104 */
105 public const FAQ_BEAVER_MODULE = 'thinkrank-faq';
106
107 /**
108 * Every FAQ producer found on a post, in collection order.
109 *
110 * @since 2.10.1
111 * @param \WP_Post $post Post to read.
112 * @return array<int, array{source: string, schema: bool, pairs: array}>
113 */
114 public static function groups(\WP_Post $post): array {
115 $groups = [];
116
117 // A Bricks page throws `post_content` away, so a FAQ block left there
118 // when the page was switched over never renders. Reporting its
119 // questions would describe content no visitor can see, which Google
120 // treats as a violation rather than merely a duplicate (#650).
121 self::load_builder_content();
122
123 if (!Builder_Content::bricks_supersedes_post_content((int) $post->ID)) {
124 $groups = array_merge($groups, self::block_groups($post));
125 }
126
127 $groups = array_merge($groups, self::elementor_groups($post));
128 $groups = array_merge($groups, self::bricks_groups($post));
129 $groups = array_merge($groups, self::beaver_groups($post));
130
131 return $groups;
132 }
133
134 /**
135 * Every question/answer pair on a post, flattened and normalised.
136 *
137 * Rows with no question or no answer are dropped: they are a half-filled
138 * repeater row in the editor, not an FAQ entry, and reporting them as one
139 * would have an agent "fixing" content the author is still writing.
140 *
141 * @since 2.10.1
142 * @param \WP_Post $post Post to read.
143 * @return array<int, array{question: string, answer: string, source: string, schema_enabled: bool, image_id: int, image_url: string, image_alt: string}>
144 */
145 public static function items(\WP_Post $post): array {
146 $items = [];
147
148 foreach (self::groups($post) as $group) {
149 foreach (self::rows($group['pairs']) as $row) {
150 $question = trim(wp_strip_all_tags((string) ($row['question'] ?? '')));
151 $answer = trim((string) ($row['answer'] ?? ''));
152
153 if ('' === $question || '' === $answer) {
154 continue;
155 }
156
157 $items[] = [
158 'question' => $question,
159 'answer' => $answer,
160 'source' => $group['source'],
161 'schema_enabled' => $group['schema'],
162 'image_id' => (int) ($row['imageId'] ?? $row['image_id'] ?? 0),
163 'image_url' => (string) ($row['imageUrl'] ?? $row['image_url'] ?? ''),
164 'image_alt' => (string) ($row['imageAlt'] ?? $row['image_alt'] ?? ''),
165 ];
166 }
167 }
168
169 return $items;
170 }
171
172 /**
173 * Which page builder renders this post, if any.
174 *
175 * Each test is the builder's own: Elementor stores `builder` in
176 * `_elementor_edit_mode` for a page it owns, Beaver Builder flags
177 * `_fl_builder_enabled`, and Bricks is asked through the resolver that
178 * already knows when it supersedes `post_content`.
179 *
180 * @since 2.10.1
181 * @param int $post_id Post ID.
182 * @return string One of the SOURCE_* builder names, or '' for the block editor.
183 */
184 public static function builder(int $post_id): string {
185 self::load_builder_content();
186
187 if ('builder' === (string) get_post_meta($post_id, '_elementor_edit_mode', true)) {
188 return self::SOURCE_ELEMENTOR;
189 }
190
191 if (Builder_Content::bricks_supersedes_post_content($post_id)) {
192 return self::SOURCE_BRICKS;
193 }
194
195 if (!empty(get_post_meta($post_id, '_fl_builder_enabled', true))) {
196 return self::SOURCE_BEAVER;
197 }
198
199 return '';
200 }
201
202 /**
203 * Load Builder_Content, which resolves the Bricks half of the answer.
204 *
205 * Required rather than autoloaded for the same reason Schema_Graph used to
206 * require it: this runs in contexts where the plugin autoloader is not
207 * guaranteed to be registered.
208 *
209 * @return void
210 */
211 private static function load_builder_content(): void {
212 if (class_exists('ThinkRank\\SEO\\Builder_Content')) {
213 return;
214 }
215
216 $file = THINKRANK_PLUGIN_DIR . 'includes/seo/class-builder-content.php';
217 if (file_exists($file)) {
218 require_once $file;
219 }
220 }
221
222 /**
223 * FAQ blocks in a post's content, including nested ones.
224 *
225 * @param \WP_Post $post Post to read.
226 * @return array<int, array{source: string, schema: bool, pairs: array}>
227 */
228 private static function block_groups(\WP_Post $post): array {
229 if (!function_exists('parse_blocks') || !has_blocks($post->post_content)) {
230 return [];
231 }
232
233 return self::walk_blocks(parse_blocks($post->post_content));
234 }
235
236 /**
237 * Recurse a parsed block tree.
238 *
239 * @param array $blocks Parsed blocks.
240 * @return array<int, array{source: string, schema: bool, pairs: array}>
241 */
242 private static function walk_blocks(array $blocks): array {
243 $groups = [];
244
245 foreach ($blocks as $block) {
246 if (!is_array($block)) {
247 continue;
248 }
249
250 $block_name = (string) ($block['blockName'] ?? '');
251 $attrs = is_array($block['attrs'] ?? null) ? $block['attrs'] : [];
252
253 // A leftover Rank Math FAQ block is absorbed as if it were ours, so
254 // an unmigrated post contributes its questions to the single
255 // FAQPage rather than to nothing at all (#777). The fallback stays
256 // silent while Rank Math is active and still emitting its own.
257 if (\ThinkRank\Integrations\Rank_Math_Blocks::is_source_block($block_name)) {
258 $fallback = \ThinkRank\Integrations\Rank_Math_Blocks::schema_fallback($block_name, $attrs);
259 if (null !== $fallback) {
260 $block_name = $fallback['name'];
261 $attrs = $fallback['attrs'];
262 }
263 }
264
265 if ($block_name === self::FAQ_BLOCK) {
266 $groups[] = [
267 'source' => self::SOURCE_BLOCK,
268 // Mirrors Blocks_Manager: schema is on unless explicitly disabled.
269 'schema' => !(array_key_exists('outputSchema', $attrs) && false === $attrs['outputSchema']),
270 'pairs' => self::rows($attrs['faqs'] ?? []),
271 ];
272 }
273
274 if (!empty($block['innerBlocks']) && is_array($block['innerBlocks'])) {
275 $groups = array_merge($groups, self::walk_blocks($block['innerBlocks']));
276 }
277 }
278
279 return $groups;
280 }
281
282 /**
283 * FAQ widgets in a post's Elementor tree.
284 *
285 * @param \WP_Post $post Post to read.
286 * @return array<int, array{source: string, schema: bool, pairs: array}>
287 */
288 private static function elementor_groups(\WP_Post $post): array {
289 $raw = get_post_meta($post->ID, '_elementor_data', true);
290 if (empty($raw) || !is_string($raw)) {
291 return [];
292 }
293
294 $elements = json_decode($raw, true);
295 if (!is_array($elements)) {
296 return [];
297 }
298
299 return self::walk_elementor($elements);
300 }
301
302 /**
303 * Recurse an Elementor element tree.
304 *
305 * @param array $elements Elementor elements.
306 * @return array<int, array{source: string, schema: bool, pairs: array}>
307 */
308 private static function walk_elementor(array $elements): array {
309 $groups = [];
310
311 foreach ($elements as $element) {
312 if (!is_array($element)) {
313 continue;
314 }
315
316 if (($element['widgetType'] ?? '') === self::FAQ_WIDGET) {
317 $settings = is_array($element['settings'] ?? null) ? $element['settings'] : [];
318
319 $groups[] = [
320 'source' => self::SOURCE_ELEMENTOR,
321 // Mirrors FAQ_Widget: schema unless the toggle is off.
322 'schema' => 'yes' === ($settings['output_schema'] ?? 'yes'),
323 'pairs' => self::rows($settings['faqs'] ?? []),
324 ];
325 }
326
327 if (!empty($element['elements']) && is_array($element['elements'])) {
328 $groups = array_merge($groups, self::walk_elementor($element['elements']));
329 }
330 }
331
332 return $groups;
333 }
334
335 /**
336 * FAQ elements in a post's Bricks tree.
337 *
338 * Reads the tree Bricks will actually render — resolved through
339 * `Builder_Content`, so a page whose content lives on a content template or
340 * inside a component is covered, and one switched back to the block editor
341 * is not.
342 *
343 * Unlike the block, this is not gated on Bricks owning `post_content`: a
344 * Bricks element is on the page whenever Bricks renders the page, which is
345 * exactly what resolving the tree already establishes (#626).
346 *
347 * The element's own settings are read here rather than through
348 * `FAQ_Element`, whose class extends `Bricks\Element` and so cannot even be
349 * loaded when the theme is inactive — which is exactly the case that still
350 * has a stored tree, on a site that has since switched themes.
351 *
352 * The tree is flat, so no recursion: `Builder_Content::bricks_tree()`
353 * splices component definitions into the same list.
354 *
355 * @param \WP_Post $post Post to read.
356 * @return array<int, array{source: string, schema: bool, pairs: array}>
357 */
358 private static function bricks_groups(\WP_Post $post): array {
359 $groups = [];
360
361 foreach (Builder_Content::bricks_tree((int) $post->ID) as $element) {
362 if (!is_array($element) || ($element['name'] ?? '') !== self::FAQ_BRICKS_ELEMENT) {
363 continue;
364 }
365
366 $settings = is_array($element['settings'] ?? null) ? $element['settings'] : [];
367
368 $groups[] = [
369 'source' => self::SOURCE_BRICKS,
370 // Mirrors FAQ_Element: a cleared Bricks checkbox loses its key.
371 'schema' => !empty($settings['outputSchema']),
372 'pairs' => self::rows($settings['faqs'] ?? []),
373 ];
374 }
375
376 return $groups;
377 }
378
379 /**
380 * FAQ modules in a post's Beaver Builder layout.
381 *
382 * Beaver Builder keeps its layout in postmeta as a map of node objects and
383 * leaves `post_content` alone, so — unlike Bricks — there is no
384 * "supersedes post_content" gate to apply: a block FAQ left in the body and
385 * a module FAQ in the layout can both genuinely be on the page, and both
386 * belong in the one FAQPage.
387 *
388 * The published layout is preferred over the draft for the same reason the
389 * rest of the plugin prefers it: a draft holds edits no visitor has been
390 * served yet, and schema must describe the page as delivered.
391 *
392 * @param \WP_Post $post Post to read.
393 * @return array<int, array{source: string, schema: bool, pairs: array}>
394 */
395 private static function beaver_groups(\WP_Post $post): array {
396 $layout = get_post_meta($post->ID, '_fl_builder_data', true);
397
398 if (!is_array($layout) || empty($layout)) {
399 return [];
400 }
401
402 $groups = [];
403
404 foreach ($layout as $node) {
405 $settings = is_object($node) ? ($node->settings ?? null) : ($node['settings'] ?? null);
406 $settings = is_object($settings) ? get_object_vars($settings) : $settings;
407
408 if (!is_array($settings) || ($settings['type'] ?? '') !== self::FAQ_BEAVER_MODULE) {
409 continue;
410 }
411
412 $groups[] = [
413 'source' => self::SOURCE_BEAVER,
414 // Mirrors ThinkRank_Beaver_FAQ_Module::schema_enabled(): Beaver
415 // Builder stores a cleared toggle as the string '0'.
416 'schema' => !empty($settings['output_schema']),
417 'pairs' => self::rows($settings['faqs'] ?? []),
418 ];
419 }
420
421 return $groups;
422 }
423
424 /**
425 * Normalise a repeater to a list of arrays.
426 *
427 * Beaver Builder stores its rows as stdClass, everything else as arrays.
428 *
429 * @param mixed $rows Stored repeater.
430 * @return array<int, array<string, mixed>>
431 */
432 private static function rows($rows): array {
433 if (!is_array($rows)) {
434 return [];
435 }
436
437 $normalised = [];
438
439 foreach ($rows as $row) {
440 if (is_object($row)) {
441 $row = get_object_vars($row);
442 }
443
444 if (is_array($row)) {
445 $normalised[] = $row;
446 }
447 }
448
449 return $normalised;
450 }
451 }
452