PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / trunk
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO vtrunk
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 1.11.0 All 47 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 trunk, at includes/seo/class-builder-content.php

1,452 lines 54.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 * Bricks' element that renders the post's own `post_content`.
102 *
103 * A Bricks page normally discards `post_content` entirely, which is why
104 * anything left there is invisible. Dropping this element onto the canvas
105 * is the one way an author puts it back on the page, so its presence flips
106 * `post_content` from stale leftovers to content the visitor reads.
107 *
108 * @since 2.3.1
109 * @var string
110 */
111 private const BRICKS_POST_CONTENT_ELEMENT = 'post-content';
112
113 /**
114 * Resolved Bricks trees for this request, keyed by post ID.
115 *
116 * Rendering one page asks for the tree about twenty times — every
117 * description, every schema node, the FAQ guard — and resolving it is not
118 * free. `bricks_content_source()` clears `Bricks\Database::$active_templates`
119 * before asking Bricks which content template applies, which defeats
120 * Bricks' own early-return and re-runs its whole template-condition engine;
121 * `expand_bricks_components()` then walks the tree again. Measured on a
122 * Bricks page with no content of its own, that was ten full runs of the
123 * rules engine per request.
124 *
125 * Per-request only, and only ever read back within one page render — a
126 * request that writes Bricks content does not also render it.
127 *
128 * @since 2.3.1
129 * @var array<int,array<int,mixed>>
130 */
131 private static array $bricks_trees = [];
132
133 /**
134 * JSON keys whose values are user-visible text.
135 *
136 * Builder trees mix content with configuration, so a blind string sweep
137 * would count CSS classes and option slugs as words. Matching on the key
138 * keeps the word count honest.
139 *
140 * @var string[]
141 */
142 private const CONTENT_KEYS = [
143 'text', 'title', 'subtitle', 'heading', 'subheading', 'content',
144 'description', 'caption', 'excerpt', 'label', 'value', 'html',
145 'editor', 'quote', 'answer', 'question', 'body', 'button_text',
146 ];
147
148 /**
149 * JSON keys whose values hold a link destination.
150 *
151 * Builders store a link's destination in a structured field separate from
152 * its label, either as a bare URL string or as a `{ url: … }` object.
153 * Neither shape survives a text sweep — the key is not content and a bare
154 * URL contains no `<` — so no `<a>` tag reached the link counters.
155 *
156 * @var string[]
157 */
158 private const URL_KEYS = [
159 'link', 'url', 'href', 'link_url', 'button_link', 'permalink', 'link_to',
160 ];
161
162 /**
163 * JSON keys whose values hold an embedded video's source.
164 *
165 * A builder's video widget keeps its destination in a provider-specific
166 * field — Elementor picks `youtube_url`, `vimeo_url`, `dailymotion_url` or
167 * `hosted_url` according to the chosen source type — none of which is a
168 * link field or a content field, so a video on a builder page reached the
169 * analyzers as nothing at all.
170 *
171 * These are deliberately kept out of URL_KEYS. A video is an embed, not an
172 * outbound link: rendering one as `<a href>` would add a spurious external
173 * link to every page carrying a video and skew the link counts. They are
174 * reconstructed as `<iframe>`/`<video>` instead, which the video detector
175 * recognises and the link and image counters ignore.
176 *
177 * @since 2.3.1
178 * @var string[]
179 */
180 private const VIDEO_KEYS = [
181 'youtube_url', 'vimeo_url', 'dailymotion_url', 'videopress_url',
182 'hosted_url', 'video_url', 'video_src', 'video_link',
183 ];
184
185 /**
186 * File extensions that mean a video source is a file, not a provider page.
187 *
188 * @since 2.3.1
189 * @var string[]
190 */
191 private const VIDEO_FILE_EXTENSIONS = ['mp4', 'webm', 'ogv', 'mov', 'm4v'];
192
193 /**
194 * Source keys to trust for a declared video source type.
195 *
196 * A widget keeps one field per provider and does not clear the others when
197 * the author switches source: an Elementor video moved from YouTube to Self
198 * Hosted still carries the earlier `youtube_url`. Reading whichever key
199 * turns up first then emits the video the author replaced. The widget says
200 * which one it is actually playing, so that is read first and the flat key
201 * sweep is only the fallback for a builder that declares nothing.
202 *
203 * @since 2.3.1
204 * @var array<string,string[]>
205 */
206 private const VIDEO_KEYS_BY_TYPE = [
207 'youtube' => ['youtube_url'],
208 'vimeo' => ['vimeo_url'],
209 'dailymotion' => ['dailymotion_url'],
210 'videopress' => ['videopress_url'],
211 'hosted' => ['hosted_url', 'video_url', 'video_src', 'video_link'],
212 'media' => ['hosted_url', 'video_url', 'video_src', 'video_link'],
213 'file' => ['hosted_url', 'video_url', 'video_src', 'video_link'],
214 'self_hosted' => ['hosted_url', 'video_url', 'video_src', 'video_link'],
215 ];
216
217 /**
218 * Keys a builder uses to name which video source a widget is playing.
219 *
220 * @since 2.3.1
221 * @var string[]
222 */
223 private const VIDEO_TYPE_KEYS = ['video_type', 'videotype', 'video_source', 'source_type'];
224
225 /**
226 * JSON keys whose values hold an image, as a URL string or `{ url, alt }`.
227 *
228 * @var string[]
229 */
230 private const IMAGE_KEYS = [
231 'image', 'src', 'image_url', 'background_image', 'bg_image', 'photo',
232 ];
233
234 /**
235 * JSON keys that carry a heading level for the node's text.
236 *
237 * A builder heading's text is collected (its key is in CONTENT_KEYS) and so
238 * counts toward the word count, but it arrives as bare text with no `<h2>`
239 * wrapper — which is why heading-structure checks saw none.
240 *
241 * @var string[]
242 */
243 private const HEADING_TAG_KEYS = [
244 // Lower-cased on both sides of the comparison, so `headingtag` is the
245 // camelCase `headingTag` Bricks uses throughout its own controls and
246 // which ThinkRank's Bricks elements declare. Without it their section
247 // headings counted as body copy and never reached heading structure.
248 'header_size', 'heading_tag', 'headingtag', 'html_tag', 'title_tag', 'tag', 'level', 'size',
249 ];
250
251 /**
252 * Keys whose value is alternative text for a sibling image.
253 *
254 * @var string[]
255 */
256 private const ALT_KEYS = ['alt', 'alt_text', 'image_alt', 'title'];
257
258 /**
259 * Resolve the content worth analyzing for a post.
260 *
261 * @param \WP_Post $post Post being analyzed.
262 * @return string HTML/text to analyze.
263 */
264 public static function resolve(\WP_Post $post): string {
265 $raw = (string) $post->post_content;
266
267 // A page built in Gutenberg and then switched to Bricks keeps its old
268 // blocks in `post_content` forever — Bricks never clears them, and
269 // never renders them either. Resolving that first meant the stale draft
270 // beat the tree the visitor actually reads, and it did not stop at the
271 // score: the same string becomes the meta description, og:description,
272 // twitter:description and the schema description. Starting from nothing
273 // sends the resolution straight to Bricks' storage, which is where this
274 // page's words are (#651).
275 //
276 // Only for the stored path. `resolve_markup()` is also called with live
277 // editor content, and the Bricks panel's resolver reads the canvas —
278 // discarding that would replace what the author is typing with the last
279 // save.
280 if (self::bricks_supersedes_post_content((int) $post->ID)) {
281 $raw = '';
282 }
283
284 return self::resolve_markup($raw, $post);
285 }
286
287 /**
288 * Whether Bricks renders this post and throws its `post_content` away.
289 *
290 * True means anything still stored in `post_content` is invisible: it is
291 * not on the page, so it must not be scored, described or published as
292 * structured data. False covers both a post Bricks does not own and a
293 * Bricks page that puts `post_content` back with a Post Content element.
294 *
295 * @since 2.3.1
296 *
297 * @param int $post_id Post being resolved.
298 * @return bool
299 */
300 public static function bricks_supersedes_post_content(int $post_id): bool {
301 $tree = self::bricks_tree($post_id);
302
303 if (empty($tree)) {
304 return false;
305 }
306
307 foreach ($tree as $element) {
308 if (is_array($element)
309 && self::BRICKS_POST_CONTENT_ELEMENT === ($element['name'] ?? null)
310 ) {
311 return false;
312 }
313 }
314
315 return !self::bricks_tree_prints_post_content($tree);
316 }
317
318 /**
319 * Whether a Bricks tree prints the body through a dynamic-data tag.
320 *
321 * The Post Content element is not the only way back onto the page: Bricks'
322 * `{post_content}` tag renders the same thing from inside an ordinary text
323 * element, and a single-post template written that way is a common shape.
324 * Missing it would mean the post's real body is discarded everywhere —
325 * scoring, the meta/og/twitter descriptions, the schema description — for a
326 * page that is displaying it.
327 *
328 * Matched over the encoded tree rather than per setting, because the tag can
329 * sit in any string field of any element and Bricks allows modifiers after
330 * the name (`{post_content:...}`).
331 *
332 * @since 2.3.1
333 *
334 * @param array $tree Bricks element tree.
335 * @return bool
336 */
337 private static function bricks_tree_prints_post_content(array $tree): bool {
338 $encoded = wp_json_encode($tree);
339
340 return is_string($encoded) && false !== stripos($encoded, '{post_content');
341 }
342
343 /**
344 * The post's content as the visitor actually receives it.
345 *
346 * `post_content` for everything except a Bricks page that discards it, and
347 * there the Bricks tree's text. Descriptions are derived from a post's body
348 * in half a dozen places; every one of them wants this rather than the raw
349 * column (#651).
350 *
351 * @since 2.3.1
352 *
353 * @param \WP_Post $post Post being described.
354 * @return string
355 */
356 public static function visible_content(\WP_Post $post): string {
357 $superseding = self::superseding_content($post);
358
359 return '' !== $superseding ? $superseding : (string) $post->post_content;
360 }
361
362 /**
363 * Replacement body text for a post whose `post_content` does not render.
364 *
365 * Empty for every ordinary post, which is what makes this safe to call from
366 * paths that already handle excerpts their own way: they keep that handling
367 * and only a Bricks page is diverted.
368 *
369 * @since 2.3.1
370 *
371 * @param \WP_Post $post Post being described.
372 * @return string Visible body text, or '' when `post_content` is fine.
373 */
374 public static function superseding_content(\WP_Post $post): string {
375 if (!self::bricks_supersedes_post_content((int) $post->ID)) {
376 return '';
377 }
378
379 $bricks = self::from_bricks((int) $post->ID);
380
381 return self::is_blank($bricks) ? '' : $bricks;
382 }
383
384 /**
385 * Body text to derive a description from, when the usual source is wrong.
386 *
387 * A hand-written excerpt is the author's own summary and is correct however
388 * the page is built, so it yields '' here and the caller's normal
389 * `get_the_excerpt()` path keeps it. Only a Bricks page with no excerpt —
390 * where core would derive one from discarded `post_content` — gets diverted.
391 *
392 * @since 2.3.1
393 *
394 * @param \WP_Post $post Post being described.
395 * @return string Text to summarize, or '' to leave the caller's path alone.
396 */
397 public static function superseding_excerpt_source(\WP_Post $post): string {
398 if ('' !== trim((string) $post->post_excerpt)) {
399 return '';
400 }
401
402 return self::superseding_content($post);
403 }
404
405 /**
406 * The Bricks element tree that renders for a post.
407 *
408 * Public because what Bricks puts on the page is not only a scoring
409 * question: the schema graph has to know whether a Bricks element already
410 * publishes the page's FAQ before adding one of its own (#649, #650).
411 *
412 * Flat, in Bricks' own storage shape — `expand_bricks_components()`
413 * appends component definitions to the same list rather than nesting them,
414 * so one `foreach` reaches every element.
415 *
416 * @since 2.3.1
417 *
418 * @param int $post_id Post being resolved.
419 * @return array<int,mixed> Elements, or [] when Bricks renders nothing here.
420 */
421 public static function bricks_tree(int $post_id): array {
422 if (array_key_exists($post_id, self::$bricks_trees)) {
423 return self::$bricks_trees[$post_id];
424 }
425
426 self::$bricks_trees[$post_id] = self::resolve_bricks_tree($post_id);
427
428 return self::$bricks_trees[$post_id];
429 }
430
431 /**
432 * Discard the resolved-tree memo. Test seam.
433 *
434 * @since 2.3.1
435 * @return void
436 */
437 public static function flush_bricks_cache(): void {
438 self::$bricks_trees = [];
439 }
440
441 /**
442 * Read and resolve a post's Bricks tree, ignoring the memo.
443 *
444 * @since 2.3.1
445 *
446 * @param int $post_id Post being resolved.
447 * @return array<int,mixed>
448 */
449 private static function resolve_bricks_tree(int $post_id): array {
450 if (!self::bricks_owns_post($post_id)) {
451 return [];
452 }
453
454 $source = self::bricks_content_source($post_id);
455 if (!$source) {
456 return [];
457 }
458
459 $stored = get_post_meta($source, self::bricks_meta_key(), true);
460
461 if (is_string($stored)) {
462 $stored = '' === trim($stored) ? null : json_decode($stored, true);
463 }
464
465 if (!is_array($stored) || empty($stored)) {
466 return [];
467 }
468
469 return self::expand_bricks_components($stored);
470 }
471
472 /**
473 * Resolve an arbitrary chunk of editor markup for the given post.
474 *
475 * The editor sends its live content to the scorer so an author sees their
476 * unsaved edits reflected. On a builder page that live string is the raw
477 * builder markup — the block editor hands over Divi's
478 * `<!-- wp:divi/... -->` comments verbatim, because it cannot render
479 * blocks it has no client-side registration for. Analyzed as-is it reads
480 * as zero words, which is how a Divi page could show a correct saved score
481 * while the live Content Analysis panel next to it still said
482 * "No content".
483 *
484 * Running the live string through the same chain as stored content keeps
485 * both paths honest, and falling through to the post's builder storage
486 * covers builders (Oxygen) whose editor content is empty to begin with.
487 *
488 * @since 1.23.0
489 *
490 * @param string $raw Markup to analyze.
491 * @param \WP_Post $post Post the markup belongs to.
492 * @return string Content to analyze.
493 */
494 public static function resolve_markup(string $raw, \WP_Post $post): string {
495 $content = self::render_post_content($raw);
496
497 // Block markup that renders to nothing usually means the builder that
498 // owns those blocks did not register them in this context — Divi 5
499 // loads its module library lazily per-request, so in CLI, REST, admin
500 // and block-editor requests do_blocks() yields an empty string while
501 // the words sit right there in the block attributes. Read them
502 // directly.
503 if (self::is_blank($content)) {
504 $from_blocks = self::from_block_attributes($raw);
505 if (!self::is_blank($from_blocks)) {
506 $content = $from_blocks;
507 }
508 }
509
510 // Only reach for builder storage when the markup yielded nothing — a
511 // classic post must never pay for this.
512 if (self::is_blank($content)) {
513 $builder = self::from_builder_meta((int) $post->ID);
514 if (!self::is_blank($builder)) {
515 $content = $builder;
516 }
517 }
518
519 // A resolution that collapsed to nothing is worse than the raw markup.
520 if (self::is_blank($content) && !self::is_blank($raw)) {
521 $content = $raw;
522 }
523
524 /**
525 * Filter the content ThinkRank analyzes for a post.
526 *
527 * Use this to teach ThinkRank about a builder it does not know, or to
528 * override extraction for one it does.
529 *
530 * @since 1.23.0
531 *
532 * @param string $content Resolved content.
533 * @param \WP_Post $post Post being analyzed.
534 * @param string $raw Markup this resolution started from.
535 */
536 return (string) apply_filters('thinkrank_analyzable_content', $content, $post, $raw);
537 }
538
539 /**
540 * Render blocks and shortcodes found in post_content.
541 *
542 * Best-effort: a third-party block that fatals must not take the whole
543 * score down with it.
544 *
545 * @param string $raw Raw post content.
546 * @return string Rendered content.
547 */
548 private static function render_post_content(string $raw): string {
549 if ('' === trim($raw)) {
550 return '';
551 }
552
553 $content = $raw;
554
555 try {
556 if (function_exists('has_blocks') && function_exists('do_blocks') && has_blocks($raw)) {
557 $content = do_blocks($raw);
558 }
559
560 // Block output can itself contain shortcodes, so this runs either way.
561 if (function_exists('do_shortcode') && strpos($content, '[') !== false) {
562 $content = do_shortcode($content);
563 }
564 } catch (\Throwable $e) {
565 return $raw;
566 }
567
568 return self::is_blank($content) ? $raw : $content;
569 }
570
571 /**
572 * Extract text from the attributes of parsed blocks.
573 *
574 * @param string $raw Raw post content containing block markup.
575 * @return string Collected text, or '' when nothing was found.
576 */
577 private static function from_block_attributes(string $raw): string {
578 if (!function_exists('parse_blocks') || !function_exists('has_blocks') || !has_blocks($raw)) {
579 return '';
580 }
581
582 try {
583 $blocks = parse_blocks($raw);
584 } catch (\Throwable $e) {
585 return '';
586 }
587
588 $attrs = [];
589 $collect = static function (array $items) use (&$collect, &$attrs): void {
590 foreach ($items as $block) {
591 if (!empty($block['attrs']) && is_array($block['attrs'])) {
592 $attrs[] = $block['attrs'];
593 }
594 if (!empty($block['innerBlocks']) && is_array($block['innerBlocks'])) {
595 $collect($block['innerBlocks']);
596 }
597 }
598 };
599 $collect($blocks);
600
601 return empty($attrs) ? '' : self::text_from_tree($attrs);
602 }
603
604 /**
605 * Everything Bricks contributes to this post's analyzable content.
606 *
607 * Bricks is the only builder here that needs more than a meta key, on
608 * three counts:
609 *
610 * - It leaves its stored tree behind when a post is switched back to the
611 * block editor, so an editor-mode gate has to run first or ThinkRank
612 * scores markup the visitor never sees — the same failure
613 * `_fl_builder_draft` was ordered against in #449.
614 * - A post's content can live on ANOTHER post. Bricks' Templates feature
615 * assigns a content template by condition, and a page using one stores
616 * nothing of its own; reading only the page's meta scores it blank
617 * while the visitor reads a full page.
618 * - Its stored text carries dynamic-data tags and internal element names
619 * that never reach the rendered page.
620 *
621 * @since 2.2.1
622 *
623 * @param int $post_id Post being resolved.
624 * @return string Extracted text, or '' when Bricks has nothing for it.
625 */
626 private static function from_bricks(int $post_id): string {
627 $tree = self::bricks_tree($post_id);
628
629 if (empty($tree)) {
630 return '';
631 }
632
633 return self::strip_bricks_dynamic_tags(
634 self::text_from_tree(self::without_bricks_element_labels($tree))
635 );
636 }
637
638 /**
639 * Whether Bricks — not the block editor — renders this post.
640 *
641 * Bricks writes `bricks` or `wordpress` into its editor-mode meta as the
642 * author toggles between the two, and never clears the content it stored
643 * for the other mode. Only the `wordpress` value is disqualifying: an
644 * absent value is the normal state for a post Bricks built and never
645 * toggled. This follows Bricks' own `Helpers::render_with_bricks()`, which
646 * bails on exactly that one value.
647 *
648 * It deliberately does not match it exactly: the comparison here is
649 * case-insensitive, where Bricks' is strict. Bricks 2.3.12 only ever writes
650 * the value lowercase, so the two agree on everything Bricks itself
651 * stores; they part company only on a value some other integration wrote.
652 * The two shipping today disagree about the casing — SureRank compares
653 * against `'WordPress'`, AIOSEO against `'bricks'` — and of the two ways to
654 * be wrong about `'WordPress'`, blocking costs a score on a page that has
655 * one, while allowing scores stale content the visitor never sees, which is
656 * the failure this gate exists to prevent.
657 *
658 * @since 2.2.1
659 *
660 * @param int $post_id Post being resolved.
661 * @return bool
662 */
663 private static function bricks_owns_post(int $post_id): bool {
664 $mode = get_post_meta($post_id, self::bricks_editor_mode_key(), true);
665
666 // phpcs:ignore WordPress.WP.CapitalPDangit.MisspelledInText -- Bricks' own stored meta value, lower-cased for the comparison.
667 return !(is_string($mode) && 'wordpress' === strtolower(trim($mode)));
668 }
669
670 /**
671 * The post whose Bricks tree actually renders for this post.
672 *
673 * Usually the post itself. When it stores nothing of its own, Bricks falls
674 * back to whichever content template's conditions match, and that template
675 * is a separate post carrying the words the visitor reads.
676 *
677 * Resolution is delegated to Bricks rather than reimplemented: template
678 * conditions are a whole rules engine (post IDs, types, taxonomies,
679 * archives), and a second implementation would drift from it. Bricks
680 * answers through statics, so they are saved and restored around the call —
681 * `set_active_templates()` returns early once populated, and on a
682 * front-end request Bricks has already populated it for the page being
683 * served. Clobbering that would corrupt the render in progress.
684 *
685 * Best-effort by design: any failure returns the post's own data, which is
686 * exactly today's behaviour.
687 *
688 * @since 2.2.1
689 *
690 * @param int $post_id Post being resolved.
691 * @return int Post ID holding the Bricks tree, or 0 when there is none.
692 */
693 private static function bricks_content_source(int $post_id): int {
694 $own = get_post_meta($post_id, self::bricks_meta_key(), true);
695 if ((is_array($own) && !empty($own)) || (is_string($own) && '' !== trim($own))) {
696 return $post_id;
697 }
698
699 if (!class_exists('\\Bricks\\Database')
700 || !method_exists('\\Bricks\\Database', 'set_active_templates')
701 ) {
702 return 0;
703 }
704
705 // `set_active_templates()` writes TWO statics — `$active_templates` and,
706 // when a header template resolves, `$header_position`. Both are saved,
707 // and both are restored in `finally` rather than on the happy path: a
708 // throw part-way through (a third-party hook on
709 // `bricks/database/content_type`, `bricks/builder/data_post_id` or
710 // `bricks/active_templates` is enough) must not leave Bricks' render
711 // state holding this lookup's values. Restoring only after a clean
712 // return is what the `catch` below would otherwise skip.
713 $has_header_position = property_exists('\\Bricks\\Database', 'header_position');
714 $saved_templates = \Bricks\Database::$active_templates;
715 $saved_header_position = $has_header_position ? \Bricks\Database::$header_position : null;
716
717 try {
718 \Bricks\Database::$active_templates = [];
719 \Bricks\Database::set_active_templates($post_id);
720 $template = (int) (\Bricks\Database::$active_templates['content'] ?? 0);
721 } catch (\Throwable $e) {
722 return 0;
723 } finally {
724 \Bricks\Database::$active_templates = $saved_templates;
725 if ($has_header_position) {
726 \Bricks\Database::$header_position = $saved_header_position;
727 }
728 }
729
730 // A template that is the post itself adds nothing over the empty read
731 // above, and would otherwise recurse conceptually.
732 return $template === $post_id ? 0 : $template;
733 }
734
735 /**
736 * Splice component definitions into the tree.
737 *
738 * A Bricks component keeps its markup in the `bricks_components` option,
739 * not on the page. The page stores only an instance: an element carrying
740 * `cid` and, usually, empty `settings`. Walking the page alone therefore
741 * found no words at all, and a page built entirely from components scored
742 * blank — the same failure as a page built from a content template.
743 *
744 * Confirmed on Bricks 2.3.12: `Bricks\Frontend::render_data()` renders the
745 * component's copy from an instance this walker extracted '' from.
746 *
747 * The definition is read straight from the option rather than through
748 * `Bricks\Helpers::get_component_instance()`. That helper resolves an
749 * instance's property overrides, which would be better, but it reads
750 * `Bricks\Database::$global_data['components']` — populated once per
751 * request, and empty in the admin and CLI contexts where bulk scoring
752 * runs. Refreshing it would mean writing to Bricks' live render state, the
753 * same hazard the template resolver is careful to avoid, and gating on it
754 * would make a page score differently in wp-admin than on the front end.
755 * Reading the stored definition is consistent everywhere.
756 *
757 * The trade-off: an instance that overrides a component property is scored
758 * with the component's authored copy rather than the override. That is the
759 * text the component renders by default, and it is much closer than the
760 * nothing this returned before.
761 *
762 * @since 2.2.1
763 *
764 * @param array $tree Bricks content area.
765 * @return array Tree with component elements spliced in after each instance.
766 */
767 private static function expand_bricks_components(array $tree): array {
768 $expanded = [];
769 $open = [];
770
771 $walk = static function (array $elements, int $depth) use (&$walk, &$expanded, &$open): void {
772 foreach ($elements as $element) {
773 $expanded[] = $element;
774
775 if (!is_array($element) || empty($element['cid']) || !is_string($element['cid'])) {
776 continue;
777 }
778
779 $cid = $element['cid'];
780
781 // A component nested inside its own definition would recurse
782 // forever; the depth cap covers deep but legitimate nesting.
783 if (isset($open[$cid]) || $depth > 4) {
784 continue;
785 }
786
787 $children = self::bricks_component_elements($cid);
788 if (empty($children)) {
789 continue;
790 }
791
792 // Re-entrant per branch, not per page: the guard is released
793 // after the walk so a second instance further along the page
794 // still expands, rather than being mistaken for recursion.
795 //
796 // That does NOT double the word count — `text_from_tree()`
797 // ends in `array_unique()`, which collapses a repeated
798 // component's copy the same way it collapses a value repeated
799 // across responsive breakpoints. Expanding both instances is
800 // about not silently dropping the second one's structure.
801 $open[$cid] = true;
802 $walk($children, $depth + 1);
803 unset($open[$cid]);
804 }
805 };
806
807 $walk($tree, 0);
808
809 return $expanded;
810 }
811
812 /**
813 * The stored elements of one Bricks component.
814 *
815 * @since 2.2.1
816 *
817 * @param string $cid Component id held by an instance element.
818 * @return array Component elements, or [] when it cannot be resolved.
819 */
820 private static function bricks_component_elements(string $cid): array {
821 $components = get_option(self::bricks_constant('BRICKS_DB_COMPONENTS', self::BRICKS_COMPONENTS_OPTION), []);
822
823 if (!is_array($components)) {
824 return [];
825 }
826
827 foreach ($components as $component) {
828 $component = self::as_children($component);
829 if (null === $component) {
830 continue;
831 }
832
833 if (isset($component['id']) && $component['id'] === $cid && !empty($component['elements'])) {
834 return is_array($component['elements']) ? $component['elements'] : [];
835 }
836 }
837
838 return [];
839 }
840
841 /**
842 * Drop each Bricks element's internal name before the tree is walked.
843 *
844 * A Bricks element carries an optional top-level `label` — the nickname an
845 * author types in the Structure panel to find it again ("Hero headline",
846 * "CTA row"). It is builder chrome and is never rendered, but `label` is in
847 * CONTENT_KEYS because it is real content for other builders' form fields,
848 * so it was being counted as page copy.
849 *
850 * Only the element's own `label` is removed. A `label` inside `settings`
851 * is a rendered field label and stays.
852 *
853 * @since 2.2.1
854 *
855 * @param array $tree Bricks content area.
856 * @return array Tree with element nicknames removed.
857 */
858 private static function without_bricks_element_labels(array $tree): array {
859 foreach ($tree as $index => $element) {
860 if (is_array($element) && isset($element['id'], $element['label'])) {
861 unset($tree[$index]['label']);
862 }
863 }
864
865 return $tree;
866 }
867
868 /**
869 * Remove Bricks dynamic-data tags from extracted text.
870 *
871 * Bricks stores `{post_title}`, `{post_meta:price}`, `{echo:my_fn}` and the
872 * like verbatim and resolves them when it renders. Extraction reads the
873 * stored tree, so without this the placeholders were counted as words, and
874 * a heading whose text is `{post_title}` reported the literal token as its
875 * heading text.
876 *
877 * The pattern is deliberately narrower than Bricks' own
878 * (`/{([\wÀ-ÖØ-öø-ÿ\-\s\.\/:\(\)...]+)}/u`), which also matches braces
879 * containing spaces. Bricks only substitutes tags that resolve to a
880 * registered provider and leaves anything else on the page as literal text,
881 * so the broad pattern would delete prose the visitor can actually read.
882 * Matching only tag-shaped tokens keeps every real sentence and still
883 * removes every placeholder — the same trade-off SureRank makes.
884 *
885 * @since 2.2.1
886 *
887 * @param string $text Extracted text.
888 * @return string Text with placeholders removed.
889 */
890 private static function strip_bricks_dynamic_tags(string $text): string {
891 $stripped = preg_replace('/\{[a-z0-9_][a-z0-9_:\-\.]*\}/i', '', $text);
892
893 if (null === $stripped) {
894 return $text;
895 }
896
897 // Collapse the runs of spaces a removed tag leaves mid-sentence,
898 // without touching the newlines that separate collected nodes.
899 $tidied = preg_replace('/[ \t]{2,}/', ' ', $stripped);
900
901 return null === $tidied ? $stripped : $tidied;
902 }
903
904 /**
905 * Bricks' content-area meta key, preferring Bricks' own constant.
906 *
907 * @since 2.2.1
908 *
909 * @return string
910 */
911 private static function bricks_meta_key(): string {
912 return self::bricks_constant('BRICKS_DB_PAGE_CONTENT', self::BRICKS_CONTENT_META_KEY);
913 }
914
915 /**
916 * Bricks' editor-mode meta key, preferring Bricks' own constant.
917 *
918 * @since 2.2.1
919 *
920 * @return string
921 */
922 private static function bricks_editor_mode_key(): string {
923 return self::bricks_constant('BRICKS_DB_EDITOR_MODE', self::BRICKS_EDITOR_MODE_META_KEY);
924 }
925
926 /**
927 * Read one of Bricks' key-name constants, falling back to the literal.
928 *
929 * @since 2.2.1
930 *
931 * @param string $name Constant name.
932 * @param string $fallback Key to use when the constant is unavailable.
933 * @return string
934 */
935 private static function bricks_constant(string $name, string $fallback): string {
936 if (defined($name)) {
937 $value = constant($name);
938 if (is_string($value) && '' !== trim($value)) {
939 return $value;
940 }
941 }
942
943 return $fallback;
944 }
945
946 /**
947 * Pull text out of whichever builder stored this post.
948 *
949 * @param int $post_id Post ID.
950 * @return string Extracted text, or '' when no builder data was found.
951 */
952 private static function from_builder_meta(int $post_id): string {
953 // Bricks first: it is the only builder whose content can live on
954 // another post, and the only one gated on an editor mode.
955 $bricks = self::from_bricks($post_id);
956 if (!self::is_blank($bricks)) {
957 return $bricks;
958 }
959
960 foreach (self::BUILDER_META_KEYS as $key) {
961 $stored = get_post_meta($post_id, $key, true);
962
963 if (is_string($stored) && '' !== trim($stored)) {
964 $decoded = json_decode($stored, true);
965
966 // JSON node tree (Breakdance/Oxygen 6, Elementor).
967 if (is_array($decoded)) {
968 $text = self::text_from_tree($decoded);
969 if (!self::is_blank($text)) {
970 return $text;
971 }
972 continue;
973 }
974
975 // Shortcode tree (Oxygen classic).
976 if (strpos($stored, '[') !== false && function_exists('do_shortcode')) {
977 try {
978 $rendered = do_shortcode($stored);
979 } catch (\Throwable $e) {
980 $rendered = $stored;
981 }
982 if (!self::is_blank($rendered)) {
983 return $rendered;
984 }
985 }
986
987 continue;
988 }
989
990 // Some builders store an already-decoded tree — an array for most,
991 // an array of objects for Beaver Builder (#449).
992 $tree = self::as_children($stored);
993 if (null !== $tree) {
994 $text = self::text_from_tree($tree);
995 if (!self::is_blank($text)) {
996 return $text;
997 }
998 }
999 }
1000
1001 return '';
1002 }
1003
1004 /**
1005 * A node's children, whether it stores them as an array or an object.
1006 *
1007 * The walker used to return immediately on `!is_array($node)`, so an
1008 * object node was dropped along with its entire subtree — silently, as
1009 * `''`, which the caller reads as "this builder stored nothing" rather
1010 * than "this walker cannot read this shape".
1011 *
1012 * Beaver Builder stores `_fl_builder_data` as an array of stdClass nodes,
1013 * each with a stdClass `settings` object, so every node would have been
1014 * dropped and adding its meta key alone would have looked like it worked
1015 * and changed nothing. Not BB-specific: any builder storing objects hits
1016 * this, and that shape will come up again (#449).
1017 *
1018 * @since 2.1.0
1019 *
1020 * @param mixed $node Candidate node.
1021 * @return array<string|int,mixed>|null Traversable children, or null.
1022 */
1023 private static function as_children($node): ?array {
1024 if (is_array($node)) {
1025 return $node;
1026 }
1027
1028 // Deliberately not is_object(): a builder can store a value object
1029 // (DateTime, a WP_Post) whose properties are not content, and
1030 // get_object_vars() on those yields noise. stdClass is what the
1031 // JSON/serialize round-trip produces, which is the shape we want.
1032 if ($node instanceof \stdClass) {
1033 return get_object_vars($node);
1034 }
1035
1036 return null;
1037 }
1038
1039 /**
1040 * Walk a builder node tree and collect the user-visible text.
1041 *
1042 * Values are joined with block-level markup so downstream heading, link and
1043 * image detection keeps working on the result.
1044 *
1045 * @param array $tree Decoded builder tree.
1046 * @return string Collected HTML.
1047 */
1048 private static function text_from_tree(array $tree): string {
1049 $collected = [];
1050
1051 // Strings already represented inside reconstructed markup, so the plain
1052 // sweep below doesn't emit a link label or heading a second time and
1053 // double it in the word count.
1054 $consumed = [];
1055
1056 // Pass 1 — rebuild <a>, <img> and <hN> from node *shape*. This has to
1057 // happen per node rather than per leaf: a link's label and its
1058 // destination are separate sibling fields, so once the tree is
1059 // flattened to leaves the pairing is gone.
1060 $reconstruct = static function ($node) use (&$reconstruct, &$collected, &$consumed): void {
1061 $node = self::as_children($node);
1062 if (null === $node) {
1063 return;
1064 }
1065
1066 $markup = self::markup_for_node($node, $consumed);
1067 if ('' !== $markup) {
1068 $collected[] = $markup;
1069 }
1070
1071 foreach ($node as $child_key => $child) {
1072 // A `link` / `image` sub-object is a destination descriptor the
1073 // parent has already folded into its markup. Descending into it
1074 // would emit the same URL a second time as a bare link, and
1075 // would turn an image's own `url` field into a spurious <a>.
1076 if (is_string($child_key)
1077 && (in_array(strtolower($child_key), self::URL_KEYS, true)
1078 || in_array(strtolower($child_key), self::IMAGE_KEYS, true)
1079 || in_array(strtolower($child_key), self::VIDEO_KEYS, true))
1080 ) {
1081 continue;
1082 }
1083
1084 $reconstruct($child);
1085 }
1086 };
1087 $reconstruct($tree);
1088
1089 // Pass 2 — remaining visible text.
1090 $walk = static function ($node, $key = null) use (&$walk, &$collected, &$consumed): void {
1091 $children = self::as_children($node);
1092 if (null !== $children) {
1093 foreach ($children as $child_key => $child) {
1094 $walk($child, is_string($child_key) ? $child_key : $key);
1095 }
1096 return;
1097 }
1098
1099 if (!is_string($node) || '' === trim($node)) {
1100 return;
1101 }
1102
1103 // Already inside a reconstructed tag.
1104 if (in_array($node, $consumed, true)) {
1105 return;
1106 }
1107
1108 $is_content_key = is_string($key)
1109 && in_array(strtolower($key), self::CONTENT_KEYS, true);
1110
1111 // Markup is content wherever it appears; bare strings only count
1112 // when their key says they are content, so slugs and class names
1113 // stay out of the word count.
1114 if ($is_content_key || strpos($node, '<') !== false) {
1115 $collected[] = $node;
1116 }
1117 };
1118
1119 $walk($tree);
1120
1121 if (empty($collected)) {
1122 return '';
1123 }
1124
1125 // De-duplicate: builder trees often repeat a value across responsive
1126 // breakpoints, which would otherwise multiply the word count.
1127 $collected = array_unique($collected);
1128
1129 return implode("\n", $collected);
1130 }
1131
1132 /**
1133 * The video source a node is actually playing, if any.
1134 *
1135 * @since 2.3.1
1136 *
1137 * @param array $node Builder node.
1138 * @return string Video source, or '' when the node carries none.
1139 */
1140 private static function video_from(array $node): string {
1141 foreach ($node as $key => $value) {
1142 if (!is_string($key) || !is_string($value)) {
1143 continue;
1144 }
1145
1146 if (!in_array(strtolower($key), self::VIDEO_TYPE_KEYS, true)) {
1147 continue;
1148 }
1149
1150 $keys = self::VIDEO_KEYS_BY_TYPE[strtolower(trim($value))] ?? null;
1151 if (null === $keys) {
1152 continue;
1153 }
1154
1155 // A recognised video_type settles it, including when that
1156 // provider's own field is empty. Falling through to the flat sweep
1157 // there handed back whichever sibling key happened to come first in
1158 // node order — the stale youtube_url left behind after switching
1159 // the widget to a hosted file, which is exactly what keying on the
1160 // declared type is meant to prevent.
1161 $declared = self::url_from($node, $keys);
1162
1163 return self::is_video_source($declared) ? $declared : '';
1164 }
1165
1166 $url = self::url_from($node, self::VIDEO_KEYS);
1167
1168 return self::is_video_source($url) ? $url : '';
1169 }
1170
1171 /**
1172 * Whether a value can be a video source.
1173 *
1174 * `looks_like_url()` also accepts `#anchor`, `mailto:` and `tel:`, which a
1175 * link node may legitimately hold but a video cannot: `<iframe src="#top">`
1176 * is not a video and would reach a video sitemap as one.
1177 *
1178 * @since 2.3.1
1179 *
1180 * @param string $url Candidate source.
1181 * @return bool
1182 */
1183 private static function is_video_source(string $url): bool {
1184 return '' !== $url
1185 && (1 === preg_match('#^(https?:)?//#i', $url) || str_starts_with($url, '/'));
1186 }
1187
1188 /**
1189 * Whether a video source points at a file rather than a provider page.
1190 *
1191 * @since 2.3.1
1192 *
1193 * @param string $url Video source.
1194 * @return bool
1195 */
1196 private static function is_video_file(string $url): bool {
1197 $path = (string) wp_parse_url($url, PHP_URL_PATH);
1198 $ext = strtolower((string) pathinfo($path, PATHINFO_EXTENSION));
1199
1200 return in_array($ext, self::VIDEO_FILE_EXTENSIONS, true);
1201 }
1202
1203 /**
1204 * Rebuild the HTML a single builder node represents, if any.
1205 *
1206 * Looks only at the node's own fields (plus one level of nesting, because
1207 * builders commonly wrap a destination as `{ url: … }`). Returns an empty
1208 * string for the vast majority of nodes, which are layout or configuration.
1209 *
1210 * Any leaf string folded into the returned markup is appended to $consumed
1211 * so the plain-text sweep doesn't count it twice.
1212 *
1213 * @param array $node Builder node.
1214 * @param array $consumed Collects strings represented in the returned markup.
1215 * @return string Reconstructed HTML, or '' when the node carries none.
1216 */
1217 private static function markup_for_node(array $node, array &$consumed): string {
1218 $text = self::first_value($node, self::CONTENT_KEYS);
1219 $url = self::url_from($node, self::URL_KEYS);
1220 $image = self::image_from($node);
1221 $video = self::video_from($node);
1222 $tag = self::heading_tag_from($node);
1223
1224 $parts = [];
1225
1226 // Video: an embed shape rather than a link, so the video detector can
1227 // see it while the link counters do not mistake it for an outbound
1228 // link. A file source becomes <video src>, anything else an <iframe>,
1229 // matching how the builder itself renders the two cases.
1230 if ('' !== $video) {
1231 $parts[] = self::is_video_file($video)
1232 ? sprintf('<video src="%s"></video>', esc_url_raw($video))
1233 : sprintf('<iframe src="%s"></iframe>', esc_url_raw($video));
1234 }
1235
1236 // Image: alt text matters as much as the tag, since alt checks run over
1237 // whatever this returns.
1238 if ('' !== $image['url']) {
1239 $alt = '' !== $image['alt'] ? $image['alt'] : (string) self::first_value($node, self::ALT_KEYS);
1240 if ('' !== $alt) {
1241 $consumed[] = $alt;
1242 }
1243 $parts[] = sprintf(
1244 '<img src="%s" alt="%s" />',
1245 esc_url_raw($image['url']),
1246 htmlspecialchars($alt, ENT_QUOTES)
1247 );
1248 }
1249
1250 if ('' !== $text) {
1251 $inner = $text;
1252
1253 if ('' !== $url) {
1254 $consumed[] = $text;
1255 $inner = sprintf('<a href="%s">%s</a>', esc_url_raw($url), $text);
1256 }
1257
1258 if ('' !== $tag) {
1259 $consumed[] = $text;
1260 $parts[] = sprintf('<%1$s>%2$s</%1$s>', $tag, $inner);
1261 } elseif ('' !== $url) {
1262 $parts[] = $inner;
1263 }
1264 } elseif ('' !== $url) {
1265 // A destination with no label still counts as a link for link
1266 // checks; the URL doubles as its anchor text.
1267 $parts[] = sprintf('<a href="%1$s">%1$s</a>', esc_url_raw($url));
1268 }
1269
1270 return implode("\n", $parts);
1271 }
1272
1273 /**
1274 * First non-empty scalar value under any of the given keys.
1275 *
1276 * @param array $node Builder node.
1277 * @param string[] $keys Candidate keys.
1278 * @return string Trimmed value, or '' when none match.
1279 */
1280 private static function first_value(array $node, array $keys): string {
1281 foreach ($node as $key => $value) {
1282 if (!is_string($key) || !is_string($value)) {
1283 continue;
1284 }
1285 if (in_array(strtolower($key), $keys, true) && '' !== trim($value)) {
1286 return trim($value);
1287 }
1288 }
1289
1290 return '';
1291 }
1292
1293 /**
1294 * Link destination held by a node, as a bare string or a `{ url: … }` object.
1295 *
1296 * @param array $node Builder node.
1297 * @param string[] $keys Candidate keys.
1298 * @return string URL, or '' when the node holds none.
1299 */
1300 private static function url_from(array $node, array $keys): string {
1301 foreach ($node as $key => $value) {
1302 if (!is_string($key) || !in_array(strtolower($key), $keys, true)) {
1303 continue;
1304 }
1305
1306 if (is_string($value) && self::looks_like_url($value)) {
1307 return trim($value);
1308 }
1309
1310 // Elementor and Breakdance both nest the destination one level down.
1311 $nested_values = self::as_children($value);
1312 if (null !== $nested_values) {
1313 foreach ($nested_values as $nested_key => $nested) {
1314 if (is_string($nested_key)
1315 && in_array(strtolower($nested_key), ['url', 'href', 'permalink'], true)
1316 && is_string($nested)
1317 && self::looks_like_url($nested)
1318 ) {
1319 return trim($nested);
1320 }
1321 }
1322 }
1323 }
1324
1325 return '';
1326 }
1327
1328 /**
1329 * Image URL and alt text held by a node.
1330 *
1331 * @param array $node Builder node.
1332 * @return array{url:string,alt:string}
1333 */
1334 private static function image_from(array $node): array {
1335 foreach ($node as $key => $value) {
1336 if (!is_string($key) || !in_array(strtolower($key), self::IMAGE_KEYS, true)) {
1337 continue;
1338 }
1339
1340 if (is_string($value) && self::looks_like_url($value)) {
1341 return ['url' => trim($value), 'alt' => ''];
1342 }
1343
1344 $nested_values = self::as_children($value);
1345 if (null !== $nested_values) {
1346 $url = '';
1347 $alt = '';
1348 foreach ($nested_values as $nested_key => $nested) {
1349 if (!is_string($nested_key) || !is_string($nested)) {
1350 continue;
1351 }
1352 $nested_key = strtolower($nested_key);
1353 if ('' === $url && in_array($nested_key, ['url', 'src'], true) && self::looks_like_url($nested)) {
1354 $url = trim($nested);
1355 }
1356 if ('' === $alt && in_array($nested_key, self::ALT_KEYS, true)) {
1357 $alt = trim($nested);
1358 }
1359 }
1360 if ('' !== $url) {
1361 return ['url' => $url, 'alt' => $alt];
1362 }
1363 }
1364 }
1365
1366 return ['url' => '', 'alt' => ''];
1367 }
1368
1369 /**
1370 * Heading tag a node asks for, normalised to h1–h6.
1371 *
1372 * Accepts both the `h2` form and a bare level like `2`.
1373 *
1374 * @param array $node Builder node.
1375 * @return string Tag name, or '' when the node is not a heading.
1376 */
1377 private static function heading_tag_from(array $node): string {
1378 foreach ($node as $key => $value) {
1379 if (!is_string($key) || !in_array(strtolower($key), self::HEADING_TAG_KEYS, true)) {
1380 continue;
1381 }
1382
1383 if (is_string($value) && preg_match('/^h([1-6])$/i', trim($value), $m)) {
1384 return 'h' . $m[1];
1385 }
1386
1387 // A bare level only counts under a key that unambiguously means one;
1388 // `size` and `tag` carry values like "large" or "div" far more often.
1389 if (is_numeric($value)
1390 && in_array(strtolower($key), ['level'], true)
1391 && (int) $value >= 1 && (int) $value <= 6
1392 ) {
1393 return 'h' . (int) $value;
1394 }
1395 }
1396
1397 return '';
1398 }
1399
1400 /**
1401 * Whether a string is plausibly a link or asset destination.
1402 *
1403 * Deliberately permissive about relative paths — builders store internal
1404 * links that way — but rejects the option slugs and CSS values that make up
1405 * most of a builder tree.
1406 *
1407 * @param string $value Candidate.
1408 * @return bool
1409 */
1410 private static function looks_like_url(string $value): bool {
1411 $value = trim($value);
1412
1413 if ('' === $value || strlen($value) > 2048) {
1414 return false;
1415 }
1416
1417 if (preg_match('#^(https?:)?//#i', $value) || str_starts_with($value, '/')) {
1418 return true;
1419 }
1420
1421 // Protocol-ish destinations a link node can legitimately hold.
1422 return (bool) preg_match('#^(mailto:|tel:|\#)#i', $value);
1423 }
1424
1425 /**
1426 * Whether a value carries nothing worth analyzing.
1427 *
1428 * Readable text is the usual signal, but not the only one: a page can be
1429 * made entirely of media. A builder section holding just a gallery
1430 * reconstructs to `<img>` tags and one holding just a video widget to a
1431 * single `<iframe>` — both strip to an empty string, so a text-only test
1432 * discarded them here and the page fell through to the next builder key,
1433 * then to the raw markup, and finally reported as having no content at all.
1434 *
1435 * Comments are dropped before the tag test: the raw markup this class falls
1436 * back to on a builder page is unrendered block comments, which must stay
1437 * blank rather than be mistaken for reconstructed media.
1438 *
1439 * @param string $value Candidate content.
1440 * @return bool
1441 */
1442 private static function is_blank(string $value): bool {
1443 if ('' !== trim(wp_strip_all_tags($value))) {
1444 return false;
1445 }
1446
1447 $without_comments = (string) preg_replace('~<!--.*?-->~s', '', $value);
1448
1449 return 1 !== preg_match('~<(?:a|img|iframe|video|source)\b~i', $without_comments);
1450 }
1451 }
1452