` 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); } /** * 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 { 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 array. if (is_array($stored)) { $text = self::text_from_tree($stored); if (!self::is_blank($text)) { return $text; } } } return ''; } /** * 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 { if (!is_array($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 { if (is_array($node)) { foreach ($node 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. if (is_array($value)) { foreach ($value 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' => '']; } if (is_array($value)) { $url = ''; $alt = ''; foreach ($value 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)); } }