` tag reached the link counters. * * @var string[] */ private const URL_KEYS = [ 'link', 'url', 'href', 'link_url', 'button_link', 'permalink', 'link_to', ]; /** * JSON keys whose values hold an image, as a URL string or `{ url, alt }`. * * @var string[] */ private const IMAGE_KEYS = [ 'image', 'src', 'image_url', 'background_image', 'bg_image', 'photo', ]; /** * JSON keys that carry a heading level for the node's text. * * A builder heading's text is collected (its key is in CONTENT_KEYS) and so * counts toward the word count, but it arrives as bare text with no `

` * wrapper — which is why heading-structure checks saw none. * * @var string[] */ private const HEADING_TAG_KEYS = [ 'header_size', 'heading_tag', 'html_tag', 'title_tag', 'tag', 'level', 'size', ]; /** * Keys whose value is alternative text for a sibling image. * * @var string[] */ private const ALT_KEYS = ['alt', 'alt_text', 'image_alt', 'title']; /** * Resolve the content worth analyzing for a post. * * @param \WP_Post $post Post being analyzed. * @return string HTML/text to analyze. */ public static function resolve(\WP_Post $post): string { return self::resolve_markup((string) $post->post_content, $post); } /** * Resolve an arbitrary chunk of editor markup for the given post. * * The editor sends its live content to the scorer so an author sees their * unsaved edits reflected. On a builder page that live string is the raw * builder markup — the block editor hands over Divi's * `` comments verbatim, because it cannot render * blocks it has no client-side registration for. Analyzed as-is it reads * as zero words, which is how a Divi page could show a correct saved score * while the live Content Analysis panel next to it still said * "No content". * * Running the live string through the same chain as stored content keeps * both paths honest, and falling through to the post's builder storage * covers builders (Oxygen) whose editor content is empty to begin with. * * @since 1.23.0 * * @param string $raw Markup to analyze. * @param \WP_Post $post Post the markup belongs to. * @return string Content to analyze. */ public static function resolve_markup(string $raw, \WP_Post $post): string { $content = self::render_post_content($raw); // Block markup that renders to nothing usually means the builder that // owns those blocks did not register them in this context — Divi 5 // loads its module library lazily per-request, so in CLI, REST, admin // and block-editor requests do_blocks() yields an empty string while // the words sit right there in the block attributes. Read them // directly. if (self::is_blank($content)) { $from_blocks = self::from_block_attributes($raw); if (!self::is_blank($from_blocks)) { $content = $from_blocks; } } // Only reach for builder storage when the markup yielded nothing — a // classic post must never pay for this. if (self::is_blank($content)) { $builder = self::from_builder_meta((int) $post->ID); if (!self::is_blank($builder)) { $content = $builder; } } // A resolution that collapsed to nothing is worse than the raw markup. if (self::is_blank($content) && !self::is_blank($raw)) { $content = $raw; } /** * Filter the content ThinkRank analyzes for a post. * * Use this to teach ThinkRank about a builder it does not know, or to * override extraction for one it does. * * @since 1.23.0 * * @param string $content Resolved content. * @param \WP_Post $post Post being analyzed. * @param string $raw Markup this resolution started from. */ return (string) apply_filters('thinkrank_analyzable_content', $content, $post, $raw); } /** * Render blocks and shortcodes found in post_content. * * Best-effort: a third-party block that fatals must not take the whole * score down with it. * * @param string $raw Raw post content. * @return string Rendered content. */ private static function render_post_content(string $raw): string { if ('' === trim($raw)) { return ''; } $content = $raw; try { if (function_exists('has_blocks') && function_exists('do_blocks') && has_blocks($raw)) { $content = do_blocks($raw); } // Block output can itself contain shortcodes, so this runs either way. if (function_exists('do_shortcode') && strpos($content, '[') !== false) { $content = do_shortcode($content); } } catch (\Throwable $e) { return $raw; } return self::is_blank($content) ? $raw : $content; } /** * Extract text from the attributes of parsed blocks. * * @param string $raw Raw post content containing block markup. * @return string Collected text, or '' when nothing was found. */ private static function from_block_attributes(string $raw): string { if (!function_exists('parse_blocks') || !function_exists('has_blocks') || !has_blocks($raw)) { return ''; } try { $blocks = parse_blocks($raw); } catch (\Throwable $e) { return ''; } $attrs = []; $collect = static function (array $items) use (&$collect, &$attrs): void { foreach ($items as $block) { if (!empty($block['attrs']) && is_array($block['attrs'])) { $attrs[] = $block['attrs']; } if (!empty($block['innerBlocks']) && is_array($block['innerBlocks'])) { $collect($block['innerBlocks']); } } }; $collect($blocks); return empty($attrs) ? '' : self::text_from_tree($attrs); } /** * Everything Bricks contributes to this post's analyzable content. * * Bricks is the only builder here that needs more than a meta key, on * three counts: * * - It leaves its stored tree behind when a post is switched back to the * block editor, so an editor-mode gate has to run first or ThinkRank * scores markup the visitor never sees — the same failure * `_fl_builder_draft` was ordered against in #449. * - A post's content can live on ANOTHER post. Bricks' Templates feature * assigns a content template by condition, and a page using one stores * nothing of its own; reading only the page's meta scores it blank * while the visitor reads a full page. * - Its stored text carries dynamic-data tags and internal element names * that never reach the rendered page. * * @since 2.2.1 * * @param int $post_id Post being resolved. * @return string Extracted text, or '' when Bricks has nothing for it. */ private static function from_bricks(int $post_id): string { if (!self::bricks_owns_post($post_id)) { return ''; } $source = self::bricks_content_source($post_id); if (!$source) { return ''; } $stored = get_post_meta($source, self::bricks_meta_key(), true); if (is_string($stored)) { $stored = '' === trim($stored) ? null : json_decode($stored, true); } if (!is_array($stored) || empty($stored)) { return ''; } return self::strip_bricks_dynamic_tags( self::text_from_tree( self::without_bricks_element_labels(self::expand_bricks_components($stored)) ) ); } /** * Whether Bricks — not the block editor — renders this post. * * Bricks writes `bricks` or `wordpress` into its editor-mode meta as the * author toggles between the two, and never clears the content it stored * for the other mode. Only the `wordpress` value is disqualifying: an * absent value is the normal state for a post Bricks built and never * toggled. This follows Bricks' own `Helpers::render_with_bricks()`, which * bails on exactly that one value. * * It deliberately does not match it exactly: the comparison here is * case-insensitive, where Bricks' is strict. Bricks 2.3.12 only ever writes * the value lowercase, so the two agree on everything Bricks itself * stores; they part company only on a value some other integration wrote. * The two shipping today disagree about the casing — SureRank compares * against `'WordPress'`, AIOSEO against `'bricks'` — and of the two ways to * be wrong about `'WordPress'`, blocking costs a score on a page that has * one, while allowing scores stale content the visitor never sees, which is * the failure this gate exists to prevent. * * @since 2.2.1 * * @param int $post_id Post being resolved. * @return bool */ private static function bricks_owns_post(int $post_id): bool { $mode = get_post_meta($post_id, self::bricks_editor_mode_key(), true); // phpcs:ignore WordPress.WP.CapitalPDangit.MisspelledInText -- Bricks' own stored meta value, lower-cased for the comparison. return !(is_string($mode) && 'wordpress' === strtolower(trim($mode))); } /** * The post whose Bricks tree actually renders for this post. * * Usually the post itself. When it stores nothing of its own, Bricks falls * back to whichever content template's conditions match, and that template * is a separate post carrying the words the visitor reads. * * Resolution is delegated to Bricks rather than reimplemented: template * conditions are a whole rules engine (post IDs, types, taxonomies, * archives), and a second implementation would drift from it. Bricks * answers through statics, so they are saved and restored around the call — * `set_active_templates()` returns early once populated, and on a * front-end request Bricks has already populated it for the page being * served. Clobbering that would corrupt the render in progress. * * Best-effort by design: any failure returns the post's own data, which is * exactly today's behaviour. * * @since 2.2.1 * * @param int $post_id Post being resolved. * @return int Post ID holding the Bricks tree, or 0 when there is none. */ private static function bricks_content_source(int $post_id): int { $own = get_post_meta($post_id, self::bricks_meta_key(), true); if ((is_array($own) && !empty($own)) || (is_string($own) && '' !== trim($own))) { return $post_id; } if (!class_exists('\\Bricks\\Database') || !method_exists('\\Bricks\\Database', 'set_active_templates') ) { return 0; } // `set_active_templates()` writes TWO statics — `$active_templates` and, // when a header template resolves, `$header_position`. Both are saved, // and both are restored in `finally` rather than on the happy path: a // throw part-way through (a third-party hook on // `bricks/database/content_type`, `bricks/builder/data_post_id` or // `bricks/active_templates` is enough) must not leave Bricks' render // state holding this lookup's values. Restoring only after a clean // return is what the `catch` below would otherwise skip. $has_header_position = property_exists('\\Bricks\\Database', 'header_position'); $saved_templates = \Bricks\Database::$active_templates; $saved_header_position = $has_header_position ? \Bricks\Database::$header_position : null; try { \Bricks\Database::$active_templates = []; \Bricks\Database::set_active_templates($post_id); $template = (int) (\Bricks\Database::$active_templates['content'] ?? 0); } catch (\Throwable $e) { return 0; } finally { \Bricks\Database::$active_templates = $saved_templates; if ($has_header_position) { \Bricks\Database::$header_position = $saved_header_position; } } // A template that is the post itself adds nothing over the empty read // above, and would otherwise recurse conceptually. return $template === $post_id ? 0 : $template; } /** * Splice component definitions into the tree. * * A Bricks component keeps its markup in the `bricks_components` option, * not on the page. The page stores only an instance: an element carrying * `cid` and, usually, empty `settings`. Walking the page alone therefore * found no words at all, and a page built entirely from components scored * blank — the same failure as a page built from a content template. * * Confirmed on Bricks 2.3.12: `Bricks\Frontend::render_data()` renders the * component's copy from an instance this walker extracted '' from. * * The definition is read straight from the option rather than through * `Bricks\Helpers::get_component_instance()`. That helper resolves an * instance's property overrides, which would be better, but it reads * `Bricks\Database::$global_data['components']` — populated once per * request, and empty in the admin and CLI contexts where bulk scoring * runs. Refreshing it would mean writing to Bricks' live render state, the * same hazard the template resolver is careful to avoid, and gating on it * would make a page score differently in wp-admin than on the front end. * Reading the stored definition is consistent everywhere. * * The trade-off: an instance that overrides a component property is scored * with the component's authored copy rather than the override. That is the * text the component renders by default, and it is much closer than the * nothing this returned before. * * @since 2.2.1 * * @param array $tree Bricks content area. * @return array Tree with component elements spliced in after each instance. */ private static function expand_bricks_components(array $tree): array { $expanded = []; $open = []; $walk = static function (array $elements, int $depth) use (&$walk, &$expanded, &$open): void { foreach ($elements as $element) { $expanded[] = $element; if (!is_array($element) || empty($element['cid']) || !is_string($element['cid'])) { continue; } $cid = $element['cid']; // A component nested inside its own definition would recurse // forever; the depth cap covers deep but legitimate nesting. if (isset($open[$cid]) || $depth > 4) { continue; } $children = self::bricks_component_elements($cid); if (empty($children)) { continue; } // Re-entrant per branch, not per page: the guard is released // after the walk so a second instance further along the page // still expands, rather than being mistaken for recursion. // // That does NOT double the word count — `text_from_tree()` // ends in `array_unique()`, which collapses a repeated // component's copy the same way it collapses a value repeated // across responsive breakpoints. Expanding both instances is // about not silently dropping the second one's structure. $open[$cid] = true; $walk($children, $depth + 1); unset($open[$cid]); } }; $walk($tree, 0); return $expanded; } /** * The stored elements of one Bricks component. * * @since 2.2.1 * * @param string $cid Component id held by an instance element. * @return array Component elements, or [] when it cannot be resolved. */ private static function bricks_component_elements(string $cid): array { $components = get_option(self::bricks_constant('BRICKS_DB_COMPONENTS', self::BRICKS_COMPONENTS_OPTION), []); if (!is_array($components)) { return []; } foreach ($components as $component) { $component = self::as_children($component); if (null === $component) { continue; } if (isset($component['id']) && $component['id'] === $cid && !empty($component['elements'])) { return is_array($component['elements']) ? $component['elements'] : []; } } return []; } /** * Drop each Bricks element's internal name before the tree is walked. * * A Bricks element carries an optional top-level `label` — the nickname an * author types in the Structure panel to find it again ("Hero headline", * "CTA row"). It is builder chrome and is never rendered, but `label` is in * CONTENT_KEYS because it is real content for other builders' form fields, * so it was being counted as page copy. * * Only the element's own `label` is removed. A `label` inside `settings` * is a rendered field label and stays. * * @since 2.2.1 * * @param array $tree Bricks content area. * @return array Tree with element nicknames removed. */ private static function without_bricks_element_labels(array $tree): array { foreach ($tree as $index => $element) { if (is_array($element) && isset($element['id'], $element['label'])) { unset($tree[$index]['label']); } } return $tree; } /** * Remove Bricks dynamic-data tags from extracted text. * * Bricks stores `{post_title}`, `{post_meta:price}`, `{echo:my_fn}` and the * like verbatim and resolves them when it renders. Extraction reads the * stored tree, so without this the placeholders were counted as words, and * a heading whose text is `{post_title}` reported the literal token as its * heading text. * * The pattern is deliberately narrower than Bricks' own * (`/{([\wÀ-ÖØ-öø-ÿ\-\s\.\/:\(\)...]+)}/u`), which also matches braces * containing spaces. Bricks only substitutes tags that resolve to a * registered provider and leaves anything else on the page as literal text, * so the broad pattern would delete prose the visitor can actually read. * Matching only tag-shaped tokens keeps every real sentence and still * removes every placeholder — the same trade-off SureRank makes. * * @since 2.2.1 * * @param string $text Extracted text. * @return string Text with placeholders removed. */ private static function strip_bricks_dynamic_tags(string $text): string { $stripped = preg_replace('/\{[a-z0-9_][a-z0-9_:\-\.]*\}/i', '', $text); if (null === $stripped) { return $text; } // Collapse the runs of spaces a removed tag leaves mid-sentence, // without touching the newlines that separate collected nodes. $tidied = preg_replace('/[ \t]{2,}/', ' ', $stripped); return null === $tidied ? $stripped : $tidied; } /** * Bricks' content-area meta key, preferring Bricks' own constant. * * @since 2.2.1 * * @return string */ private static function bricks_meta_key(): string { return self::bricks_constant('BRICKS_DB_PAGE_CONTENT', self::BRICKS_CONTENT_META_KEY); } /** * Bricks' editor-mode meta key, preferring Bricks' own constant. * * @since 2.2.1 * * @return string */ private static function bricks_editor_mode_key(): string { return self::bricks_constant('BRICKS_DB_EDITOR_MODE', self::BRICKS_EDITOR_MODE_META_KEY); } /** * Read one of Bricks' key-name constants, falling back to the literal. * * @since 2.2.1 * * @param string $name Constant name. * @param string $fallback Key to use when the constant is unavailable. * @return string */ private static function bricks_constant(string $name, string $fallback): string { if (defined($name)) { $value = constant($name); if (is_string($value) && '' !== trim($value)) { return $value; } } return $fallback; } /** * Pull text out of whichever builder stored this post. * * @param int $post_id Post ID. * @return string Extracted text, or '' when no builder data was found. */ private static function from_builder_meta(int $post_id): string { // Bricks first: it is the only builder whose content can live on // another post, and the only one gated on an editor mode. $bricks = self::from_bricks($post_id); if (!self::is_blank($bricks)) { return $bricks; } foreach (self::BUILDER_META_KEYS as $key) { $stored = get_post_meta($post_id, $key, true); if (is_string($stored) && '' !== trim($stored)) { $decoded = json_decode($stored, true); // JSON node tree (Breakdance/Oxygen 6, Elementor). if (is_array($decoded)) { $text = self::text_from_tree($decoded); if (!self::is_blank($text)) { return $text; } continue; } // Shortcode tree (Oxygen classic). if (strpos($stored, '[') !== false && function_exists('do_shortcode')) { try { $rendered = do_shortcode($stored); } catch (\Throwable $e) { $rendered = $stored; } if (!self::is_blank($rendered)) { return $rendered; } } continue; } // Some builders store an already-decoded tree — an array for most, // an array of objects for Beaver Builder (#449). $tree = self::as_children($stored); if (null !== $tree) { $text = self::text_from_tree($tree); if (!self::is_blank($text)) { return $text; } } } return ''; } /** * A node's children, whether it stores them as an array or an object. * * The walker used to return immediately on `!is_array($node)`, so an * object node was dropped along with its entire subtree — silently, as * `''`, which the caller reads as "this builder stored nothing" rather * than "this walker cannot read this shape". * * Beaver Builder stores `_fl_builder_data` as an array of stdClass nodes, * each with a stdClass `settings` object, so every node would have been * dropped and adding its meta key alone would have looked like it worked * and changed nothing. Not BB-specific: any builder storing objects hits * this, and that shape will come up again (#449). * * @since 2.1.0 * * @param mixed $node Candidate node. * @return array|null Traversable children, or null. */ private static function as_children($node): ?array { if (is_array($node)) { return $node; } // Deliberately not is_object(): a builder can store a value object // (DateTime, a WP_Post) whose properties are not content, and // get_object_vars() on those yields noise. stdClass is what the // JSON/serialize round-trip produces, which is the shape we want. if ($node instanceof \stdClass) { return get_object_vars($node); } return null; } /** * Walk a builder node tree and collect the user-visible text. * * Values are joined with block-level markup so downstream heading, link and * image detection keeps working on the result. * * @param array $tree Decoded builder tree. * @return string Collected HTML. */ private static function text_from_tree(array $tree): string { $collected = []; // Strings already represented inside reconstructed markup, so the plain // sweep below doesn't emit a link label or heading a second time and // double it in the word count. $consumed = []; // Pass 1 — rebuild , and from node *shape*. This has to // happen per node rather than per leaf: a link's label and its // destination are separate sibling fields, so once the tree is // flattened to leaves the pairing is gone. $reconstruct = static function ($node) use (&$reconstruct, &$collected, &$consumed): void { $node = self::as_children($node); if (null === $node) { return; } $markup = self::markup_for_node($node, $consumed); if ('' !== $markup) { $collected[] = $markup; } foreach ($node as $child_key => $child) { // A `link` / `image` sub-object is a destination descriptor the // parent has already folded into its markup. Descending into it // would emit the same URL a second time as a bare link, and // would turn an image's own `url` field into a spurious . if (is_string($child_key) && (in_array(strtolower($child_key), self::URL_KEYS, true) || in_array(strtolower($child_key), self::IMAGE_KEYS, true)) ) { continue; } $reconstruct($child); } }; $reconstruct($tree); // Pass 2 — remaining visible text. $walk = static function ($node, $key = null) use (&$walk, &$collected, &$consumed): void { $children = self::as_children($node); if (null !== $children) { foreach ($children as $child_key => $child) { $walk($child, is_string($child_key) ? $child_key : $key); } return; } if (!is_string($node) || '' === trim($node)) { return; } // Already inside a reconstructed tag. if (in_array($node, $consumed, true)) { return; } $is_content_key = is_string($key) && in_array(strtolower($key), self::CONTENT_KEYS, true); // Markup is content wherever it appears; bare strings only count // when their key says they are content, so slugs and class names // stay out of the word count. if ($is_content_key || strpos($node, '<') !== false) { $collected[] = $node; } }; $walk($tree); if (empty($collected)) { return ''; } // De-duplicate: builder trees often repeat a value across responsive // breakpoints, which would otherwise multiply the word count. $collected = array_unique($collected); return implode("\n", $collected); } /** * Rebuild the HTML a single builder node represents, if any. * * Looks only at the node's own fields (plus one level of nesting, because * builders commonly wrap a destination as `{ url: … }`). Returns an empty * string for the vast majority of nodes, which are layout or configuration. * * Any leaf string folded into the returned markup is appended to $consumed * so the plain-text sweep doesn't count it twice. * * @param array $node Builder node. * @param array $consumed Collects strings represented in the returned markup. * @return string Reconstructed HTML, or '' when the node carries none. */ private static function markup_for_node(array $node, array &$consumed): string { $text = self::first_value($node, self::CONTENT_KEYS); $url = self::url_from($node, self::URL_KEYS); $image = self::image_from($node); $tag = self::heading_tag_from($node); $parts = []; // Image: alt text matters as much as the tag, since alt checks run over // whatever this returns. if ('' !== $image['url']) { $alt = '' !== $image['alt'] ? $image['alt'] : (string) self::first_value($node, self::ALT_KEYS); if ('' !== $alt) { $consumed[] = $alt; } $parts[] = sprintf( '%s', esc_url_raw($image['url']), htmlspecialchars($alt, ENT_QUOTES) ); } if ('' !== $text) { $inner = $text; if ('' !== $url) { $consumed[] = $text; $inner = sprintf('%s', esc_url_raw($url), $text); } if ('' !== $tag) { $consumed[] = $text; $parts[] = sprintf('<%1$s>%2$s', $tag, $inner); } elseif ('' !== $url) { $parts[] = $inner; } } elseif ('' !== $url) { // A destination with no label still counts as a link for link // checks; the URL doubles as its anchor text. $parts[] = sprintf('%1$s', esc_url_raw($url)); } return implode("\n", $parts); } /** * First non-empty scalar value under any of the given keys. * * @param array $node Builder node. * @param string[] $keys Candidate keys. * @return string Trimmed value, or '' when none match. */ private static function first_value(array $node, array $keys): string { foreach ($node as $key => $value) { if (!is_string($key) || !is_string($value)) { continue; } if (in_array(strtolower($key), $keys, true) && '' !== trim($value)) { return trim($value); } } return ''; } /** * Link destination held by a node, as a bare string or a `{ url: … }` object. * * @param array $node Builder node. * @param string[] $keys Candidate keys. * @return string URL, or '' when the node holds none. */ private static function url_from(array $node, array $keys): string { foreach ($node as $key => $value) { if (!is_string($key) || !in_array(strtolower($key), $keys, true)) { continue; } if (is_string($value) && self::looks_like_url($value)) { return trim($value); } // Elementor and Breakdance both nest the destination one level down. $nested_values = self::as_children($value); if (null !== $nested_values) { foreach ($nested_values as $nested_key => $nested) { if (is_string($nested_key) && in_array(strtolower($nested_key), ['url', 'href', 'permalink'], true) && is_string($nested) && self::looks_like_url($nested) ) { return trim($nested); } } } } return ''; } /** * Image URL and alt text held by a node. * * @param array $node Builder node. * @return array{url:string,alt:string} */ private static function image_from(array $node): array { foreach ($node as $key => $value) { if (!is_string($key) || !in_array(strtolower($key), self::IMAGE_KEYS, true)) { continue; } if (is_string($value) && self::looks_like_url($value)) { return ['url' => trim($value), 'alt' => '']; } $nested_values = self::as_children($value); if (null !== $nested_values) { $url = ''; $alt = ''; foreach ($nested_values as $nested_key => $nested) { if (!is_string($nested_key) || !is_string($nested)) { continue; } $nested_key = strtolower($nested_key); if ('' === $url && in_array($nested_key, ['url', 'src'], true) && self::looks_like_url($nested)) { $url = trim($nested); } if ('' === $alt && in_array($nested_key, self::ALT_KEYS, true)) { $alt = trim($nested); } } if ('' !== $url) { return ['url' => $url, 'alt' => $alt]; } } } return ['url' => '', 'alt' => '']; } /** * Heading tag a node asks for, normalised to h1–h6. * * Accepts both the `h2` form and a bare level like `2`. * * @param array $node Builder node. * @return string Tag name, or '' when the node is not a heading. */ private static function heading_tag_from(array $node): string { foreach ($node as $key => $value) { if (!is_string($key) || !in_array(strtolower($key), self::HEADING_TAG_KEYS, true)) { continue; } if (is_string($value) && preg_match('/^h([1-6])$/i', trim($value), $m)) { return 'h' . $m[1]; } // A bare level only counts under a key that unambiguously means one; // `size` and `tag` carry values like "large" or "div" far more often. if (is_numeric($value) && in_array(strtolower($key), ['level'], true) && (int) $value >= 1 && (int) $value <= 6 ) { return 'h' . (int) $value; } } return ''; } /** * Whether a string is plausibly a link or asset destination. * * Deliberately permissive about relative paths — builders store internal * links that way — but rejects the option slugs and CSS values that make up * most of a builder tree. * * @param string $value Candidate. * @return bool */ private static function looks_like_url(string $value): bool { $value = trim($value); if ('' === $value || strlen($value) > 2048) { return false; } if (preg_match('#^(https?:)?//#i', $value) || str_starts_with($value, '/')) { return true; } // Protocol-ish destinations a link node can legitimately hold. return (bool) preg_match('#^(mailto:|tel:|\#)#i', $value); } /** * Whether a value carries no readable text. * * @param string $value Candidate content. * @return bool */ private static function is_blank(string $value): bool { return '' === trim(wp_strip_all_tags($value)); } }