PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / 2.7.0
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO v2.7.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
← All changes | includes/seo/class-builder-content.php +1156 -11 1.25.02.7.0 View file →
@@ -52,11 +52,86 @@
52 52 '_breakdance_data', // Oxygen 6+ / Breakdance
53 53 '_oxygen_data', // Oxygen (earlier releases)
54 54 'ct_builder_shortcodes', // Oxygen classic
55 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)
56 62 ];
57 63
58 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 + /**
59 134 * JSON keys whose values are user-visible text.
60 135 *
61 136 * Builder trees mix content with configuration, so a blind string sweep
62 137 * would count CSS classes and option slugs as words. Matching on the key
@@ -70,8 +145,118 @@
70 145 'editor', 'quote', 'answer', 'question', 'body', 'button_text',
71 146 ];
72 147
73 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 + /**
74 259 * Resolve the content worth analyzing for a post.
75 260 *
76 261 * @param \WP_Post $post Post being analyzed.
77 262 * @return string HTML/text to analyze.
@@ -76,12 +261,231 @@
76 261 * @param \WP_Post $post Post being analyzed.
77 262 * @return string HTML/text to analyze.
78 263 */
79 264 public static function resolve(\WP_Post $post): string {
80 - return self::resolve_markup((string) $post->post_content, $post);
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);
81 285 }
82 286
83 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 + /**
422 + * The builder meta keys, for callers that need to inspect the raw storage
423 + * rather than the text extracted from it.
424 + *
425 + * The SEO Analyzer reads these to answer "is there a ThinkRank FAQ element
426 + * on this post?", which is a question about the stored tree, not about the
427 + * words in it (#686).
428 + *
429 + * @since 2.7.0
430 + * @return string[]
431 + */
432 + public static function builder_meta_keys(): array {
433 + return self::BUILDER_META_KEYS;
434 + }
435 +
436 + public static function bricks_tree(int $post_id): array {
437 + if (array_key_exists($post_id, self::$bricks_trees)) {
438 + return self::$bricks_trees[$post_id];
439 + }
440 +
441 + self::$bricks_trees[$post_id] = self::resolve_bricks_tree($post_id);
442 +
443 + return self::$bricks_trees[$post_id];
444 + }
445 +
446 + /**
447 + * Discard the resolved-tree memo. Test seam.
448 + *
449 + * @since 2.3.1
450 + * @return void
451 + */
452 + public static function flush_bricks_cache(): void {
453 + self::$bricks_trees = [];
454 + }
455 +
456 + /**
457 + * Read and resolve a post's Bricks tree, ignoring the memo.
458 + *
459 + * @since 2.3.1
460 + *
461 + * @param int $post_id Post being resolved.
462 + * @return array<int,mixed>
463 + */
464 + private static function resolve_bricks_tree(int $post_id): array {
465 + if (!self::bricks_owns_post($post_id)) {
466 + return [];
467 + }
468 +
469 + $source = self::bricks_content_source($post_id);
470 + if (!$source) {
471 + return [];
472 + }
473 +
474 + $stored = get_post_meta($source, self::bricks_meta_key(), true);
475 +
476 + if (is_string($stored)) {
477 + $stored = '' === trim($stored) ? null : json_decode($stored, true);
478 + }
479 +
480 + if (!is_array($stored) || empty($stored)) {
481 + return [];
482 + }
483 +
484 + return self::expand_bricks_components($stored);
485 + }
486 +
487 + /**
84 488 * Resolve an arbitrary chunk of editor markup for the given post.
85 489 *
86 490 * The editor sends its live content to the scorer so an author sees their
87 491 * unsaved edits reflected. On a builder page that live string is the raw
@@ -196,10 +600,10 @@
196 600 return '';
197 601 }
198 602
199 603 $attrs = [];
200 - $collect = static function (array $list) use (&$collect, &$attrs): void {
201 - foreach ($list as $block) {
604 + $collect = static function (array $items) use (&$collect, &$attrs): void {
605 + foreach ($items as $block) {
202 606 if (!empty($block['attrs']) && is_array($block['attrs'])) {
203 607 $attrs[] = $block['attrs'];
204 608 }
205 609 if (!empty($block['innerBlocks']) && is_array($block['innerBlocks'])) {
@@ -212,8 +616,350 @@
212 616 return empty($attrs) ? '' : self::text_from_tree($attrs);
213 617 }
214 618
215 619 /**
620 + * Everything Bricks contributes to this post's analyzable content.
621 + *
622 + * Bricks is the only builder here that needs more than a meta key, on
623 + * three counts:
624 + *
625 + * - It leaves its stored tree behind when a post is switched back to the
626 + * block editor, so an editor-mode gate has to run first or ThinkRank
627 + * scores markup the visitor never sees — the same failure
628 + * `_fl_builder_draft` was ordered against in #449.
629 + * - A post's content can live on ANOTHER post. Bricks' Templates feature
630 + * assigns a content template by condition, and a page using one stores
631 + * nothing of its own; reading only the page's meta scores it blank
632 + * while the visitor reads a full page.
633 + * - Its stored text carries dynamic-data tags and internal element names
634 + * that never reach the rendered page.
635 + *
636 + * @since 2.2.1
637 + *
638 + * @param int $post_id Post being resolved.
639 + * @return string Extracted text, or '' when Bricks has nothing for it.
640 + */
641 + private static function from_bricks(int $post_id): string {
642 + $tree = self::bricks_tree($post_id);
643 +
644 + if (empty($tree)) {
645 + return '';
646 + }
647 +
648 + return self::strip_bricks_dynamic_tags(
649 + self::text_from_tree(self::without_bricks_element_labels($tree))
650 + );
651 + }
652 +
653 + /**
654 + * Whether Bricks — not the block editor — renders this post.
655 + *
656 + * Bricks writes `bricks` or `wordpress` into its editor-mode meta as the
657 + * author toggles between the two, and never clears the content it stored
658 + * for the other mode. Only the `wordpress` value is disqualifying: an
659 + * absent value is the normal state for a post Bricks built and never
660 + * toggled. This follows Bricks' own `Helpers::render_with_bricks()`, which
661 + * bails on exactly that one value.
662 + *
663 + * It deliberately does not match it exactly: the comparison here is
664 + * case-insensitive, where Bricks' is strict. Bricks 2.3.12 only ever writes
665 + * the value lowercase, so the two agree on everything Bricks itself
666 + * stores; they part company only on a value some other integration wrote.
667 + * The two shipping today disagree about the casing — SureRank compares
668 + * against `'WordPress'`, AIOSEO against `'bricks'` — and of the two ways to
669 + * be wrong about `'WordPress'`, blocking costs a score on a page that has
670 + * one, while allowing scores stale content the visitor never sees, which is
671 + * the failure this gate exists to prevent.
672 + *
673 + * @since 2.2.1
674 + *
675 + * @param int $post_id Post being resolved.
676 + * @return bool
677 + */
678 + private static function bricks_owns_post(int $post_id): bool {
679 + $mode = get_post_meta($post_id, self::bricks_editor_mode_key(), true);
680 +
681 + // phpcs:ignore WordPress.WP.CapitalPDangit.MisspelledInText -- Bricks' own stored meta value, lower-cased for the comparison.
682 + return !(is_string($mode) && 'wordpress' === strtolower(trim($mode)));
683 + }
684 +
685 + /**
686 + * The post whose Bricks tree actually renders for this post.
687 + *
688 + * Usually the post itself. When it stores nothing of its own, Bricks falls
689 + * back to whichever content template's conditions match, and that template
690 + * is a separate post carrying the words the visitor reads.
691 + *
692 + * Resolution is delegated to Bricks rather than reimplemented: template
693 + * conditions are a whole rules engine (post IDs, types, taxonomies,
694 + * archives), and a second implementation would drift from it. Bricks
695 + * answers through statics, so they are saved and restored around the call —
696 + * `set_active_templates()` returns early once populated, and on a
697 + * front-end request Bricks has already populated it for the page being
698 + * served. Clobbering that would corrupt the render in progress.
699 + *
700 + * Best-effort by design: any failure returns the post's own data, which is
701 + * exactly today's behaviour.
702 + *
703 + * @since 2.2.1
704 + *
705 + * @param int $post_id Post being resolved.
706 + * @return int Post ID holding the Bricks tree, or 0 when there is none.
707 + */
708 + private static function bricks_content_source(int $post_id): int {
709 + $own = get_post_meta($post_id, self::bricks_meta_key(), true);
710 + if ((is_array($own) && !empty($own)) || (is_string($own) && '' !== trim($own))) {
711 + return $post_id;
712 + }
713 +
714 + if (!class_exists('\\Bricks\\Database')
715 + || !method_exists('\\Bricks\\Database', 'set_active_templates')
716 + ) {
717 + return 0;
718 + }
719 +
720 + // `set_active_templates()` writes TWO statics — `$active_templates` and,
721 + // when a header template resolves, `$header_position`. Both are saved,
722 + // and both are restored in `finally` rather than on the happy path: a
723 + // throw part-way through (a third-party hook on
724 + // `bricks/database/content_type`, `bricks/builder/data_post_id` or
725 + // `bricks/active_templates` is enough) must not leave Bricks' render
726 + // state holding this lookup's values. Restoring only after a clean
727 + // return is what the `catch` below would otherwise skip.
728 + $has_header_position = property_exists('\\Bricks\\Database', 'header_position');
729 + $saved_templates = \Bricks\Database::$active_templates;
730 + $saved_header_position = $has_header_position ? \Bricks\Database::$header_position : null;
731 +
732 + try {
733 + \Bricks\Database::$active_templates = [];
734 + \Bricks\Database::set_active_templates($post_id);
735 + $template = (int) (\Bricks\Database::$active_templates['content'] ?? 0);
736 + } catch (\Throwable $e) {
737 + return 0;
738 + } finally {
739 + \Bricks\Database::$active_templates = $saved_templates;
740 + if ($has_header_position) {
741 + \Bricks\Database::$header_position = $saved_header_position;
742 + }
743 + }
744 +
745 + // A template that is the post itself adds nothing over the empty read
746 + // above, and would otherwise recurse conceptually.
747 + return $template === $post_id ? 0 : $template;
748 + }
749 +
750 + /**
751 + * Splice component definitions into the tree.
752 + *
753 + * A Bricks component keeps its markup in the `bricks_components` option,
754 + * not on the page. The page stores only an instance: an element carrying
755 + * `cid` and, usually, empty `settings`. Walking the page alone therefore
756 + * found no words at all, and a page built entirely from components scored
757 + * blank — the same failure as a page built from a content template.
758 + *
759 + * Confirmed on Bricks 2.3.12: `Bricks\Frontend::render_data()` renders the
760 + * component's copy from an instance this walker extracted '' from.
761 + *
762 + * The definition is read straight from the option rather than through
763 + * `Bricks\Helpers::get_component_instance()`. That helper resolves an
764 + * instance's property overrides, which would be better, but it reads
765 + * `Bricks\Database::$global_data['components']` — populated once per
766 + * request, and empty in the admin and CLI contexts where bulk scoring
767 + * runs. Refreshing it would mean writing to Bricks' live render state, the
768 + * same hazard the template resolver is careful to avoid, and gating on it
769 + * would make a page score differently in wp-admin than on the front end.
770 + * Reading the stored definition is consistent everywhere.
771 + *
772 + * The trade-off: an instance that overrides a component property is scored
773 + * with the component's authored copy rather than the override. That is the
774 + * text the component renders by default, and it is much closer than the
775 + * nothing this returned before.
776 + *
777 + * @since 2.2.1
778 + *
779 + * @param array $tree Bricks content area.
780 + * @return array Tree with component elements spliced in after each instance.
781 + */
782 + private static function expand_bricks_components(array $tree): array {
783 + $expanded = [];
784 + $open = [];
785 +
786 + $walk = static function (array $elements, int $depth) use (&$walk, &$expanded, &$open): void {
787 + foreach ($elements as $element) {
788 + $expanded[] = $element;
789 +
790 + if (!is_array($element) || empty($element['cid']) || !is_string($element['cid'])) {
791 + continue;
792 + }
793 +
794 + $cid = $element['cid'];
795 +
796 + // A component nested inside its own definition would recurse
797 + // forever; the depth cap covers deep but legitimate nesting.
798 + if (isset($open[$cid]) || $depth > 4) {
799 + continue;
800 + }
801 +
802 + $children = self::bricks_component_elements($cid);
803 + if (empty($children)) {
804 + continue;
805 + }
806 +
807 + // Re-entrant per branch, not per page: the guard is released
808 + // after the walk so a second instance further along the page
809 + // still expands, rather than being mistaken for recursion.
810 + //
811 + // That does NOT double the word count — `text_from_tree()`
812 + // ends in `array_unique()`, which collapses a repeated
813 + // component's copy the same way it collapses a value repeated
814 + // across responsive breakpoints. Expanding both instances is
815 + // about not silently dropping the second one's structure.
816 + $open[$cid] = true;
817 + $walk($children, $depth + 1);
818 + unset($open[$cid]);
819 + }
820 + };
821 +
822 + $walk($tree, 0);
823 +
824 + return $expanded;
825 + }
826 +
827 + /**
828 + * The stored elements of one Bricks component.
829 + *
830 + * @since 2.2.1
831 + *
832 + * @param string $cid Component id held by an instance element.
833 + * @return array Component elements, or [] when it cannot be resolved.
834 + */
835 + private static function bricks_component_elements(string $cid): array {
836 + $components = get_option(self::bricks_constant('BRICKS_DB_COMPONENTS', self::BRICKS_COMPONENTS_OPTION), []);
837 +
838 + if (!is_array($components)) {
839 + return [];
840 + }
841 +
842 + foreach ($components as $component) {
843 + $component = self::as_children($component);
844 + if (null === $component) {
845 + continue;
846 + }
847 +
848 + if (isset($component['id']) && $component['id'] === $cid && !empty($component['elements'])) {
849 + return is_array($component['elements']) ? $component['elements'] : [];
850 + }
851 + }
852 +
853 + return [];
854 + }
855 +
856 + /**
857 + * Drop each Bricks element's internal name before the tree is walked.
858 + *
859 + * A Bricks element carries an optional top-level `label` — the nickname an
860 + * author types in the Structure panel to find it again ("Hero headline",
861 + * "CTA row"). It is builder chrome and is never rendered, but `label` is in
862 + * CONTENT_KEYS because it is real content for other builders' form fields,
863 + * so it was being counted as page copy.
864 + *
865 + * Only the element's own `label` is removed. A `label` inside `settings`
866 + * is a rendered field label and stays.
867 + *
868 + * @since 2.2.1
869 + *
870 + * @param array $tree Bricks content area.
871 + * @return array Tree with element nicknames removed.
872 + */
873 + private static function without_bricks_element_labels(array $tree): array {
874 + foreach ($tree as $index => $element) {
875 + if (is_array($element) && isset($element['id'], $element['label'])) {
876 + unset($tree[$index]['label']);
877 + }
878 + }
879 +
880 + return $tree;
881 + }
882 +
883 + /**
884 + * Remove Bricks dynamic-data tags from extracted text.
885 + *
886 + * Bricks stores `{post_title}`, `{post_meta:price}`, `{echo:my_fn}` and the
887 + * like verbatim and resolves them when it renders. Extraction reads the
888 + * stored tree, so without this the placeholders were counted as words, and
889 + * a heading whose text is `{post_title}` reported the literal token as its
890 + * heading text.
891 + *
892 + * The pattern is deliberately narrower than Bricks' own
893 + * (`/{([\wÀ-ÖØ-öø-ÿ\-\s\.\/:\(\)...]+)}/u`), which also matches braces
894 + * containing spaces. Bricks only substitutes tags that resolve to a
895 + * registered provider and leaves anything else on the page as literal text,
896 + * so the broad pattern would delete prose the visitor can actually read.
897 + * Matching only tag-shaped tokens keeps every real sentence and still
898 + * removes every placeholder — the same trade-off SureRank makes.
899 + *
900 + * @since 2.2.1
901 + *
902 + * @param string $text Extracted text.
903 + * @return string Text with placeholders removed.
904 + */
905 + private static function strip_bricks_dynamic_tags(string $text): string {
906 + $stripped = preg_replace('/\{[a-z0-9_][a-z0-9_:\-\.]*\}/i', '', $text);
907 +
908 + if (null === $stripped) {
909 + return $text;
910 + }
911 +
912 + // Collapse the runs of spaces a removed tag leaves mid-sentence,
913 + // without touching the newlines that separate collected nodes.
914 + $tidied = preg_replace('/[ \t]{2,}/', ' ', $stripped);
915 +
916 + return null === $tidied ? $stripped : $tidied;
917 + }
918 +
919 + /**
920 + * Bricks' content-area meta key, preferring Bricks' own constant.
921 + *
922 + * @since 2.2.1
923 + *
924 + * @return string
925 + */
926 + private static function bricks_meta_key(): string {
927 + return self::bricks_constant('BRICKS_DB_PAGE_CONTENT', self::BRICKS_CONTENT_META_KEY);
928 + }
929 +
930 + /**
931 + * Bricks' editor-mode meta key, preferring Bricks' own constant.
932 + *
933 + * @since 2.2.1
934 + *
935 + * @return string
936 + */
937 + private static function bricks_editor_mode_key(): string {
938 + return self::bricks_constant('BRICKS_DB_EDITOR_MODE', self::BRICKS_EDITOR_MODE_META_KEY);
939 + }
940 +
941 + /**
942 + * Read one of Bricks' key-name constants, falling back to the literal.
943 + *
944 + * @since 2.2.1
945 + *
946 + * @param string $name Constant name.
947 + * @param string $fallback Key to use when the constant is unavailable.
948 + * @return string
949 + */
950 + private static function bricks_constant(string $name, string $fallback): string {
951 + if (defined($name)) {
952 + $value = constant($name);
953 + if (is_string($value) && '' !== trim($value)) {
954 + return $value;
955 + }
956 + }
957 +
958 + return $fallback;
959 + }
960 +
961 + /**
216 962 * Pull text out of whichever builder stored this post.
217 963 *
218 964 * @param int $post_id Post ID.
219 965 * @return string Extracted text, or '' when no builder data was found.
@@ -218,8 +964,15 @@
218 964 * @param int $post_id Post ID.
219 965 * @return string Extracted text, or '' when no builder data was found.
220 966 */
221 967 private static function from_builder_meta(int $post_id): string {
968 + // Bricks first: it is the only builder whose content can live on
969 + // another post, and the only one gated on an editor mode.
970 + $bricks = self::from_bricks($post_id);
971 + if (!self::is_blank($bricks)) {
972 + return $bricks;
973 + }
974 +
222 975 foreach (self::BUILDER_META_KEYS as $key) {
223 976 $stored = get_post_meta($post_id, $key, true);
224 977
225 978 if (is_string($stored) && '' !== trim($stored)) {
@@ -248,11 +1001,13 @@
248 1001
249 1002 continue;
250 1003 }
251 1004
252 - // Some builders store an already-decoded array.
253 - if (is_array($stored)) {
254 - $text = self::text_from_tree($stored);
1005 + // Some builders store an already-decoded tree — an array for most,
1006 + // an array of objects for Beaver Builder (#449).
1007 + $tree = self::as_children($stored);
1008 + if (null !== $tree) {
1009 + $text = self::text_from_tree($tree);
255 1010 if (!self::is_blank($text)) {
256 1011 return $text;
257 1012 }
258 1013 }
@@ -261,8 +1016,43 @@
261 1016 return '';
262 1017 }
263 1018
264 1019 /**
1020 + * A node's children, whether it stores them as an array or an object.
1021 + *
1022 + * The walker used to return immediately on `!is_array($node)`, so an
1023 + * object node was dropped along with its entire subtree — silently, as
1024 + * `''`, which the caller reads as "this builder stored nothing" rather
1025 + * than "this walker cannot read this shape".
1026 + *
1027 + * Beaver Builder stores `_fl_builder_data` as an array of stdClass nodes,
1028 + * each with a stdClass `settings` object, so every node would have been
1029 + * dropped and adding its meta key alone would have looked like it worked
1030 + * and changed nothing. Not BB-specific: any builder storing objects hits
1031 + * this, and that shape will come up again (#449).
1032 + *
1033 + * @since 2.1.0
1034 + *
1035 + * @param mixed $node Candidate node.
1036 + * @return array<string|int,mixed>|null Traversable children, or null.
1037 + */
1038 + private static function as_children($node): ?array {
1039 + if (is_array($node)) {
1040 + return $node;
1041 + }
1042 +
1043 + // Deliberately not is_object(): a builder can store a value object
1044 + // (DateTime, a WP_Post) whose properties are not content, and
1045 + // get_object_vars() on those yields noise. stdClass is what the
1046 + // JSON/serialize round-trip produces, which is the shape we want.
1047 + if ($node instanceof \stdClass) {
1048 + return get_object_vars($node);
1049 + }
1050 +
1051 + return null;
1052 + }
1053 +
1054 + /**
265 1055 * Walk a builder node tree and collect the user-visible text.
266 1056 *
267 1057 * Values are joined with block-level markup so downstream heading, link and
268 1058 * image detection keeps working on the result.
@@ -272,11 +1062,51 @@
272 1062 */
273 1063 private static function text_from_tree(array $tree): string {
274 1064 $collected = [];
275 1065
276 - $walk = static function ($node, $key = null) use (&$walk, &$collected): void {
277 - if (is_array($node)) {
278 - foreach ($node as $child_key => $child) {
1066 + // Strings already represented inside reconstructed markup, so the plain
1067 + // sweep below doesn't emit a link label or heading a second time and
1068 + // double it in the word count.
1069 + $consumed = [];
1070 +
1071 + // Pass 1 — rebuild <a>, <img> and <hN> from node *shape*. This has to
1072 + // happen per node rather than per leaf: a link's label and its
1073 + // destination are separate sibling fields, so once the tree is
1074 + // flattened to leaves the pairing is gone.
1075 + $reconstruct = static function ($node) use (&$reconstruct, &$collected, &$consumed): void {
1076 + $node = self::as_children($node);
1077 + if (null === $node) {
1078 + return;
1079 + }
1080 +
1081 + $markup = self::markup_for_node($node, $consumed);
1082 + if ('' !== $markup) {
1083 + $collected[] = $markup;
1084 + }
1085 +
1086 + foreach ($node as $child_key => $child) {
1087 + // A `link` / `image` sub-object is a destination descriptor the
1088 + // parent has already folded into its markup. Descending into it
1089 + // would emit the same URL a second time as a bare link, and
1090 + // would turn an image's own `url` field into a spurious <a>.
1091 + if (is_string($child_key)
1092 + && (in_array(strtolower($child_key), self::URL_KEYS, true)
1093 + || in_array(strtolower($child_key), self::IMAGE_KEYS, true)
1094 + || in_array(strtolower($child_key), self::VIDEO_KEYS, true))
1095 + ) {
1096 + continue;
1097 + }
1098 +
1099 + $reconstruct($child);
1100 + }
1101 + };
1102 + $reconstruct($tree);
1103 +
1104 + // Pass 2 — remaining visible text.
1105 + $walk = static function ($node, $key = null) use (&$walk, &$collected, &$consumed): void {
1106 + $children = self::as_children($node);
1107 + if (null !== $children) {
1108 + foreach ($children as $child_key => $child) {
279 1109 $walk($child, is_string($child_key) ? $child_key : $key);
280 1110 }
281 1111 return;
282 1112 }
@@ -284,8 +1114,13 @@
284 1114 if (!is_string($node) || '' === trim($node)) {
285 1115 return;
286 1116 }
287 1117
1118 + // Already inside a reconstructed tag.
1119 + if (in_array($node, $consumed, true)) {
1120 + return;
1121 + }
1122 +
288 1123 $is_content_key = is_string($key)
289 1124 && in_array(strtolower($key), self::CONTENT_KEYS, true);
290 1125
291 1126 // Markup is content wherever it appears; bare strings only count
@@ -309,13 +1144,323 @@
309 1144 return implode("\n", $collected);
310 1145 }
311 1146
312 1147 /**
313 - * Whether a value carries no readable text.
1148 + * The video source a node is actually playing, if any.
314 1149 *
1150 + * @since 2.3.1
1151 + *
1152 + * @param array $node Builder node.
1153 + * @return string Video source, or '' when the node carries none.
1154 + */
1155 + private static function video_from(array $node): string {
1156 + foreach ($node as $key => $value) {
1157 + if (!is_string($key) || !is_string($value)) {
1158 + continue;
1159 + }
1160 +
1161 + if (!in_array(strtolower($key), self::VIDEO_TYPE_KEYS, true)) {
1162 + continue;
1163 + }
1164 +
1165 + $keys = self::VIDEO_KEYS_BY_TYPE[strtolower(trim($value))] ?? null;
1166 + if (null === $keys) {
1167 + continue;
1168 + }
1169 +
1170 + // A recognised video_type settles it, including when that
1171 + // provider's own field is empty. Falling through to the flat sweep
1172 + // there handed back whichever sibling key happened to come first in
1173 + // node order — the stale youtube_url left behind after switching
1174 + // the widget to a hosted file, which is exactly what keying on the
1175 + // declared type is meant to prevent.
1176 + $declared = self::url_from($node, $keys);
1177 +
1178 + return self::is_video_source($declared) ? $declared : '';
1179 + }
1180 +
1181 + $url = self::url_from($node, self::VIDEO_KEYS);
1182 +
1183 + return self::is_video_source($url) ? $url : '';
1184 + }
1185 +
1186 + /**
1187 + * Whether a value can be a video source.
1188 + *
1189 + * `looks_like_url()` also accepts `#anchor`, `mailto:` and `tel:`, which a
1190 + * link node may legitimately hold but a video cannot: `<iframe src="#top">`
1191 + * is not a video and would reach a video sitemap as one.
1192 + *
1193 + * @since 2.3.1
1194 + *
1195 + * @param string $url Candidate source.
1196 + * @return bool
1197 + */
1198 + private static function is_video_source(string $url): bool {
1199 + return '' !== $url
1200 + && (1 === preg_match('#^(https?:)?//#i', $url) || str_starts_with($url, '/'));
1201 + }
1202 +
1203 + /**
1204 + * Whether a video source points at a file rather than a provider page.
1205 + *
1206 + * @since 2.3.1
1207 + *
1208 + * @param string $url Video source.
1209 + * @return bool
1210 + */
1211 + private static function is_video_file(string $url): bool {
1212 + $path = (string) wp_parse_url($url, PHP_URL_PATH);
1213 + $ext = strtolower((string) pathinfo($path, PATHINFO_EXTENSION));
1214 +
1215 + return in_array($ext, self::VIDEO_FILE_EXTENSIONS, true);
1216 + }
1217 +
1218 + /**
1219 + * Rebuild the HTML a single builder node represents, if any.
1220 + *
1221 + * Looks only at the node's own fields (plus one level of nesting, because
1222 + * builders commonly wrap a destination as `{ url: … }`). Returns an empty
1223 + * string for the vast majority of nodes, which are layout or configuration.
1224 + *
1225 + * Any leaf string folded into the returned markup is appended to $consumed
1226 + * so the plain-text sweep doesn't count it twice.
1227 + *
1228 + * @param array $node Builder node.
1229 + * @param array $consumed Collects strings represented in the returned markup.
1230 + * @return string Reconstructed HTML, or '' when the node carries none.
1231 + */
1232 + private static function markup_for_node(array $node, array &$consumed): string {
1233 + $text = self::first_value($node, self::CONTENT_KEYS);
1234 + $url = self::url_from($node, self::URL_KEYS);
1235 + $image = self::image_from($node);
1236 + $video = self::video_from($node);
1237 + $tag = self::heading_tag_from($node);
1238 +
1239 + $parts = [];
1240 +
1241 + // Video: an embed shape rather than a link, so the video detector can
1242 + // see it while the link counters do not mistake it for an outbound
1243 + // link. A file source becomes <video src>, anything else an <iframe>,
1244 + // matching how the builder itself renders the two cases.
1245 + if ('' !== $video) {
1246 + $parts[] = self::is_video_file($video)
1247 + ? sprintf('<video src="%s"></video>', esc_url_raw($video))
1248 + : sprintf('<iframe src="%s"></iframe>', esc_url_raw($video));
1249 + }
1250 +
1251 + // Image: alt text matters as much as the tag, since alt checks run over
1252 + // whatever this returns.
1253 + if ('' !== $image['url']) {
1254 + $alt = '' !== $image['alt'] ? $image['alt'] : (string) self::first_value($node, self::ALT_KEYS);
1255 + if ('' !== $alt) {
1256 + $consumed[] = $alt;
1257 + }
1258 + $parts[] = sprintf(
1259 + '<img src="%s" alt="%s" />',
1260 + esc_url_raw($image['url']),
1261 + htmlspecialchars($alt, ENT_QUOTES)
1262 + );
1263 + }
1264 +
1265 + if ('' !== $text) {
1266 + $inner = $text;
1267 +
1268 + if ('' !== $url) {
1269 + $consumed[] = $text;
1270 + $inner = sprintf('<a href="%s">%s</a>', esc_url_raw($url), $text);
1271 + }
1272 +
1273 + if ('' !== $tag) {
1274 + $consumed[] = $text;
1275 + $parts[] = sprintf('<%1$s>%2$s</%1$s>', $tag, $inner);
1276 + } elseif ('' !== $url) {
1277 + $parts[] = $inner;
1278 + }
1279 + } elseif ('' !== $url) {
1280 + // A destination with no label still counts as a link for link
1281 + // checks; the URL doubles as its anchor text.
1282 + $parts[] = sprintf('<a href="%1$s">%1$s</a>', esc_url_raw($url));
1283 + }
1284 +
1285 + return implode("\n", $parts);
1286 + }
1287 +
1288 + /**
1289 + * First non-empty scalar value under any of the given keys.
1290 + *
1291 + * @param array $node Builder node.
1292 + * @param string[] $keys Candidate keys.
1293 + * @return string Trimmed value, or '' when none match.
1294 + */
1295 + private static function first_value(array $node, array $keys): string {
1296 + foreach ($node as $key => $value) {
1297 + if (!is_string($key) || !is_string($value)) {
1298 + continue;
1299 + }
1300 + if (in_array(strtolower($key), $keys, true) && '' !== trim($value)) {
1301 + return trim($value);
1302 + }
1303 + }
1304 +
1305 + return '';
1306 + }
1307 +
1308 + /**
1309 + * Link destination held by a node, as a bare string or a `{ url: … }` object.
1310 + *
1311 + * @param array $node Builder node.
1312 + * @param string[] $keys Candidate keys.
1313 + * @return string URL, or '' when the node holds none.
1314 + */
1315 + private static function url_from(array $node, array $keys): string {
1316 + foreach ($node as $key => $value) {
1317 + if (!is_string($key) || !in_array(strtolower($key), $keys, true)) {
1318 + continue;
1319 + }
1320 +
1321 + if (is_string($value) && self::looks_like_url($value)) {
1322 + return trim($value);
1323 + }
1324 +
1325 + // Elementor and Breakdance both nest the destination one level down.
1326 + $nested_values = self::as_children($value);
1327 + if (null !== $nested_values) {
1328 + foreach ($nested_values as $nested_key => $nested) {
1329 + if (is_string($nested_key)
1330 + && in_array(strtolower($nested_key), ['url', 'href', 'permalink'], true)
1331 + && is_string($nested)
1332 + && self::looks_like_url($nested)
1333 + ) {
1334 + return trim($nested);
1335 + }
1336 + }
1337 + }
1338 + }
1339 +
1340 + return '';
1341 + }
1342 +
1343 + /**
1344 + * Image URL and alt text held by a node.
1345 + *
1346 + * @param array $node Builder node.
1347 + * @return array{url:string,alt:string}
1348 + */
1349 + private static function image_from(array $node): array {
1350 + foreach ($node as $key => $value) {
1351 + if (!is_string($key) || !in_array(strtolower($key), self::IMAGE_KEYS, true)) {
1352 + continue;
1353 + }
1354 +
1355 + if (is_string($value) && self::looks_like_url($value)) {
1356 + return ['url' => trim($value), 'alt' => ''];
1357 + }
1358 +
1359 + $nested_values = self::as_children($value);
1360 + if (null !== $nested_values) {
1361 + $url = '';
1362 + $alt = '';
1363 + foreach ($nested_values as $nested_key => $nested) {
1364 + if (!is_string($nested_key) || !is_string($nested)) {
1365 + continue;
1366 + }
1367 + $nested_key = strtolower($nested_key);
1368 + if ('' === $url && in_array($nested_key, ['url', 'src'], true) && self::looks_like_url($nested)) {
1369 + $url = trim($nested);
1370 + }
1371 + if ('' === $alt && in_array($nested_key, self::ALT_KEYS, true)) {
1372 + $alt = trim($nested);
1373 + }
1374 + }
1375 + if ('' !== $url) {
1376 + return ['url' => $url, 'alt' => $alt];
1377 + }
1378 + }
1379 + }
1380 +
1381 + return ['url' => '', 'alt' => ''];
1382 + }
1383 +
1384 + /**
1385 + * Heading tag a node asks for, normalised to h1–h6.
1386 + *
1387 + * Accepts both the `h2` form and a bare level like `2`.
1388 + *
1389 + * @param array $node Builder node.
1390 + * @return string Tag name, or '' when the node is not a heading.
1391 + */
1392 + private static function heading_tag_from(array $node): string {
1393 + foreach ($node as $key => $value) {
1394 + if (!is_string($key) || !in_array(strtolower($key), self::HEADING_TAG_KEYS, true)) {
1395 + continue;
1396 + }
1397 +
1398 + if (is_string($value) && preg_match('/^h([1-6])$/i', trim($value), $m)) {
1399 + return 'h' . $m[1];
1400 + }
1401 +
1402 + // A bare level only counts under a key that unambiguously means one;
1403 + // `size` and `tag` carry values like "large" or "div" far more often.
1404 + if (is_numeric($value)
1405 + && in_array(strtolower($key), ['level'], true)
1406 + && (int) $value >= 1 && (int) $value <= 6
1407 + ) {
1408 + return 'h' . (int) $value;
1409 + }
1410 + }
1411 +
1412 + return '';
1413 + }
1414 +
1415 + /**
1416 + * Whether a string is plausibly a link or asset destination.
1417 + *
1418 + * Deliberately permissive about relative paths — builders store internal
1419 + * links that way — but rejects the option slugs and CSS values that make up
1420 + * most of a builder tree.
1421 + *
1422 + * @param string $value Candidate.
1423 + * @return bool
1424 + */
1425 + private static function looks_like_url(string $value): bool {
1426 + $value = trim($value);
1427 +
1428 + if ('' === $value || strlen($value) > 2048) {
1429 + return false;
1430 + }
1431 +
1432 + if (preg_match('#^(https?:)?//#i', $value) || str_starts_with($value, '/')) {
1433 + return true;
1434 + }
1435 +
1436 + // Protocol-ish destinations a link node can legitimately hold.
1437 + return (bool) preg_match('#^(mailto:|tel:|\#)#i', $value);
1438 + }
1439 +
1440 + /**
1441 + * Whether a value carries nothing worth analyzing.
1442 + *
1443 + * Readable text is the usual signal, but not the only one: a page can be
1444 + * made entirely of media. A builder section holding just a gallery
1445 + * reconstructs to `<img>` tags and one holding just a video widget to a
1446 + * single `<iframe>` — both strip to an empty string, so a text-only test
1447 + * discarded them here and the page fell through to the next builder key,
1448 + * then to the raw markup, and finally reported as having no content at all.
1449 + *
1450 + * Comments are dropped before the tag test: the raw markup this class falls
1451 + * back to on a builder page is unrendered block comments, which must stay
1452 + * blank rather than be mistaken for reconstructed media.
1453 + *
315 1454 * @param string $value Candidate content.
316 1455 * @return bool
317 1456 */
318 1457 private static function is_blank(string $value): bool {
319 - return '' === trim(wp_strip_all_tags($value));
1458 + if ('' !== trim(wp_strip_all_tags($value))) {
1459 + return false;
1460 + }
1461 +
1462 + $without_comments = (string) preg_replace('~<!--.*?-->~s', '', $value);
1463 +
1464 + return 1 !== preg_match('~<(?:a|img|iframe|video|source)\b~i', $without_comments);
320 1465 }
321 1466 }