> */ private static array $bricks_trees = []; /** * JSON keys whose values are user-visible text. * * Builder trees mix content with configuration, so a blind string sweep * would count CSS classes and option slugs as words. Matching on the key * keeps the word count honest. * * @var string[] */ private const CONTENT_KEYS = [ 'text', 'title', 'subtitle', 'heading', 'subheading', 'content', 'description', 'caption', 'excerpt', 'label', 'value', 'html', 'editor', 'quote', 'answer', 'question', 'body', 'button_text', ]; /** * JSON keys whose values hold a link destination. * * Builders store a link's destination in a structured field separate from * its label, either as a bare URL string or as a `{ url: … }` object. * Neither shape survives a text sweep — the key is not content and a bare * URL contains no `<` — so no `` 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 embedded video's source. * * A builder's video widget keeps its destination in a provider-specific * field — Elementor picks `youtube_url`, `vimeo_url`, `dailymotion_url` or * `hosted_url` according to the chosen source type — none of which is a * link field or a content field, so a video on a builder page reached the * analyzers as nothing at all. * * These are deliberately kept out of URL_KEYS. A video is an embed, not an * outbound link: rendering one as `` would add a spurious external * link to every page carrying a video and skew the link counts. They are * reconstructed as `', esc_url_raw($video)); } // 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 nothing worth analyzing. * * Readable text is the usual signal, but not the only one: a page can be * made entirely of media. A builder section holding just a gallery * reconstructs to `` tags and one holding just a video widget to a * single `