PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / 2.3.0
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO v2.3.0
2.7.0 2.6.0 2.5.0 2.4.0 2.3.0 2.2.0 2.1.1 2.1.0 2.0.2 2.0.1 2.0.0 1.32.0 1.31.0 1.30.0 1.29.0 1.28.0 1.27.0 1.26.0 1.25.0 trunk 1.0.0 1.0.1 1.0.2 1.1.0 1.10.0 All 48 releases
thinkrank / includes / seo / class-builder-content.php

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

1,063 lines 39.9 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Page-builder content extraction.
4 *
5 * SEO analysis reads `post_content`, which is only the real text on a classic
6 * post. Page builders keep the words somewhere else, and every server-side
7 * scoring path — bulk analysis, the post-list SEO Overview column, the MCP
8 * abilities, cron reports — saw an empty page as a result:
9 *
10 * - Oxygen / Breakdance leave `post_content` completely EMPTY and store the
11 * node tree in postmeta. Nothing to render, nothing to strip: the analyzer
12 * reported "No content" on pages with well over a thousand visible words.
13 * - Elementor does the same via `_elementor_data`.
14 * - Divi 5 and Gutenberg do store block markup in `post_content`, but Divi
15 * keeps module text inside the block's JSON attributes — inside an HTML
16 * comment, which tag stripping removes wholesale.
17 * - Divi 4 and other shortcode builders keep text in shortcode attributes.
18 *
19 * Extraction reads the builder's own stored data rather than invoking its
20 * render engine. Rendering an Oxygen page outside a front-end request is slow,
21 * stateful and can fatal in an admin context, whereas the stored tree is just
22 * JSON — cheap, side-effect free and safe to touch during a bulk run.
23 *
24 * @package ThinkRank\SEO
25 * @since 1.23.0
26 */
27
28 declare(strict_types=1);
29
30 namespace ThinkRank\SEO;
31
32 if (!defined('ABSPATH')) {
33 exit;
34 }
35
36 /**
37 * Resolves the analyzable content of a post, whatever built it.
38 */
39 class Builder_Content {
40
41 /**
42 * Post meta keys that hold builder data, in priority order.
43 *
44 * Several generations of the same builder are listed on purpose: Oxygen 6
45 * is Breakdance under the hood (`_breakdance_data`), while earlier Oxygen
46 * releases used `_oxygen_data` or the shortcode-based
47 * `ct_builder_shortcodes`. A site can only have one of them.
48 *
49 * @var string[]
50 */
51 private const BUILDER_META_KEYS = [
52 '_breakdance_data', // Oxygen 6+ / Breakdance
53 '_oxygen_data', // Oxygen (earlier releases)
54 'ct_builder_shortcodes', // Oxygen classic
55 '_elementor_data', // Elementor
56 // Beaver Builder. Published layout first: `_fl_builder_draft` holds
57 // unsaved changes and would score content the visitor cannot see.
58 // Both are arrays of stdClass nodes, which is why the walker below
59 // has to treat objects like arrays (#449).
60 '_fl_builder_data', // Beaver Builder (published)
61 '_fl_builder_draft', // Beaver Builder (unsaved changes)
62 ];
63
64 /**
65 * Bricks' content-area meta key, used when Bricks itself isn't loaded.
66 *
67 * Bricks exposes `BRICKS_DB_PAGE_CONTENT` and renames the underlying key
68 * between generations (it gained the `_2` suffix in 1.7.3), so the
69 * constant is authoritative and this literal is only the fallback for the
70 * contexts where it is undefined — Bricks is a theme, so on an admin or
71 * CLI request against a site that has since switched themes the constant
72 * is simply not there while the post meta still is.
73 *
74 * Bricks stores three areas — header, content and footer. Only the content
75 * area belongs to the post being scored; the header and footer areas live
76 * on Bricks' own template posts and would double-count site chrome into
77 * every page's word count, so they are deliberately not read here.
78 *
79 * @since 2.2.1
80 * @var string
81 */
82 private const BRICKS_CONTENT_META_KEY = '_bricks_page_content_2';
83
84 /**
85 * Bricks' per-post editor-mode meta key, used when Bricks isn't loaded.
86 *
87 * @since 2.2.1
88 * @var string
89 */
90 private const BRICKS_EDITOR_MODE_META_KEY = '_bricks_editor_mode';
91
92 /**
93 * Bricks' components option, used when Bricks itself isn't loaded.
94 *
95 * @since 2.2.1
96 * @var string
97 */
98 private const BRICKS_COMPONENTS_OPTION = 'bricks_components';
99
100 /**
101 * JSON keys whose values are user-visible text.
102 *
103 * Builder trees mix content with configuration, so a blind string sweep
104 * would count CSS classes and option slugs as words. Matching on the key
105 * keeps the word count honest.
106 *
107 * @var string[]
108 */
109 private const CONTENT_KEYS = [
110 'text', 'title', 'subtitle', 'heading', 'subheading', 'content',
111 'description', 'caption', 'excerpt', 'label', 'value', 'html',
112 'editor', 'quote', 'answer', 'question', 'body', 'button_text',
113 ];
114
115 /**
116 * JSON keys whose values hold a link destination.
117 *
118 * Builders store a link's destination in a structured field separate from
119 * its label, either as a bare URL string or as a `{ url: … }` object.
120 * Neither shape survives a text sweep — the key is not content and a bare
121 * URL contains no `<` — so no `<a>` tag reached the link counters.
122 *
123 * @var string[]
124 */
125 private const URL_KEYS = [
126 'link', 'url', 'href', 'link_url', 'button_link', 'permalink', 'link_to',
127 ];
128
129 /**
130 * JSON keys whose values hold an image, as a URL string or `{ url, alt }`.
131 *
132 * @var string[]
133 */
134 private const IMAGE_KEYS = [
135 'image', 'src', 'image_url', 'background_image', 'bg_image', 'photo',
136 ];
137
138 /**
139 * JSON keys that carry a heading level for the node's text.
140 *
141 * A builder heading's text is collected (its key is in CONTENT_KEYS) and so
142 * counts toward the word count, but it arrives as bare text with no `<h2>`
143 * wrapper — which is why heading-structure checks saw none.
144 *
145 * @var string[]
146 */
147 private const HEADING_TAG_KEYS = [
148 'header_size', 'heading_tag', 'html_tag', 'title_tag', 'tag', 'level', 'size',
149 ];
150
151 /**
152 * Keys whose value is alternative text for a sibling image.
153 *
154 * @var string[]
155 */
156 private const ALT_KEYS = ['alt', 'alt_text', 'image_alt', 'title'];
157
158 /**
159 * Resolve the content worth analyzing for a post.
160 *
161 * @param \WP_Post $post Post being analyzed.
162 * @return string HTML/text to analyze.
163 */
164 public static function resolve(\WP_Post $post): string {
165 return self::resolve_markup((string) $post->post_content, $post);
166 }
167
168 /**
169 * Resolve an arbitrary chunk of editor markup for the given post.
170 *
171 * The editor sends its live content to the scorer so an author sees their
172 * unsaved edits reflected. On a builder page that live string is the raw
173 * builder markup — the block editor hands over Divi's
174 * `<!-- wp:divi/... -->` comments verbatim, because it cannot render
175 * blocks it has no client-side registration for. Analyzed as-is it reads
176 * as zero words, which is how a Divi page could show a correct saved score
177 * while the live Content Analysis panel next to it still said
178 * "No content".
179 *
180 * Running the live string through the same chain as stored content keeps
181 * both paths honest, and falling through to the post's builder storage
182 * covers builders (Oxygen) whose editor content is empty to begin with.
183 *
184 * @since 1.23.0
185 *
186 * @param string $raw Markup to analyze.
187 * @param \WP_Post $post Post the markup belongs to.
188 * @return string Content to analyze.
189 */
190 public static function resolve_markup(string $raw, \WP_Post $post): string {
191 $content = self::render_post_content($raw);
192
193 // Block markup that renders to nothing usually means the builder that
194 // owns those blocks did not register them in this context — Divi 5
195 // loads its module library lazily per-request, so in CLI, REST, admin
196 // and block-editor requests do_blocks() yields an empty string while
197 // the words sit right there in the block attributes. Read them
198 // directly.
199 if (self::is_blank($content)) {
200 $from_blocks = self::from_block_attributes($raw);
201 if (!self::is_blank($from_blocks)) {
202 $content = $from_blocks;
203 }
204 }
205
206 // Only reach for builder storage when the markup yielded nothing — a
207 // classic post must never pay for this.
208 if (self::is_blank($content)) {
209 $builder = self::from_builder_meta((int) $post->ID);
210 if (!self::is_blank($builder)) {
211 $content = $builder;
212 }
213 }
214
215 // A resolution that collapsed to nothing is worse than the raw markup.
216 if (self::is_blank($content) && !self::is_blank($raw)) {
217 $content = $raw;
218 }
219
220 /**
221 * Filter the content ThinkRank analyzes for a post.
222 *
223 * Use this to teach ThinkRank about a builder it does not know, or to
224 * override extraction for one it does.
225 *
226 * @since 1.23.0
227 *
228 * @param string $content Resolved content.
229 * @param \WP_Post $post Post being analyzed.
230 * @param string $raw Markup this resolution started from.
231 */
232 return (string) apply_filters('thinkrank_analyzable_content', $content, $post, $raw);
233 }
234
235 /**
236 * Render blocks and shortcodes found in post_content.
237 *
238 * Best-effort: a third-party block that fatals must not take the whole
239 * score down with it.
240 *
241 * @param string $raw Raw post content.
242 * @return string Rendered content.
243 */
244 private static function render_post_content(string $raw): string {
245 if ('' === trim($raw)) {
246 return '';
247 }
248
249 $content = $raw;
250
251 try {
252 if (function_exists('has_blocks') && function_exists('do_blocks') && has_blocks($raw)) {
253 $content = do_blocks($raw);
254 }
255
256 // Block output can itself contain shortcodes, so this runs either way.
257 if (function_exists('do_shortcode') && strpos($content, '[') !== false) {
258 $content = do_shortcode($content);
259 }
260 } catch (\Throwable $e) {
261 return $raw;
262 }
263
264 return self::is_blank($content) ? $raw : $content;
265 }
266
267 /**
268 * Extract text from the attributes of parsed blocks.
269 *
270 * @param string $raw Raw post content containing block markup.
271 * @return string Collected text, or '' when nothing was found.
272 */
273 private static function from_block_attributes(string $raw): string {
274 if (!function_exists('parse_blocks') || !function_exists('has_blocks') || !has_blocks($raw)) {
275 return '';
276 }
277
278 try {
279 $blocks = parse_blocks($raw);
280 } catch (\Throwable $e) {
281 return '';
282 }
283
284 $attrs = [];
285 $collect = static function (array $items) use (&$collect, &$attrs): void {
286 foreach ($items as $block) {
287 if (!empty($block['attrs']) && is_array($block['attrs'])) {
288 $attrs[] = $block['attrs'];
289 }
290 if (!empty($block['innerBlocks']) && is_array($block['innerBlocks'])) {
291 $collect($block['innerBlocks']);
292 }
293 }
294 };
295 $collect($blocks);
296
297 return empty($attrs) ? '' : self::text_from_tree($attrs);
298 }
299
300 /**
301 * Everything Bricks contributes to this post's analyzable content.
302 *
303 * Bricks is the only builder here that needs more than a meta key, on
304 * three counts:
305 *
306 * - It leaves its stored tree behind when a post is switched back to the
307 * block editor, so an editor-mode gate has to run first or ThinkRank
308 * scores markup the visitor never sees — the same failure
309 * `_fl_builder_draft` was ordered against in #449.
310 * - A post's content can live on ANOTHER post. Bricks' Templates feature
311 * assigns a content template by condition, and a page using one stores
312 * nothing of its own; reading only the page's meta scores it blank
313 * while the visitor reads a full page.
314 * - Its stored text carries dynamic-data tags and internal element names
315 * that never reach the rendered page.
316 *
317 * @since 2.2.1
318 *
319 * @param int $post_id Post being resolved.
320 * @return string Extracted text, or '' when Bricks has nothing for it.
321 */
322 private static function from_bricks(int $post_id): string {
323 if (!self::bricks_owns_post($post_id)) {
324 return '';
325 }
326
327 $source = self::bricks_content_source($post_id);
328 if (!$source) {
329 return '';
330 }
331
332 $stored = get_post_meta($source, self::bricks_meta_key(), true);
333
334 if (is_string($stored)) {
335 $stored = '' === trim($stored) ? null : json_decode($stored, true);
336 }
337
338 if (!is_array($stored) || empty($stored)) {
339 return '';
340 }
341
342 return self::strip_bricks_dynamic_tags(
343 self::text_from_tree(
344 self::without_bricks_element_labels(self::expand_bricks_components($stored))
345 )
346 );
347 }
348
349 /**
350 * Whether Bricks — not the block editor — renders this post.
351 *
352 * Bricks writes `bricks` or `wordpress` into its editor-mode meta as the
353 * author toggles between the two, and never clears the content it stored
354 * for the other mode. Only the `wordpress` value is disqualifying: an
355 * absent value is the normal state for a post Bricks built and never
356 * toggled. This follows Bricks' own `Helpers::render_with_bricks()`, which
357 * bails on exactly that one value.
358 *
359 * It deliberately does not match it exactly: the comparison here is
360 * case-insensitive, where Bricks' is strict. Bricks 2.3.12 only ever writes
361 * the value lowercase, so the two agree on everything Bricks itself
362 * stores; they part company only on a value some other integration wrote.
363 * The two shipping today disagree about the casing — SureRank compares
364 * against `'WordPress'`, AIOSEO against `'bricks'` — and of the two ways to
365 * be wrong about `'WordPress'`, blocking costs a score on a page that has
366 * one, while allowing scores stale content the visitor never sees, which is
367 * the failure this gate exists to prevent.
368 *
369 * @since 2.2.1
370 *
371 * @param int $post_id Post being resolved.
372 * @return bool
373 */
374 private static function bricks_owns_post(int $post_id): bool {
375 $mode = get_post_meta($post_id, self::bricks_editor_mode_key(), true);
376
377 // phpcs:ignore WordPress.WP.CapitalPDangit.MisspelledInText -- Bricks' own stored meta value, lower-cased for the comparison.
378 return !(is_string($mode) && 'wordpress' === strtolower(trim($mode)));
379 }
380
381 /**
382 * The post whose Bricks tree actually renders for this post.
383 *
384 * Usually the post itself. When it stores nothing of its own, Bricks falls
385 * back to whichever content template's conditions match, and that template
386 * is a separate post carrying the words the visitor reads.
387 *
388 * Resolution is delegated to Bricks rather than reimplemented: template
389 * conditions are a whole rules engine (post IDs, types, taxonomies,
390 * archives), and a second implementation would drift from it. Bricks
391 * answers through statics, so they are saved and restored around the call —
392 * `set_active_templates()` returns early once populated, and on a
393 * front-end request Bricks has already populated it for the page being
394 * served. Clobbering that would corrupt the render in progress.
395 *
396 * Best-effort by design: any failure returns the post's own data, which is
397 * exactly today's behaviour.
398 *
399 * @since 2.2.1
400 *
401 * @param int $post_id Post being resolved.
402 * @return int Post ID holding the Bricks tree, or 0 when there is none.
403 */
404 private static function bricks_content_source(int $post_id): int {
405 $own = get_post_meta($post_id, self::bricks_meta_key(), true);
406 if ((is_array($own) && !empty($own)) || (is_string($own) && '' !== trim($own))) {
407 return $post_id;
408 }
409
410 if (!class_exists('\\Bricks\\Database')
411 || !method_exists('\\Bricks\\Database', 'set_active_templates')
412 ) {
413 return 0;
414 }
415
416 // `set_active_templates()` writes TWO statics — `$active_templates` and,
417 // when a header template resolves, `$header_position`. Both are saved,
418 // and both are restored in `finally` rather than on the happy path: a
419 // throw part-way through (a third-party hook on
420 // `bricks/database/content_type`, `bricks/builder/data_post_id` or
421 // `bricks/active_templates` is enough) must not leave Bricks' render
422 // state holding this lookup's values. Restoring only after a clean
423 // return is what the `catch` below would otherwise skip.
424 $has_header_position = property_exists('\\Bricks\\Database', 'header_position');
425 $saved_templates = \Bricks\Database::$active_templates;
426 $saved_header_position = $has_header_position ? \Bricks\Database::$header_position : null;
427
428 try {
429 \Bricks\Database::$active_templates = [];
430 \Bricks\Database::set_active_templates($post_id);
431 $template = (int) (\Bricks\Database::$active_templates['content'] ?? 0);
432 } catch (\Throwable $e) {
433 return 0;
434 } finally {
435 \Bricks\Database::$active_templates = $saved_templates;
436 if ($has_header_position) {
437 \Bricks\Database::$header_position = $saved_header_position;
438 }
439 }
440
441 // A template that is the post itself adds nothing over the empty read
442 // above, and would otherwise recurse conceptually.
443 return $template === $post_id ? 0 : $template;
444 }
445
446 /**
447 * Splice component definitions into the tree.
448 *
449 * A Bricks component keeps its markup in the `bricks_components` option,
450 * not on the page. The page stores only an instance: an element carrying
451 * `cid` and, usually, empty `settings`. Walking the page alone therefore
452 * found no words at all, and a page built entirely from components scored
453 * blank — the same failure as a page built from a content template.
454 *
455 * Confirmed on Bricks 2.3.12: `Bricks\Frontend::render_data()` renders the
456 * component's copy from an instance this walker extracted '' from.
457 *
458 * The definition is read straight from the option rather than through
459 * `Bricks\Helpers::get_component_instance()`. That helper resolves an
460 * instance's property overrides, which would be better, but it reads
461 * `Bricks\Database::$global_data['components']` — populated once per
462 * request, and empty in the admin and CLI contexts where bulk scoring
463 * runs. Refreshing it would mean writing to Bricks' live render state, the
464 * same hazard the template resolver is careful to avoid, and gating on it
465 * would make a page score differently in wp-admin than on the front end.
466 * Reading the stored definition is consistent everywhere.
467 *
468 * The trade-off: an instance that overrides a component property is scored
469 * with the component's authored copy rather than the override. That is the
470 * text the component renders by default, and it is much closer than the
471 * nothing this returned before.
472 *
473 * @since 2.2.1
474 *
475 * @param array $tree Bricks content area.
476 * @return array Tree with component elements spliced in after each instance.
477 */
478 private static function expand_bricks_components(array $tree): array {
479 $expanded = [];
480 $open = [];
481
482 $walk = static function (array $elements, int $depth) use (&$walk, &$expanded, &$open): void {
483 foreach ($elements as $element) {
484 $expanded[] = $element;
485
486 if (!is_array($element) || empty($element['cid']) || !is_string($element['cid'])) {
487 continue;
488 }
489
490 $cid = $element['cid'];
491
492 // A component nested inside its own definition would recurse
493 // forever; the depth cap covers deep but legitimate nesting.
494 if (isset($open[$cid]) || $depth > 4) {
495 continue;
496 }
497
498 $children = self::bricks_component_elements($cid);
499 if (empty($children)) {
500 continue;
501 }
502
503 // Re-entrant per branch, not per page: the guard is released
504 // after the walk so a second instance further along the page
505 // still expands, rather than being mistaken for recursion.
506 //
507 // That does NOT double the word count — `text_from_tree()`
508 // ends in `array_unique()`, which collapses a repeated
509 // component's copy the same way it collapses a value repeated
510 // across responsive breakpoints. Expanding both instances is
511 // about not silently dropping the second one's structure.
512 $open[$cid] = true;
513 $walk($children, $depth + 1);
514 unset($open[$cid]);
515 }
516 };
517
518 $walk($tree, 0);
519
520 return $expanded;
521 }
522
523 /**
524 * The stored elements of one Bricks component.
525 *
526 * @since 2.2.1
527 *
528 * @param string $cid Component id held by an instance element.
529 * @return array Component elements, or [] when it cannot be resolved.
530 */
531 private static function bricks_component_elements(string $cid): array {
532 $components = get_option(self::bricks_constant('BRICKS_DB_COMPONENTS', self::BRICKS_COMPONENTS_OPTION), []);
533
534 if (!is_array($components)) {
535 return [];
536 }
537
538 foreach ($components as $component) {
539 $component = self::as_children($component);
540 if (null === $component) {
541 continue;
542 }
543
544 if (isset($component['id']) && $component['id'] === $cid && !empty($component['elements'])) {
545 return is_array($component['elements']) ? $component['elements'] : [];
546 }
547 }
548
549 return [];
550 }
551
552 /**
553 * Drop each Bricks element's internal name before the tree is walked.
554 *
555 * A Bricks element carries an optional top-level `label` — the nickname an
556 * author types in the Structure panel to find it again ("Hero headline",
557 * "CTA row"). It is builder chrome and is never rendered, but `label` is in
558 * CONTENT_KEYS because it is real content for other builders' form fields,
559 * so it was being counted as page copy.
560 *
561 * Only the element's own `label` is removed. A `label` inside `settings`
562 * is a rendered field label and stays.
563 *
564 * @since 2.2.1
565 *
566 * @param array $tree Bricks content area.
567 * @return array Tree with element nicknames removed.
568 */
569 private static function without_bricks_element_labels(array $tree): array {
570 foreach ($tree as $index => $element) {
571 if (is_array($element) && isset($element['id'], $element['label'])) {
572 unset($tree[$index]['label']);
573 }
574 }
575
576 return $tree;
577 }
578
579 /**
580 * Remove Bricks dynamic-data tags from extracted text.
581 *
582 * Bricks stores `{post_title}`, `{post_meta:price}`, `{echo:my_fn}` and the
583 * like verbatim and resolves them when it renders. Extraction reads the
584 * stored tree, so without this the placeholders were counted as words, and
585 * a heading whose text is `{post_title}` reported the literal token as its
586 * heading text.
587 *
588 * The pattern is deliberately narrower than Bricks' own
589 * (`/{([\wÀ-ÖØ-öø-ÿ\-\s\.\/:\(\)...]+)}/u`), which also matches braces
590 * containing spaces. Bricks only substitutes tags that resolve to a
591 * registered provider and leaves anything else on the page as literal text,
592 * so the broad pattern would delete prose the visitor can actually read.
593 * Matching only tag-shaped tokens keeps every real sentence and still
594 * removes every placeholder — the same trade-off SureRank makes.
595 *
596 * @since 2.2.1
597 *
598 * @param string $text Extracted text.
599 * @return string Text with placeholders removed.
600 */
601 private static function strip_bricks_dynamic_tags(string $text): string {
602 $stripped = preg_replace('/\{[a-z0-9_][a-z0-9_:\-\.]*\}/i', '', $text);
603
604 if (null === $stripped) {
605 return $text;
606 }
607
608 // Collapse the runs of spaces a removed tag leaves mid-sentence,
609 // without touching the newlines that separate collected nodes.
610 $tidied = preg_replace('/[ \t]{2,}/', ' ', $stripped);
611
612 return null === $tidied ? $stripped : $tidied;
613 }
614
615 /**
616 * Bricks' content-area meta key, preferring Bricks' own constant.
617 *
618 * @since 2.2.1
619 *
620 * @return string
621 */
622 private static function bricks_meta_key(): string {
623 return self::bricks_constant('BRICKS_DB_PAGE_CONTENT', self::BRICKS_CONTENT_META_KEY);
624 }
625
626 /**
627 * Bricks' editor-mode meta key, preferring Bricks' own constant.
628 *
629 * @since 2.2.1
630 *
631 * @return string
632 */
633 private static function bricks_editor_mode_key(): string {
634 return self::bricks_constant('BRICKS_DB_EDITOR_MODE', self::BRICKS_EDITOR_MODE_META_KEY);
635 }
636
637 /**
638 * Read one of Bricks' key-name constants, falling back to the literal.
639 *
640 * @since 2.2.1
641 *
642 * @param string $name Constant name.
643 * @param string $fallback Key to use when the constant is unavailable.
644 * @return string
645 */
646 private static function bricks_constant(string $name, string $fallback): string {
647 if (defined($name)) {
648 $value = constant($name);
649 if (is_string($value) && '' !== trim($value)) {
650 return $value;
651 }
652 }
653
654 return $fallback;
655 }
656
657 /**
658 * Pull text out of whichever builder stored this post.
659 *
660 * @param int $post_id Post ID.
661 * @return string Extracted text, or '' when no builder data was found.
662 */
663 private static function from_builder_meta(int $post_id): string {
664 // Bricks first: it is the only builder whose content can live on
665 // another post, and the only one gated on an editor mode.
666 $bricks = self::from_bricks($post_id);
667 if (!self::is_blank($bricks)) {
668 return $bricks;
669 }
670
671 foreach (self::BUILDER_META_KEYS as $key) {
672 $stored = get_post_meta($post_id, $key, true);
673
674 if (is_string($stored) && '' !== trim($stored)) {
675 $decoded = json_decode($stored, true);
676
677 // JSON node tree (Breakdance/Oxygen 6, Elementor).
678 if (is_array($decoded)) {
679 $text = self::text_from_tree($decoded);
680 if (!self::is_blank($text)) {
681 return $text;
682 }
683 continue;
684 }
685
686 // Shortcode tree (Oxygen classic).
687 if (strpos($stored, '[') !== false && function_exists('do_shortcode')) {
688 try {
689 $rendered = do_shortcode($stored);
690 } catch (\Throwable $e) {
691 $rendered = $stored;
692 }
693 if (!self::is_blank($rendered)) {
694 return $rendered;
695 }
696 }
697
698 continue;
699 }
700
701 // Some builders store an already-decoded tree — an array for most,
702 // an array of objects for Beaver Builder (#449).
703 $tree = self::as_children($stored);
704 if (null !== $tree) {
705 $text = self::text_from_tree($tree);
706 if (!self::is_blank($text)) {
707 return $text;
708 }
709 }
710 }
711
712 return '';
713 }
714
715 /**
716 * A node's children, whether it stores them as an array or an object.
717 *
718 * The walker used to return immediately on `!is_array($node)`, so an
719 * object node was dropped along with its entire subtree — silently, as
720 * `''`, which the caller reads as "this builder stored nothing" rather
721 * than "this walker cannot read this shape".
722 *
723 * Beaver Builder stores `_fl_builder_data` as an array of stdClass nodes,
724 * each with a stdClass `settings` object, so every node would have been
725 * dropped and adding its meta key alone would have looked like it worked
726 * and changed nothing. Not BB-specific: any builder storing objects hits
727 * this, and that shape will come up again (#449).
728 *
729 * @since 2.1.0
730 *
731 * @param mixed $node Candidate node.
732 * @return array<string|int,mixed>|null Traversable children, or null.
733 */
734 private static function as_children($node): ?array {
735 if (is_array($node)) {
736 return $node;
737 }
738
739 // Deliberately not is_object(): a builder can store a value object
740 // (DateTime, a WP_Post) whose properties are not content, and
741 // get_object_vars() on those yields noise. stdClass is what the
742 // JSON/serialize round-trip produces, which is the shape we want.
743 if ($node instanceof \stdClass) {
744 return get_object_vars($node);
745 }
746
747 return null;
748 }
749
750 /**
751 * Walk a builder node tree and collect the user-visible text.
752 *
753 * Values are joined with block-level markup so downstream heading, link and
754 * image detection keeps working on the result.
755 *
756 * @param array $tree Decoded builder tree.
757 * @return string Collected HTML.
758 */
759 private static function text_from_tree(array $tree): string {
760 $collected = [];
761
762 // Strings already represented inside reconstructed markup, so the plain
763 // sweep below doesn't emit a link label or heading a second time and
764 // double it in the word count.
765 $consumed = [];
766
767 // Pass 1 — rebuild <a>, <img> and <hN> from node *shape*. This has to
768 // happen per node rather than per leaf: a link's label and its
769 // destination are separate sibling fields, so once the tree is
770 // flattened to leaves the pairing is gone.
771 $reconstruct = static function ($node) use (&$reconstruct, &$collected, &$consumed): void {
772 $node = self::as_children($node);
773 if (null === $node) {
774 return;
775 }
776
777 $markup = self::markup_for_node($node, $consumed);
778 if ('' !== $markup) {
779 $collected[] = $markup;
780 }
781
782 foreach ($node as $child_key => $child) {
783 // A `link` / `image` sub-object is a destination descriptor the
784 // parent has already folded into its markup. Descending into it
785 // would emit the same URL a second time as a bare link, and
786 // would turn an image's own `url` field into a spurious <a>.
787 if (is_string($child_key)
788 && (in_array(strtolower($child_key), self::URL_KEYS, true)
789 || in_array(strtolower($child_key), self::IMAGE_KEYS, true))
790 ) {
791 continue;
792 }
793
794 $reconstruct($child);
795 }
796 };
797 $reconstruct($tree);
798
799 // Pass 2 — remaining visible text.
800 $walk = static function ($node, $key = null) use (&$walk, &$collected, &$consumed): void {
801 $children = self::as_children($node);
802 if (null !== $children) {
803 foreach ($children as $child_key => $child) {
804 $walk($child, is_string($child_key) ? $child_key : $key);
805 }
806 return;
807 }
808
809 if (!is_string($node) || '' === trim($node)) {
810 return;
811 }
812
813 // Already inside a reconstructed tag.
814 if (in_array($node, $consumed, true)) {
815 return;
816 }
817
818 $is_content_key = is_string($key)
819 && in_array(strtolower($key), self::CONTENT_KEYS, true);
820
821 // Markup is content wherever it appears; bare strings only count
822 // when their key says they are content, so slugs and class names
823 // stay out of the word count.
824 if ($is_content_key || strpos($node, '<') !== false) {
825 $collected[] = $node;
826 }
827 };
828
829 $walk($tree);
830
831 if (empty($collected)) {
832 return '';
833 }
834
835 // De-duplicate: builder trees often repeat a value across responsive
836 // breakpoints, which would otherwise multiply the word count.
837 $collected = array_unique($collected);
838
839 return implode("\n", $collected);
840 }
841
842 /**
843 * Rebuild the HTML a single builder node represents, if any.
844 *
845 * Looks only at the node's own fields (plus one level of nesting, because
846 * builders commonly wrap a destination as `{ url: … }`). Returns an empty
847 * string for the vast majority of nodes, which are layout or configuration.
848 *
849 * Any leaf string folded into the returned markup is appended to $consumed
850 * so the plain-text sweep doesn't count it twice.
851 *
852 * @param array $node Builder node.
853 * @param array $consumed Collects strings represented in the returned markup.
854 * @return string Reconstructed HTML, or '' when the node carries none.
855 */
856 private static function markup_for_node(array $node, array &$consumed): string {
857 $text = self::first_value($node, self::CONTENT_KEYS);
858 $url = self::url_from($node, self::URL_KEYS);
859 $image = self::image_from($node);
860 $tag = self::heading_tag_from($node);
861
862 $parts = [];
863
864 // Image: alt text matters as much as the tag, since alt checks run over
865 // whatever this returns.
866 if ('' !== $image['url']) {
867 $alt = '' !== $image['alt'] ? $image['alt'] : (string) self::first_value($node, self::ALT_KEYS);
868 if ('' !== $alt) {
869 $consumed[] = $alt;
870 }
871 $parts[] = sprintf(
872 '<img src="%s" alt="%s" />',
873 esc_url_raw($image['url']),
874 htmlspecialchars($alt, ENT_QUOTES)
875 );
876 }
877
878 if ('' !== $text) {
879 $inner = $text;
880
881 if ('' !== $url) {
882 $consumed[] = $text;
883 $inner = sprintf('<a href="%s">%s</a>', esc_url_raw($url), $text);
884 }
885
886 if ('' !== $tag) {
887 $consumed[] = $text;
888 $parts[] = sprintf('<%1$s>%2$s</%1$s>', $tag, $inner);
889 } elseif ('' !== $url) {
890 $parts[] = $inner;
891 }
892 } elseif ('' !== $url) {
893 // A destination with no label still counts as a link for link
894 // checks; the URL doubles as its anchor text.
895 $parts[] = sprintf('<a href="%1$s">%1$s</a>', esc_url_raw($url));
896 }
897
898 return implode("\n", $parts);
899 }
900
901 /**
902 * First non-empty scalar value under any of the given keys.
903 *
904 * @param array $node Builder node.
905 * @param string[] $keys Candidate keys.
906 * @return string Trimmed value, or '' when none match.
907 */
908 private static function first_value(array $node, array $keys): string {
909 foreach ($node as $key => $value) {
910 if (!is_string($key) || !is_string($value)) {
911 continue;
912 }
913 if (in_array(strtolower($key), $keys, true) && '' !== trim($value)) {
914 return trim($value);
915 }
916 }
917
918 return '';
919 }
920
921 /**
922 * Link destination held by a node, as a bare string or a `{ url: … }` object.
923 *
924 * @param array $node Builder node.
925 * @param string[] $keys Candidate keys.
926 * @return string URL, or '' when the node holds none.
927 */
928 private static function url_from(array $node, array $keys): string {
929 foreach ($node as $key => $value) {
930 if (!is_string($key) || !in_array(strtolower($key), $keys, true)) {
931 continue;
932 }
933
934 if (is_string($value) && self::looks_like_url($value)) {
935 return trim($value);
936 }
937
938 // Elementor and Breakdance both nest the destination one level down.
939 $nested_values = self::as_children($value);
940 if (null !== $nested_values) {
941 foreach ($nested_values as $nested_key => $nested) {
942 if (is_string($nested_key)
943 && in_array(strtolower($nested_key), ['url', 'href', 'permalink'], true)
944 && is_string($nested)
945 && self::looks_like_url($nested)
946 ) {
947 return trim($nested);
948 }
949 }
950 }
951 }
952
953 return '';
954 }
955
956 /**
957 * Image URL and alt text held by a node.
958 *
959 * @param array $node Builder node.
960 * @return array{url:string,alt:string}
961 */
962 private static function image_from(array $node): array {
963 foreach ($node as $key => $value) {
964 if (!is_string($key) || !in_array(strtolower($key), self::IMAGE_KEYS, true)) {
965 continue;
966 }
967
968 if (is_string($value) && self::looks_like_url($value)) {
969 return ['url' => trim($value), 'alt' => ''];
970 }
971
972 $nested_values = self::as_children($value);
973 if (null !== $nested_values) {
974 $url = '';
975 $alt = '';
976 foreach ($nested_values as $nested_key => $nested) {
977 if (!is_string($nested_key) || !is_string($nested)) {
978 continue;
979 }
980 $nested_key = strtolower($nested_key);
981 if ('' === $url && in_array($nested_key, ['url', 'src'], true) && self::looks_like_url($nested)) {
982 $url = trim($nested);
983 }
984 if ('' === $alt && in_array($nested_key, self::ALT_KEYS, true)) {
985 $alt = trim($nested);
986 }
987 }
988 if ('' !== $url) {
989 return ['url' => $url, 'alt' => $alt];
990 }
991 }
992 }
993
994 return ['url' => '', 'alt' => ''];
995 }
996
997 /**
998 * Heading tag a node asks for, normalised to h1–h6.
999 *
1000 * Accepts both the `h2` form and a bare level like `2`.
1001 *
1002 * @param array $node Builder node.
1003 * @return string Tag name, or '' when the node is not a heading.
1004 */
1005 private static function heading_tag_from(array $node): string {
1006 foreach ($node as $key => $value) {
1007 if (!is_string($key) || !in_array(strtolower($key), self::HEADING_TAG_KEYS, true)) {
1008 continue;
1009 }
1010
1011 if (is_string($value) && preg_match('/^h([1-6])$/i', trim($value), $m)) {
1012 return 'h' . $m[1];
1013 }
1014
1015 // A bare level only counts under a key that unambiguously means one;
1016 // `size` and `tag` carry values like "large" or "div" far more often.
1017 if (is_numeric($value)
1018 && in_array(strtolower($key), ['level'], true)
1019 && (int) $value >= 1 && (int) $value <= 6
1020 ) {
1021 return 'h' . (int) $value;
1022 }
1023 }
1024
1025 return '';
1026 }
1027
1028 /**
1029 * Whether a string is plausibly a link or asset destination.
1030 *
1031 * Deliberately permissive about relative paths — builders store internal
1032 * links that way — but rejects the option slugs and CSS values that make up
1033 * most of a builder tree.
1034 *
1035 * @param string $value Candidate.
1036 * @return bool
1037 */
1038 private static function looks_like_url(string $value): bool {
1039 $value = trim($value);
1040
1041 if ('' === $value || strlen($value) > 2048) {
1042 return false;
1043 }
1044
1045 if (preg_match('#^(https?:)?//#i', $value) || str_starts_with($value, '/')) {
1046 return true;
1047 }
1048
1049 // Protocol-ish destinations a link node can legitimately hold.
1050 return (bool) preg_match('#^(mailto:|tel:|\#)#i', $value);
1051 }
1052
1053 /**
1054 * Whether a value carries no readable text.
1055 *
1056 * @param string $value Candidate content.
1057 * @return bool
1058 */
1059 private static function is_blank(string $value): bool {
1060 return '' === trim(wp_strip_all_tags($value));
1061 }
1062 }
1063