0` and a note saying what it * could not look at — which is why `checked === 0` must be read as * "undetermined" and not as "clean", by the panel and by the aggregates. * * Nothing here is unbounded. Every scanner either asks the database to count * (and so examines the whole population without loading it) or reads a capped * batch and says in `notes` that it stopped. A site with fifty thousand posts * runs the same code as a site with nine. */ /** * Area slug => the function that scans it. * * Derived from areas.php rather than written out, so a new live area cannot be * added in one file and silently forgotten in the other. * * @return array */ function novasira_scanners(): array { $scanners = []; foreach (novasira_live_areas() as $slug) { $scanners[$slug] = 'novasira_scan_' . str_replace('-', '_', $slug); } return $scanners; } /** * The areas that have no data of their own: they compose what the others found. * * Named here so novasira_scan_all() can run the real scanners once and build * these from the result, instead of calling itself. * * @return list */ function novasira_scan_aggregates(): array { return ['audit', 'plan']; } /** * Scan one area. * * Always returns the documented shape, including for an area that does not * exist or is advisory — a caller handing this to an agent should get a * sentence explaining itself, not an exception to translate. * * @param array $args * @return array */ function novasira_scan(string $area, array $args = []): array { $area = novasira_scan_slug($area); $scanners = novasira_scanners(); if (!isset($scanners[$area])) { $known = novasira_area($area); if ($known !== null && (string) $known['mode'] === 'advisory') { return novasira_scan_undetermined($area, [ __('This area is advisory: the numbers live outside this database, so there is nothing here to scan. Read the area guidance for the method.', 'novasira'), ]); } return novasira_scan_undetermined($area, [ __('No scanner exists for that area.', 'novasira'), ]); } $callable = $scanners[$area]; if (!novasira_scan_load($area) || !function_exists($callable)) { return novasira_scan_undetermined($area, [ __('The scanner for this area could not be loaded, so nothing was examined.', 'novasira'), ]); } return novasira_scan_normalise($area, (array) call_user_func($callable, $args)); } /** * Scan every live area. * * The real scanners run once each; audit and plan are then composed from those * results rather than re-running anything. A scanner that throws is reported as * undetermined instead of taking the whole run down with it: one broken check * on one unusual site should cost that area, not the audit. * * Served from a short-lived transient unless `$args['refresh']` is true. Twenty * scanners is a real amount of work, and a dashboard that does it again on * every page view makes looking at the site expensive enough that people stop * looking. The transient is this engine's one write, it holds nothing but the * results, and it is keyed by the arguments because a run for one post is not a * run for another. * * @param array $args * @return array> */ function novasira_scan_all(array $args = []): array { $refresh = !empty($args['refresh']); unset($args['refresh']); $key = novasira_scan_cache_key($args); if (!$refresh) { $cached = novasira_scan_cache_get($key); if ($cached !== null) { return $cached; } } $direct = novasira_scan_direct($args); $results = $direct; // Composed from the direct results only. Passing the growing array would // feed the audit's own merged findings to the plan and count everything // twice. foreach (novasira_scan_aggregates() as $slug) { if (!isset(novasira_scanners()[$slug])) { continue; } $results[$slug] = novasira_scan_compose($slug, $direct); } novasira_scan_cache_set($key, $results); return $results; } /** How long a full run stays cached, in seconds. */ function novasira_scan_cache_ttl(): int { $ttl = 600; if (function_exists('apply_filters')) { $ttl = (int) apply_filters('novasira_scan_cache_ttl', $ttl); } return max(0, min($ttl, 86400)); } /** * The transient name for one set of arguments. * * Hashed rather than spelled out: the arguments can carry a topic somebody * typed, and an option name is not the place for arbitrary text. * * @param array $args */ function novasira_scan_cache_key(array $args): string { // Per user: a scan lists post titles, including drafts and private posts // the scanning user may read, and a run cached for an administrator must // never be served to a contributor. $args['__user'] = function_exists('get_current_user_id') ? get_current_user_id() : 0; ksort($args); $encoded = function_exists('wp_json_encode') ? wp_json_encode($args) : json_encode($args); return 'novasira_scan_all_' . md5(is_string($encoded) ? $encoded : ''); } /** * A cached full run, or null. * * Validated before it is trusted. A transient survives a plugin update, so a * run cached by an older version can come back in a shape this one does not * expect, and the honest response to that is to scan again. * * @return array>|null */ function novasira_scan_cache_get(string $key): ?array { if (!function_exists('get_transient') || novasira_scan_cache_ttl() === 0) { return null; } $cached = get_transient($key); if (!is_array($cached) || $cached === []) { return null; } $out = []; foreach ($cached as $slug => $result) { if (!is_array($result) || !isset($result['area'], $result['checked'], $result['findings'])) { return null; } // Rebuilt through the normaliser so that a cached run is guaranteed to // be the same shape as a fresh one, and flagged so a screen can say // when it was made rather than implying it is current. $result['cached'] = true; $out[(string) $slug] = novasira_scan_normalise((string) $slug, $result); } return $out === [] ? null : $out; } /** * Cache a full run. * * @param array> $results */ function novasira_scan_cache_set(string $key, array $results): void { $ttl = novasira_scan_cache_ttl(); if (!function_exists('set_transient') || $ttl === 0 || $results === []) { return; } set_transient($key, $results, $ttl); } /** * Every live area that has data of its own, run once each. * * A scanner that throws is reported as undetermined instead of taking the * whole run down with it: one broken check on one unusual site should cost * that area, not the audit. * * @param array $args * @return array> */ function novasira_scan_direct(array $args = []): array { $aggregates = novasira_scan_aggregates(); $results = []; foreach (novasira_scanners() as $slug => $callable) { if (in_array($slug, $aggregates, true)) { continue; } try { $results[$slug] = novasira_scan($slug, $args); } catch (Throwable $e) { $results[$slug] = novasira_scan_undetermined($slug, [ __('This area stopped before it finished, so it has nothing to report. The reason is in the PHP error log.', 'novasira'), ]); } } return $results; } /** * Build one of the aggregates out of a set of scanner results. * * @param array> $results * @return array */ function novasira_scan_compose(string $area, array $results): array { if (!novasira_scan_load($area)) { return novasira_scan_undetermined($area, [ __('The scanner for this area could not be loaded, so nothing was examined.', 'novasira'), ]); } $callable = 'novasira_scan_' . str_replace('-', '_', $area) . '_from'; if (!function_exists($callable)) { return novasira_scan_undetermined($area, [ __('The scanner for this area could not be loaded, so nothing was examined.', 'novasira'), ]); } return novasira_scan_normalise($area, (array) call_user_func($callable, $results)); } /** Only ever a known area slug, and only ever these characters in a path. */ function novasira_scan_slug(string $area): string { return (string) preg_replace('/[^a-z0-9-]/', '', strtolower(trim($area))); } /** Load one scanner file. One file per area, named for the area. */ function novasira_scan_load(string $area): bool { $area = novasira_scan_slug($area); if ($area === '') { return false; } $file = __DIR__ . '/scanners/' . $area . '.php'; if (!is_readable($file)) { return false; } require_once $file; return true; } /* --------------------------------------------------------------------------- * Building a result * ------------------------------------------------------------------------- */ /** * The severities, worst first. * * `critical` is reserved for a finding where one item is actively costing * indexing or traffic right now — a site told not to be indexed, a page whose * canonical hands it to somebody else. Kept rare on purpose: a scale where * everything urgent is critical is a scale with three values again. * * @return list */ function novasira_scan_severities(): array { return ['critical', 'high', 'medium', 'low']; } /** Findings carry at most this many example items, by contract. */ function novasira_scan_items_limit(): int { return 20; } /** * How much of a population a scanner may read into PHP. * * Each area passes the cap that suits the work it does: counting rows the * database can count is cheap, reading four hundred post bodies is not. The * filter is there for the site that genuinely wants a deeper sample, and it is * clamped so that raising it cannot be turned into a way to exhaust memory. */ function novasira_scan_cap(string $area, int $default): int { $cap = $default; if (function_exists('apply_filters')) { $cap = (int) apply_filters('novasira_scan_cap', $default, $area); } return max(1, min($cap, 5000)); } /** * One finding. * * @param array> $items * @return array */ function novasira_scan_finding( string $id, string $severity, string $title, string $detail, int $count, array $items = [], ?string $fix = null ): array { if (!in_array($severity, novasira_scan_severities(), true)) { $severity = 'low'; } $items = array_slice(array_values($items), 0, novasira_scan_items_limit()); return [ 'id' => $id, 'severity' => $severity, 'title' => $title, 'detail' => $detail, 'count' => max(count($items), max(0, $count)), 'items' => $items, 'fix' => $fix, ]; } /** * The finished result for one area: sorted worst first, scored, shape fixed. * * The score is derived from the findings unless one is passed in. The two * aggregates pass one, because their score is a mean across the other areas * and cannot be recovered from a merged finding list. * * A scan that looked at nothing scores null, not a hundred, and that is * enforced here rather than left to each scanner. Anything averaging these * numbers would otherwise read a blocked or broken area as a perfect one, so a * site's grade would rise the moment a check stopped working — wrong in the * direction that flatters, and silent about it. `checked => 0` says the same * thing a second way, for a caller that would rather test that. * * @param array> $findings * @param list $notes * @return array */ function novasira_scan_result(string $area, int $checked, array $findings, array $notes = [], ?int $score = null): array { $findings = novasira_scan_sort(array_values(array_filter($findings))); $checked = max(0, $checked); if ($checked === 0) { $resolved = null; } elseif ($score === null) { $resolved = novasira_scan_score($findings, $checked); } else { $resolved = (int) max(0, min(100, $score)); } return [ 'area' => $area, 'score' => $resolved, 'checked' => $checked, 'findings' => $findings, 'notes' => array_values(array_unique(array_filter(array_map('strval', $notes)))), 'ran_at' => time(), 'cached' => false, ]; } /** * A scan that could not be made. * * `checked => 0` with no findings is the engine's way of saying "I did not * look", and the note says why. The score is null, never a hundred: an area * that could not look has no grade, and the aggregates leave it out of their * average rather than counting it as a clean pass. * * @param list $notes * @return array */ function novasira_scan_undetermined(string $area, array $notes): array { return novasira_scan_result($area, 0, [], $notes); } /** * Force any scanner's return value into the contract. * * @param array $result * @return array */ function novasira_scan_normalise(string $area, array $result): array { $findings = []; foreach ((array) ($result['findings'] ?? []) as $finding) { if (!is_array($finding) || !isset($finding['id'])) { continue; } $findings[] = novasira_scan_finding( (string) $finding['id'], (string) ($finding['severity'] ?? 'low'), (string) ($finding['title'] ?? ''), (string) ($finding['detail'] ?? ''), (int) ($finding['count'] ?? 0), (array) ($finding['items'] ?? []), isset($finding['fix']) && $finding['fix'] !== null ? (string) $finding['fix'] : null ); } $score = null; $derive = true; if (array_key_exists('score', $result)) { $derive = $result['score'] === null ? false : true; $score = $result['score'] === null ? null : (int) $result['score']; } $normalised = novasira_scan_result( (string) ($result['area'] ?? $area), (int) ($result['checked'] ?? 0), $findings, (array) ($result['notes'] ?? []), $score ); if (!$derive) { $normalised['score'] = null; } // A scanner that was served from cache keeps the moment it actually ran. if (isset($result['ran_at']) && (int) $result['ran_at'] > 0) { $normalised['ran_at'] = (int) $result['ran_at']; } if (!empty($result['cached'])) { $normalised['cached'] = true; } return $normalised; } /** * The mean score across the areas that could be determined, or null when none * could. * * Both aggregates use this, so the audit and the plan cannot disagree about how * the same site is doing — and neither has to be loaded for the other to work * out its number. * * An area with a null score or nothing examined is skipped rather than counted * as a hundred. That is the whole point: a site must not be able to improve its * grade by breaking a check. * * @param array> $results */ function novasira_scan_mean_score(array $results): ?int { $aggregates = novasira_scan_aggregates(); $scores = []; foreach ($results as $slug => $result) { if (!is_array($result) || in_array((string) $slug, $aggregates, true)) { continue; } if (($result['score'] ?? null) === null || (int) ($result['checked'] ?? 0) === 0) { continue; } $scores[] = (int) $result['score']; } if ($scores === []) { return null; } return (int) round(array_sum($scores) / count($scores)); } /** * Worst first: severity, then how many things it affects, then the id so that * two runs over the same site put the same list in the same order. * * @param array> $findings * @return array> */ function novasira_scan_sort(array $findings): array { $rank = array_flip(novasira_scan_severities()); usort($findings, static function (array $a, array $b) use ($rank): int { $sa = $rank[(string) ($a['severity'] ?? 'low')] ?? 3; $sb = $rank[(string) ($b['severity'] ?? 'low')] ?? 3; if ($sa !== $sb) { return $sa <=> $sb; } $ca = (int) ($a['count'] ?? 0); $cb = (int) ($b['count'] ?? 0); if ($ca !== $cb) { return $cb <=> $ca; } return strcmp((string) ($a['id'] ?? ''), (string) ($b['id'] ?? '')); }); return $findings; } /** * 0-100, where 100 means nothing was found. * * A finding costs its severity's weight scaled by how much of the scanned * population it touches, with a floor so that one bad page out of five * thousand is not rounded away to nothing. Deterministic: the same site scores * the same twice. * * A critical is the exception: it costs its full weight however small a share * of the site it is, which caps the area at twenty whenever one exists. That is * the point of having the severity. Scaled by share, a site switched to * "discourage search engines" scored in the seventies, because the one setting * that makes the whole site invisible was one finding against five checks — a * number that reads as a decent site and describes one nobody can find. * * @param array> $findings */ function novasira_scan_score(array $findings, int $checked): int { if ($findings === []) { return 100; } $weights = ['critical' => 80.0, 'high' => 50.0, 'medium' => 25.0, 'low' => 10.0]; $floors = ['critical' => 25.0, 'high' => 8.0, 'medium' => 4.0, 'low' => 2.0]; $lost = 0.0; foreach ($findings as $finding) { $severity = (string) ($finding['severity'] ?? 'low'); if (!isset($weights[$severity])) { $severity = 'low'; } if ($severity === 'critical') { $lost += $weights[$severity]; continue; } $count = max(1, (int) ($finding['count'] ?? 1)); $share = $checked > 0 ? min(1.0, $count / $checked) : 1.0; $lost += max($weights[$severity] * $share, $floors[$severity]); } return (int) max(0, 100 - (int) round($lost)); } /* --------------------------------------------------------------------------- * Querying, bounded * ------------------------------------------------------------------------- */ /** * A WP_Query with the defaults a scanner always wants. * * `perm => readable` is the point of going through WP_Query at all: it is what * stops a scan listing a private post to somebody who may not read it. The * caches are turned off for the passes that do not need meta or terms, and * posts_per_page is clamped so that no caller can ask for everything. * * @param array $args */ function novasira_scan_query(array $args): WP_Query { $defaults = [ 'post_type' => novasira_scan_post_types(), 'post_status' => 'publish', 'posts_per_page' => novasira_scan_items_limit(), 'orderby' => 'ID', 'order' => 'DESC', 'perm' => 'readable', 'ignore_sticky_posts' => true, 'no_found_rows' => false, 'update_post_meta_cache' => false, 'update_post_term_cache' => false, 'lazy_load_term_meta' => false, 'suppress_filters' => false, ]; $args = array_merge($defaults, $args); $args['posts_per_page'] = max(1, min((int) $args['posts_per_page'], 5000)); return new WP_Query($args); } /** * How many posts match, without loading any of them. * * This is how most counts in this engine are honest on a large site: the * database counts the whole population, PHP never sees it. * * @param array $args */ function novasira_scan_total(array $args): int { $args['fields'] = 'ids'; $args['posts_per_page'] = 1; $query = novasira_scan_query($args); return (int) $query->found_posts; } /** * A count plus up to twenty examples of it, in one query. * * @param array $args * @return array{count: int, items: array>} */ function novasira_scan_sample(array $args): array { $args['posts_per_page'] = novasira_scan_items_limit(); $query = novasira_scan_query($args); $items = []; foreach ($query->posts as $post) { if ($post instanceof WP_Post) { $items[] = novasira_scan_item($post); } } return [ 'count' => (int) $query->found_posts, 'items' => $items, ]; } /** * One item in a finding. * * @param WP_Post|int $post * @return array */ function novasira_scan_item($post, string $label = ''): array { $id = $post instanceof WP_Post ? (int) $post->ID : (int) $post; if ($label === '') { $title = $post instanceof WP_Post ? (string) $post->post_title : (string) get_the_title($id); $label = trim($title) !== '' ? $title : sprintf( /* translators: %d: post ID */ __('Untitled (#%d)', 'novasira'), $id ); } $edit = function_exists('get_edit_post_link') ? get_edit_post_link($id, '') : null; return [ 'id' => $id, 'label' => $label, 'edit_url' => is_string($edit) ? $edit : '', ]; } /** * An item that is not a post: a term, a taxonomy, an option, a file. * * @return array */ function novasira_scan_plain_item(string $label, string $edit_url = '', int $id = 0): array { return [ 'id' => $id, 'label' => $label, 'edit_url' => $edit_url, ]; } /** * The post types a scan should look at: public, and not the media library. * * Attachments are public and would otherwise land in every content check, * where a title and a word count mean nothing. The images areas query them * directly instead. * * @return list */ function novasira_scan_post_types(): array { $types = get_post_types(['public' => true], 'names'); $types = array_values(array_diff((array) $types, ['attachment'])); return $types === [] ? ['post', 'page'] : $types; } /* --------------------------------------------------------------------------- * Reading content * ------------------------------------------------------------------------- */ /** * Post content as prose: no blocks, no shortcodes, no tags, no entities. */ function novasira_scan_text(string $content): string { $text = (string) preg_replace('//s', ' ', $content); $text = (string) preg_replace('/\[\/?[a-z0-9_\-]+[^\]]*\]/i', ' ', $text); $text = (string) preg_replace('#<(script|style)\b[^>]*>.*?#is', ' ', $text); $text = wp_strip_all_tags($text); $text = html_entity_decode($text, ENT_QUOTES | ENT_HTML5, 'UTF-8'); return trim((string) preg_replace('/\s+/u', ' ', $text)); } /** * Words of prose in a post body. * * Counted with a Unicode pattern rather than str_word_count(), which only * recognises the Latin alphabet. Given Cyrillic, Greek or Arabic text that * function counts UTF-8 bytes against a Latin-1 character list and returns a * number several times off, and a thin-content threshold built on it would * misjudge every page on a site that is not written in English. * * Scripts written without spaces between words have no word boundary to count, * so their characters are halved. That is the one approximation in this engine * and it is here because the alternative is not an exact number, it is a wrong * one. */ function novasira_scan_words(string $content): int { $text = novasira_scan_text($content); if ($text === '') { return 0; } $dense = preg_match_all('/[\p{Han}\p{Hiragana}\p{Katakana}\p{Thai}\p{Lao}\p{Khmer}\p{Myanmar}]/u', $text); if ($dense === false) { // PCRE on this build cannot do Unicode properties. Fall back to the // rough count rather than returning zero for a page full of text. return (int) str_word_count($text); } $spaced = (string) preg_replace('/[\p{Han}\p{Hiragana}\p{Katakana}\p{Thai}\p{Lao}\p{Khmer}\p{Myanmar}]+/u', ' ', $text); $words = preg_match_all("/[\p{L}\p{N}][\p{L}\p{N}\x{2019}'\-]*/u", $spaced); return (int) ($words === false ? 0 : $words) + (int) round($dense / 2); } /** * The headings in a post body, in document order. * * @return array */ function novasira_scan_headings(string $content): array { $headings = []; if (!preg_match_all('#]*>(.*?)#is', $content, $matches, PREG_SET_ORDER)) { return $headings; } foreach ($matches as $match) { $headings[] = [ 'level' => (int) $match[1], 'text' => novasira_scan_text((string) $match[2]), ]; } return $headings; } /** * Every href in a post body, split into the ones that stay on this site and * the ones that leave it. * * Protocol-relative and root-relative links both count as internal; anchors, * mailto and tel are neither. * * @return array{internal: list, external: list} */ function novasira_scan_links(string $content): array { $internal = []; $external = []; if (!preg_match_all('/]*href\s*=\s*([\'"])(.*?)\1/is', $content, $matches)) { return ['internal' => [], 'external' => []]; } $home = wp_parse_url(home_url('/')); $host = is_array($home) && isset($home['host']) ? strtolower((string) $home['host']) : ''; foreach ($matches[2] as $href) { $href = trim(html_entity_decode((string) $href, ENT_QUOTES | ENT_HTML5, 'UTF-8')); if ($href === '' || $href[0] === '#') { continue; } if (preg_match('#^(mailto|tel|javascript|data):#i', $href) === 1) { continue; } if ($href[0] === '/' && !str_starts_with($href, '//')) { $internal[] = $href; continue; } $parts = wp_parse_url($href); $target = is_array($parts) && isset($parts['host']) ? strtolower((string) $parts['host']) : ''; if ($target === '' || ($host !== '' && $target === $host)) { $internal[] = $href; continue; } $external[] = $href; } return [ 'internal' => array_values(array_unique($internal)), 'external' => array_values(array_unique($external)), ]; } /** * The tags in a post body and whether each carries alt text. * * Content images are a different population from the media library: a page can * embed an image hosted elsewhere, and the library holds images no page uses. * * @return array */ function novasira_scan_content_images(string $content): array { $images = []; if (!preg_match_all('/]*>/is', $content, $matches)) { return $images; } foreach ($matches[0] as $tag) { $src = preg_match('/\bsrc\s*=\s*([\'"])(.*?)\1/is', (string) $tag, $m) === 1 ? (string) $m[2] : ''; $alt = preg_match('/\balt\s*=\s*([\'"])(.*?)\1/is', (string) $tag, $m) === 1 ? (string) $m[2] : null; $images[] = ['src' => $src, 'alt' => $alt]; } return $images; } /* --------------------------------------------------------------------------- * Detecting what else is installed * ------------------------------------------------------------------------- */ /** * Which SEO plugin is storing per-post titles, descriptions and robots rules, * and under which meta keys. * * Read through the keys each plugin writes rather than through its API: the * keys are stable across major versions and are what the data is actually in, * and a scanner that boots a plugin's classes to ask it a question is a * scanner that breaks when that plugin changes its mind. * * @return array */ function novasira_scan_seo(): array { static $seo = null; if ($seo !== null) { return $seo; } $candidates = [ 'yoast' => [ 'name' => 'Yoast SEO', 'present' => static fn(): bool => defined('WPSEO_VERSION') || class_exists('WPSEO_Options'), 'title' => '_yoast_wpseo_title', 'description' => '_yoast_wpseo_metadesc', 'canonical' => '_yoast_wpseo_canonical', 'noindex' => '_yoast_wpseo_meta-robots-noindex', 'noindex_value' => '1', 'focus' => '_yoast_wpseo_focuskw', 'templates' => true, ], 'rankmath' => [ 'name' => 'Rank Math', 'present' => static fn(): bool => defined('RANK_MATH_VERSION') || class_exists('RankMath\\RankMath'), 'title' => 'rank_math_title', 'description' => 'rank_math_description', 'canonical' => 'rank_math_canonical_url', 'noindex' => 'rank_math_robots', 'noindex_value' => 'noindex', 'focus' => 'rank_math_focus_keyword', 'templates' => true, ], 'seopress' => [ 'name' => 'SEOPress', 'present' => static fn(): bool => defined('SEOPRESS_VERSION') || function_exists('seopress_get_service'), 'title' => '_seopress_titles_title', 'description' => '_seopress_titles_desc', 'canonical' => '_seopress_robots_canonical', 'noindex' => '_seopress_robots_index', 'noindex_value' => 'yes', 'focus' => '_seopress_analysis_target_kw', 'templates' => true, ], 'aioseo' => [ 'name' => 'All in One SEO', 'present' => static fn(): bool => defined('AIOSEO_VERSION') || function_exists('aioseo'), 'title' => '_aioseo_title', 'description' => '_aioseo_description', 'canonical' => '_aioseo_canonical_url', 'noindex' => '', 'noindex_value' => '', 'focus' => '', 'templates' => true, ], 'slim' => [ 'name' => 'Slim SEO', 'present' => static fn(): bool => defined('SLIM_SEO_VER') || class_exists('SlimSEO\\Helper'), 'title' => '', 'description' => '', 'canonical' => '', 'noindex' => '', 'noindex_value' => '', 'focus' => '', 'templates' => false, ], ]; foreach ($candidates as $slug => $plugin) { $present = $plugin['present']; unset($plugin['present']); if (is_callable($present) && $present() === true) { $seo = ['plugin' => $slug] + $plugin; return $seo; } } $seo = [ 'plugin' => '', 'name' => '', 'title' => '', 'description' => '', 'canonical' => '', 'noindex' => '', 'noindex_value' => '', 'focus' => '', 'templates' => false, ]; return $seo; } /** The one note every area repeats when no SEO plugin is storing anything. */ function novasira_scan_note_no_seo_plugin(): string { return __('No SEO plugin was detected, so this site stores no per-page title or meta description of its own. Whatever the theme emits at render time is not in the database and Novasira cannot see it.', 'novasira'); } /** * Which multilingual plugin is in play, and whether translations exist as * separate posts (which can be paired and checked) or are generated at render * time (which cannot). * * @return array */ function novasira_scan_multilingual(): array { if (function_exists('pll_languages_list')) { return ['plugin' => 'polylang', 'name' => 'Polylang', 'posts' => true]; } if (defined('ICL_SITEPRESS_VERSION') || class_exists('SitePress')) { return ['plugin' => 'wpml', 'name' => 'WPML', 'posts' => true]; } if (class_exists('MultilingualPress\\MultilingualPress')) { return ['plugin' => 'multilingualpress', 'name' => 'MultilingualPress', 'posts' => true]; } if (class_exists('TRP_Translate_Press')) { return ['plugin' => 'translatepress', 'name' => 'TranslatePress', 'posts' => false]; } if (defined('WEGLOT_VERSION') || class_exists('WeglotWP\\Services\\Option_Service_Weglot')) { return ['plugin' => 'weglot', 'name' => 'Weglot', 'posts' => false]; } if (class_exists('WPGlobus')) { return ['plugin' => 'wpglobus', 'name' => 'WPGlobus', 'posts' => false]; } return ['plugin' => '', 'name' => '', 'posts' => false]; } /** * Which shop is installed, if any. * * @return array */ function novasira_scan_shop(): array { if (class_exists('WooCommerce')) { return ['plugin' => 'woocommerce', 'name' => 'WooCommerce', 'post_type' => 'product']; } if (class_exists('Easy_Digital_Downloads') || defined('EDD_VERSION')) { return ['plugin' => 'edd', 'name' => 'Easy Digital Downloads', 'post_type' => 'download']; } if (class_exists('SureCart\\Models\\Product')) { return ['plugin' => 'surecart', 'name' => 'SureCart', 'post_type' => 'sc_product']; } return ['plugin' => '', 'name' => '', 'post_type' => '']; } /** * Page builders that keep a page's text somewhere other than post_content. * * @return array */ function novasira_scan_builders(): array { return [ 'elementor' => ['name' => 'Elementor', 'meta' => '_elementor_data'], 'bricks' => ['name' => 'Bricks', 'meta' => '_bricks_page_content_2'], 'oxygen' => ['name' => 'Oxygen', 'meta' => 'ct_builder_shortcodes'], 'beaver' => ['name' => 'Beaver Builder', 'meta' => '_fl_builder_data'], 'divi' => ['name' => 'Divi', 'meta' => '_et_pb_use_builder'], 'breakdance' => ['name' => 'Breakdance', 'meta' => '_breakdance_data'], 'etch' => ['name' => 'Etch', 'meta' => '_etch_content'], ]; } /** * Read a file at the web root, if it is really there and really a file. * * robots.txt and llms.txt are the two facts about this site that live on disk * rather than in the database. Reading them is a local file read, not a * request: this plugin makes no network requests of any kind. */ function novasira_scan_root_file(string $name): ?string { $name = basename($name); $root = defined('ABSPATH') ? (string) ABSPATH : ''; if ($root === '') { return null; } $path = rtrim(str_replace('\\', '/', $root), '/') . '/' . $name; if (!is_file($path) || !is_readable($path)) { return null; } $size = (int) filesize($path); if ($size <= 0 || $size > 262144) { return null; } $body = file_get_contents($path); return is_string($body) ? $body : null; } /** An option read as a flat array, whatever shape it was stored in. @return array */ function novasira_scan_option_array(string $option): array { $value = get_option($option); if (is_array($value)) { return $value; } if (is_object($value)) { return (array) $value; } return []; } /** * Trim a string for use as a label without cutting a word in half. */ function novasira_scan_label(string $text, int $length = 70): string { $text = trim((string) preg_replace('/\s+/u', ' ', $text)); if ($text === '') { return ''; } if (function_exists('wp_html_excerpt')) { return (string) wp_html_excerpt($text, $length, '…'); } return mb_strlen($text) > $length ? mb_substr($text, 0, $length) . '…' : $text; } /** Characters, counted the way a search engine counts a title. */ function novasira_scan_length(string $text): int { return (int) mb_strlen(trim($text)); } /** * Does this stored title or description contain a plugin's template variables? * * Yoast stores `%%title%% %%page%% %%sep%% %%sitename%%` and Rank Math stores * `%title% %sep% %sitename%`; neither has a length until it renders. Measuring * one as if it were literal text is exactly the kind of invented number this * engine refuses to report, so these are counted separately and named in notes. */ function novasira_scan_has_template(string $value): bool { return preg_match('/%%?[a-z0-9_\-]+%%?/i', $value) === 1; }