` islands of a mixed HTML template, * which strands the `translators:` hint away from its gettext call and drops * it from the POT. Class files are not affected. */ public static function thinContentNotice($count) { /* translators: %s: number of pages. */ $notice = _n('%s page has too little content to generate a description from at all.', '%s pages have too little content to generate a description from at all.', $count, 'crownify'); return sprintf($notice, number_format_i18n($count)); } public static function pagesLabel($count) { /* translators: %s: number of pages. */ $label = _n('%s page', '%s pages', $count, 'crownify'); return sprintf($label, number_format_i18n($count)); } public static function flush() { delete_transient(self::CACHE_KEY); } public static function snapshot() { $cached = get_transient(self::CACHE_KEY); if (is_array($cached)) { return $cached; } $snapshot = self::compute(); set_transient(self::CACHE_KEY, $snapshot, self::CACHE_TTL); return $snapshot; } /** * Post types that can actually appear in search — the denominator for every * percentage below. Excludes attachments, whose pages redirect to the file. */ public static function indexablePostTypes() { $postTypes = get_post_types(['public' => true], 'names'); unset($postTypes['attachment']); foreach ($postTypes as $postType) { if ('on' === Utils::getSetting('titles[' . $postType . '][noindex]')) { unset($postTypes[$postType]); } } return array_values($postTypes); } protected static function compute() { global $wpdb; $postTypes = self::indexablePostTypes(); if (empty($postTypes)) { return self::emptySnapshot(); } $total = self::countPublished($postTypes); if (0 === $total) { return self::emptySnapshot(); } $missingDescription = self::countWithoutMeta($postTypes, '_rankify_description'); $missingTitle = self::countWithoutMeta($postTypes, '_rankify_title'); $noindexed = self::countWithMeta($postTypes, '_rankify_noindex', 'on'); // No featured image means no og:image and no ImageObject in the graph, so // shares render as a bare link and the page loses a rich-result signal. $missingImage = self::countPublished($postTypes, " AND NOT EXISTS (SELECT 1 FROM %i m WHERE m.post_id = p.ID AND m.meta_key = %s AND m.meta_value != '')", [$wpdb->postmeta, '_thumbnail_id']); // A long post title will almost certainly overrun once the template adds // the separator and site name. Characters, not pixels — pixel width cannot // be measured server-side, and this is a rough triage number, not a verdict. $longTitles = self::countPublished($postTypes, ' AND CHAR_LENGTH(p.post_title) > 60'); // Nothing to summarise from: no override, no excerpt, and no content to // derive one from means the page ships with no description at all. $noSummarySource = self::countPublished($postTypes, " AND p.post_excerpt = '' AND CHAR_LENGTH(TRIM(p.post_content)) < 50 AND NOT EXISTS (SELECT 1 FROM %i m WHERE m.post_id = p.ID AND m.meta_key = %s AND m.meta_value != '')", [$wpdb->postmeta, '_rankify_description']); return [ 'total' => $total, 'indexable' => max(0, $total - $noindexed), 'noindexed' => $noindexed, 'missingDescription' => $missingDescription, 'missingTitle' => $missingTitle, 'missingImage' => $missingImage, 'longTitles' => $longTitles, 'noSummarySource' => $noSummarySource, 'postTypes' => $postTypes, 'generatedAt' => time(), ]; } protected static function emptySnapshot() { return [ 'total' => 0, 'indexable' => 0, 'noindexed' => 0, 'missingDescription' => 0, 'missingTitle' => 0, 'missingImage' => 0, 'longTitles' => 0, 'noSummarySource' => 0, 'postTypes' => [], 'generatedAt' => time(), ]; } /** * Count published, indexable posts. * * The IN list is one %s per public post type, so the SQL cannot be a single * literal. Tables go through %i and every value is bound. */ protected static function countPublished(array $postTypes, $extraSql = '', array $extraArgs = []) { global $wpdb; $marks = implode(',', array_fill(0, count($postTypes), '%s')); $sql = 'SELECT COUNT(*) FROM %i p WHERE p.post_type IN (' . $marks . ") AND p.post_status = 'publish' AND p.post_password = ''" . $extraSql; // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- IN list length follows the public post types. Identifiers use %i and every value is bound. The count has to be live. return (int) $wpdb->get_var($wpdb->prepare($sql, array_merge([$wpdb->posts], $postTypes, $extraArgs))); } protected static function countWithoutMeta(array $postTypes, $metaKey) { global $wpdb; return self::countPublished($postTypes, " AND NOT EXISTS (SELECT 1 FROM %i m WHERE m.post_id = p.ID AND m.meta_key = %s AND m.meta_value != '')", [$wpdb->postmeta, $metaKey]); } protected static function countWithMeta(array $postTypes, $metaKey, $value) { global $wpdb; return self::countPublished($postTypes, ' AND EXISTS (SELECT 1 FROM %i m WHERE m.post_id = p.ID AND m.meta_key = %s AND m.meta_value = %s)', [$wpdb->postmeta, $metaKey, $value]); } /** * Specific posts worth fixing, newest first, with an edit link. * * The overview is only useful if it ends somewhere actionable — a count of * 47 missing descriptions is a statistic, and a list of five posts with Edit * next to them is a task. */ public static function needsAttention($limit = 5) { global $wpdb; $postTypes = self::indexablePostTypes(); if (empty($postTypes)) { return []; } $marks = implode(',', array_fill(0, count($postTypes), '%s')); $sql = 'SELECT p.ID, p.post_title, p.post_type FROM %i p WHERE p.post_type IN (' . $marks . ") AND p.post_status = 'publish' AND p.post_password = '' AND p.post_excerpt = '' AND NOT EXISTS (SELECT 1 FROM %i m WHERE m.post_id = p.ID AND m.meta_key = %s AND m.meta_value != '') ORDER BY p.post_modified DESC LIMIT %d"; // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- IN list length follows the public post types. Identifiers use %i and every value is bound. $rows = $wpdb->get_results($wpdb->prepare($sql, array_merge([$wpdb->posts], $postTypes, [$wpdb->postmeta, '_rankify_description', (int) $limit]))); $items = []; foreach ($rows as $row) { $items[] = [ 'id' => (int) $row->ID, 'title' => $row->post_title ?: __('(no title)', 'crownify'), 'type' => $row->post_type, 'editUrl' => get_edit_post_link((int) $row->ID, 'raw'), 'reason' => __('No meta description. One will be generated from the content', 'crownify'), ]; } return $items; } /** * Answer-engine readiness: the things that decide whether an AI assistant can * find, read and cite this site. */ public static function aiReadiness() { $blocked = class_exists('\DaftPlug\Rankify\Features\Robots\AiCrawlers') ? \DaftPlug\Rankify\Features\Robots\AiCrawlers::blocked() : []; $all = class_exists('\DaftPlug\Rankify\Features\Robots\AiCrawlers') ? \DaftPlug\Rankify\Features\Robots\AiCrawlers::all() : []; // Blocking a citation crawler is the one that actually costs traffic — // it removes the site from AI answers rather than just from training data. $blockedCitation = []; foreach ($blocked as $token) { if (!empty($all[$token]['citation'])) { $blockedCitation[] = $all[$token]['label']; } } return [ 'llmsTxt' => 'on' === Utils::getSetting('llmsTxt'), 'schema' => 'on' === Utils::getSetting('schemaMarkup'), 'totalCrawlers' => count($all), 'blockedCount' => count($blocked), 'blockedCitation' => $blockedCitation, ]; } }