string, 'type' => string, 'label' => string ] */ public static function get_scannable_urls(): array { $urls = []; $seen = []; $add = static function ( string $url, string $type, string $label ) use ( &$urls, &$seen ): void { $url = trailingslashit( esc_url_raw( $url ) ); $label = html_entity_decode( $label, ENT_QUOTES, 'UTF-8' ); if ( empty( $url ) || isset( $seen[ $url ] ) ) return; $seen[ $url ] = true; $urls[] = compact( 'url', 'type', 'label' ); }; // Homepage $add( home_url( '/' ), 'home', 'Homepage' ); // Blog index (if different from homepage) $blog_page_id = (int) get_option( 'page_for_posts' ); if ( $blog_page_id ) { $add( get_permalink( $blog_page_id ), 'blog', 'Blog Index' ); } // Pages $pages = get_posts( [ 'post_type' => 'page', 'posts_per_page' => -1, 'post_status' => 'publish', 'orderby' => 'menu_order', 'order' => 'ASC', ] ); foreach ( $pages as $page ) { $add( (string) get_permalink( $page ), 'page', get_the_title( $page ) ); } // Posts $posts = get_posts( [ 'post_type' => 'post', 'posts_per_page' => -1, 'post_status' => 'publish', 'orderby' => 'date', 'order' => 'DESC', ] ); foreach ( $posts as $post ) { $add( (string) get_permalink( $post ), 'post', get_the_title( $post ) ); } // Category archives — unlimited for both plans. $categories = get_categories( [ 'hide_empty' => true, 'number' => 0 ] ); foreach ( $categories as $cat ) { $url = get_category_link( $cat->term_id ); if ( $url ) $add( $url, 'category', 'Category: ' . $cat->name ); } // Custom post types — Pro only. Eligible types are public, queryable, and // have an Edit screen in wp-admin (mirrors what an admin can actually manage). if ( AccessMate_Licensing::is_premium() ) { foreach ( self::get_eligible_cpts() as $cpt ) { if ( $cpt->has_archive ) { $archive_url = get_post_type_archive_link( $cpt->name ); if ( $archive_url ) { $add( $archive_url, 'archive', $cpt->label . ' Archive' ); } } $cpt_posts = get_posts( [ 'post_type' => $cpt->name, 'posts_per_page' => -1, 'post_status' => 'publish', 'orderby' => 'date', 'order' => 'DESC', ] ); $cpt_type_label = $cpt->labels->singular_name ?? $cpt->label; foreach ( $cpt_posts as $cpt_post ) { $add( (string) get_permalink( $cpt_post ), $cpt_type_label, get_the_title( $cpt_post ) ); } } } return $urls; } /** * Public, queryable, admin-editable custom post types — mirrors what an * admin can actually manage in wp-admin. Shared by URL discovery and the * REST API's Free-tier "CPTs are Pro-only" restriction, so both agree on * exactly which post types count as a CPT. * * @return WP_Post_Type[] */ public static function get_eligible_cpts(): array { $cpts = get_post_types( [ '_builtin' => false, 'public' => true, 'publicly_queryable' => true, 'show_ui' => true, ], 'objects' ); return array_filter( $cpts, static fn( $cpt ) => empty( $cpt->exclude_from_search ) ); } /** * Finds the human-readable label discovery would have assigned to $url * (e.g. "Homepage", "Blog Index", "Category: News"). Used as a fallback * when a scan has no post to look up a title from. */ private static function lookup_discovery_label( string $url ): string { $normalised = trailingslashit( $url ); foreach ( self::get_scannable_urls() as $entry ) { // Primary: exact match after normalisation. if ( $entry['url'] === $normalised ) { return $entry['label']; } // Secondary: compare without trailing slash (handles ?cat=1 vs ?cat=1/). if ( rtrim( $entry['url'], '/' ) === rtrim( $normalised, '/' ) ) { return $entry['label']; } } // Tertiary: plain-permalink archives (?cat=N, ?tag=slug, ?author=N, etc.) // where the URL-match loop above may fail due to trailingslashit() on query strings. $query = wp_parse_url( $url, PHP_URL_QUERY ) ?? ''; $vars = []; parse_str( $query, $vars ); if ( ! empty( $vars['cat'] ) ) { $term = get_category( (int) $vars['cat'] ); if ( $term && ! is_wp_error( $term ) ) { return 'Category: ' . html_entity_decode( $term->name, ENT_QUOTES, 'UTF-8' ); } } if ( ! empty( $vars['tag'] ) ) { $term = get_term_by( 'slug', sanitize_key( $vars['tag'] ), 'post_tag' ); if ( $term && ! is_wp_error( $term ) ) { return 'Tag: ' . html_entity_decode( $term->name, ENT_QUOTES, 'UTF-8' ); } } if ( ! empty( $vars['author'] ) ) { $user = get_user_by( 'id', (int) $vars['author'] ); if ( $user ) { return 'Author: ' . $user->display_name; } } return ''; } // ── Storage ─────────────────────────────────────────────── /** * Stores a browser-computed axe-core scan result for $url. * Routes to post meta or a dedicated non-autoloaded option depending on * whether $url maps to a known post/page/CPT. */ public static function store_axe_scan( string $url, array $result ): void { $url = trailingslashit( $url ); // Resolve page title: prefer get_the_title() for posts/pages since it's just the // title itself, unlike $result['page_title'] (browser document.title), which // themes usually render as "Page Title – Site Name". Fall back to page_title, // then to the discovery label (e.g. "Homepage", "Blog Index"), for non-post URLs. $post_id = url_to_postid( $url ); if ( $post_id > 0 ) { $title = html_entity_decode( get_the_title( $post_id ), ENT_QUOTES, 'UTF-8' ); } else { $title = sanitize_text_field( $result['page_title'] ?? '' ); if ( '' === $title ) { $title = self::lookup_discovery_label( $url ); } } $stored = [ 'url' => $url, 'title' => $title, 'score' => $result['score'] ?? 0, 'grade' => $result['grade'] ?? 'F', 'passed' => $result['passed'] ?? 0, 'failed' => $result['failed'] ?? 0, 'issues' => $result['issues'] ?? [], 'rulesets' => $result['rulesets'] ?? [], 'passed_checks' => $result['passed_checks'] ?? [], 'scanned_at' => $result['scanned_at'] ?? gmdate( 'c' ), 'post_type' => $result['post_type'] ?? null, ]; $post_id = url_to_postid( $url ); if ( $post_id > 0 ) { // Post / page / CPT — use post meta (indexed, auto-cleaned with post). update_post_meta( $post_id, self::META_KEY, $stored ); } else { // Non-post URL (homepage, archive, category …). $hash = md5( $url ); update_option( self::OPT_PREFIX . $hash, $stored, false ); // autoload = false // Register the hash in the index so get_page_scans() can find it. $index = get_option( self::OPT_INDEX, [] ); if ( ! in_array( $hash, $index, true ) ) { $index[] = $hash; update_option( self::OPT_INDEX, $index, false ); } } } /** * Returns the full map of per-URL scan summaries, keyed by URL. * Merges post-meta scans and non-post option scans efficiently. * * @return array */ public static function get_page_scans(): array { self::maybe_migrate_legacy(); $scans = []; // ── 1. Post-meta scans (single direct DB query, no full table scan) ── // Joined against wp_posts and filtered to 'publish' so a trashed or // deleted post's stale scan stops showing once it's no longer live — // WordPress only cascade-deletes postmeta on a hard delete, not on Trash. global $wpdb; $rows = $wpdb->get_results( // phpcs:ignore WordPress.DB.DirectDatabaseQuery $wpdb->prepare( "SELECT pm.meta_value FROM {$wpdb->postmeta} pm INNER JOIN {$wpdb->posts} p ON p.ID = pm.post_id WHERE pm.meta_key = %s AND p.post_status = 'publish'", self::META_KEY ) ); foreach ( $rows as $row ) { $data = maybe_unserialize( $row->meta_value ); if ( is_array( $data ) && isset( $data['url'] ) ) { $scans[ $data['url'] ] = $data; } } // ── 2. Non-post scans (index → individual non-autoloaded options) ──── // This branch is meant for genuinely non-post URLs (homepage, archives), // but a scan can also land here when url_to_postid() failed to resolve a // real page/post at scan time. Unlike branch 1, these options carry no // post_id to join against, so re-resolve the URL here and drop it if it // now belongs to a trashed/deleted post — otherwise it lingers forever. // // A post can also be hard-deleted outright (not just trashed), in which // case url_to_postid() returns 0 forever and the trashed-post check above // never fires. Treat "resolves to no post AND matches no currently-valid // non-post discovery URL" as stale too, and prune it permanently so it // doesn't keep inflating the scanned count. $index = get_option( self::OPT_INDEX, [] ); $stale_hashes = []; foreach ( $index as $hash ) { $data = get_option( self::OPT_PREFIX . $hash ); if ( ! is_array( $data ) || ! isset( $data['url'] ) ) { $stale_hashes[] = $hash; // option itself vanished; drop the orphaned index entry. continue; } $post_id = url_to_postid( $data['url'] ); if ( $post_id > 0 ) { if ( get_post_status( $post_id ) !== 'publish' ) { continue; // Trashed/unpublished — hide until republished, but keep the record. } } elseif ( '' === self::lookup_discovery_label( $data['url'] ) ) { delete_option( self::OPT_PREFIX . $hash ); $stale_hashes[] = $hash; continue; } $scans[ $data['url'] ] = $data; } if ( ! empty( $stale_hashes ) ) { update_option( self::OPT_INDEX, array_values( array_diff( $index, $stale_hashes ) ), false ); } // ── 3. Back-fill title for existing scans stored before this field was added, // and decode entities in titles stored before they were decoded on write ── foreach ( $scans as $url => &$data ) { if ( empty( $data['title'] ) ) { $post_id = url_to_postid( $url ); if ( $post_id > 0 ) { $data['title'] = get_the_title( $post_id ); } else { $data['title'] = self::lookup_discovery_label( $url ); } } $data['title'] = html_entity_decode( (string) ( $data['title'] ?? '' ), ENT_QUOTES, 'UTF-8' ); } unset( $data ); return $scans; } /** * PHP-scanner path (legacy, non-axe): runs AccessMate_Scanner on $url and * stores a summary. Kept for compatibility but axe-core path is preferred. */ public static function scan_page( string $url ): array { $result = AccessMate_Scanner::run( $url ); self::store_axe_scan( $url, [ 'score' => $result['score'] ?? 0, 'grade' => $result['grade'] ?? 'F', 'failed' => $result['failed'] ?? 0, 'issues' => $result['issues'] ?? [], 'scanned_at' => gmdate( 'c' ), ] ); return $result; } // ── Migration ───────────────────────────────────────────── /** * One-time migration: reads the old single-blob `accessmate_page_scans` option * (if present) and redistributes each entry into the new storage layout. * Deletes the legacy option once done. */ private static function maybe_migrate_legacy(): void { $legacy = get_option( self::LEGACY_KEY ); if ( ! is_array( $legacy ) || empty( $legacy ) ) { return; // Already migrated or never existed. } foreach ( $legacy as $url => $data ) { if ( ! is_array( $data ) || empty( $url ) ) continue; self::store_axe_scan( (string) $url, $data ); } delete_option( self::LEGACY_KEY ); } }