*/ private function by_path_words( string $path ): array { global $wpdb; $segments = array_values( array_filter( explode( '/', Address::parse( $path )->path ) ) ); $last = count( $segments ) - 1; $wanted = []; foreach ( $segments as $i => $segment ) { $segment = (string) preg_replace( '/\.(html?|php|aspx?|jsp|cfm)$/i', '', $segment ); $segment = sanitize_title( str_replace( [ '_', ' ', '+' ], '-', $segment ) ); if ( '' === $segment || ctype_digit( $segment ) ) { continue; } $wanted[ $segment ] = max( $wanted[ $segment ] ?? 0, $i === $last ? 90 : 75 ); foreach ( explode( '-', $segment ) as $word ) { if ( strlen( $word ) < 3 || ctype_digit( $word ) || in_array( $word, self::STOP_WORDS, true ) ) { continue; } $wanted[ $word ] = max( $wanted[ $word ] ?? 0, $i === $last ? 70 : 65 ); } } if ( [] === $wanted ) { return []; } $slugs = array_slice( array_keys( $wanted ), 0, 40 ); $in = implode( ',', array_fill( 0, count( $slugs ), '%s' ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare, PluginCheck.Security.DirectDB.UnescapedDBParameter -- placeholders built above. $rows = (array) $wpdb->get_results( $wpdb->prepare( "SELECT ID, post_name FROM {$wpdb->posts} WHERE post_type = 'page' AND post_status = 'publish' AND post_name IN ({$in}) LIMIT 10", ...$slugs ) ); $out = []; foreach ( $rows as $row ) { $match = $this->post( (int) $row->ID, $wanted[ (string) $row->post_name ] ?? 0 ); if ( null !== $match ) { $out[ 'p' . $row->ID ] = $match; } } // Listing pages have no post of their own — a shop with no Shop page still has /shop. foreach ( get_post_types( [ 'public' => true ], 'objects' ) as $type ) { $url = $type->has_archive ? get_post_type_archive_link( $type->name ) : false; if ( ! is_string( $url ) ) { continue; } $name = basename( Address::parse( $url )->path ); if ( isset( $wanted[ $name ] ) ) { $out[ 'a' . $type->name ] = [ 'url' => $url, 'path' => Address::parse( $url )->relative(), 'title' => (string) $type->labels->name, 'type' => 'url', 'id' => 0, 'score' => $wanted[ $name ], ]; } } return $out; } /** * @return list */ public function for_path( string $path, int $limit = 3 ): array { $slug = self::slug( $path ); if ( '' === $slug ) { return []; } $found = []; foreach ( $this->by_slug( $slug ) as $post_id ) { $found[ 'p' . $post_id ] = $this->post( $post_id, 100 ); } // "/samplepage" and "/sample_page.html" for "sample-page": the same name with its // hyphens lost or swapped, which old sites and hand-typed links are full of. foreach ( $this->by_compact( $slug ) as $post_id ) { $found[ 'p' . $post_id ] ??= $this->post( $post_id, 95 ); } foreach ( $this->archives( $slug ) as $key => $archive ) { $found[ $key ] ??= $archive; } foreach ( $this->by_path_words( $path ) as $key => $match ) { if ( ! isset( $found[ $key ] ) || $found[ $key ]['score'] < $match['score'] ) { $found[ $key ] = $match; } } foreach ( $this->by_words( $slug ) as $post_id ) { if ( ! isset( $found[ 'p' . $post_id ] ) ) { $found[ 'p' . $post_id ] = $this->post( $post_id, self::similarity( $slug, (string) get_post_field( 'post_name', $post_id ) ) ); } } foreach ( $this->terms( $slug ) as $term ) { $link = get_term_link( $term ); if ( is_string( $link ) ) { $found[ 't' . $term->term_id ] = [ 'url' => $link, 'path' => Address::parse( $link )->relative(), 'title' => $term->name, 'type' => 'term', 'id' => $term->term_id, 'score' => $term->slug === $slug ? 95 : self::similarity( $slug, $term->slug ), ]; } } $found = array_filter( $found, static fn( ?array $s ): bool => null !== $s && $s['score'] >= self::THRESHOLD && Address::parse( $s['url'] )->key() !== Address::parse( $path )->key() ); usort( $found, static fn( array $a, array $b ): int => $b['score'] <=> $a['score'] ); // One suggestion per address. WooCommerce's shop page and the product archive are // the same /shop, and offering it twice reads as a bug. $seen = []; $unique = []; foreach ( $found as $suggestion ) { $key = Address::parse( $suggestion['url'] )->key(); if ( ! isset( $seen[ $key ] ) ) { $seen[ $key ] = true; $unique[] = $suggestion; } } return array_slice( $unique, 0, $limit ); } /** * The part of an old address most likely to be the page's name: the last segment, * without a file extension, and without the trailing number WordPress adds to a * duplicate slug. */ public static function slug( string $path ): string { $segments = array_values( array_filter( explode( '/', Address::parse( $path )->path ) ) ); while ( [] !== $segments ) { $last = (string) array_pop( $segments ); $last = (string) preg_replace( '/\.(html?|php|aspx?|jsp|cfm)$/i', '', $last ); $last = str_replace( [ '_', ' ', '+' ], '-', $last ); // /2019/05/ and /page/2/ are not names; keep walking back. if ( '' !== $last && ! ctype_digit( $last ) && 'page' !== $last ) { return sanitize_title( $last ); } } return ''; } /** @return list */ private function by_slug( string $slug ): array { $ids = get_posts( [ 'name' => $slug, 'post_type' => self::post_types(), 'post_status' => 'publish', 'posts_per_page' => 3, 'fields' => 'ids', 'no_found_rows' => true, 'suppress_filters' => false, ] ); // "-2" slugs: WordPress's answer to a duplicate, and usually the one that survived. if ( [] === $ids && preg_match( '/^(.+)-\d+$/', $slug, $m ) ) { return $this->by_slug( $m[1] ); } return array_map( 'intval', $ids ); } /** * Posts whose slug, with every hyphen taken out, is this one with every hyphen taken * out. One indexed-enough query: post_name is short and the type and status narrow it. * * @return list */ private function by_compact( string $slug ): array { global $wpdb; $compact = str_replace( '-', '', $slug ); if ( strlen( $compact ) < 4 ) { return []; } $types = self::post_types(); $placeholders = implode( ',', array_fill( 0, count( $types ), '%s' ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare, PluginCheck.Security.DirectDB.UnescapedDBParameter -- placeholders built above. $ids = $wpdb->get_col( $wpdb->prepare( "SELECT ID FROM {$wpdb->posts} WHERE post_status = 'publish' AND post_type IN ({$placeholders}) AND REPLACE(post_name, '-', '') = %s LIMIT 3", ...array_merge( $types, [ $compact ] ) ) ); return array_map( 'intval', (array) $ids ); } /** * The shop, the blog and every other listing page — they have no post of their own * to find by name, and "/old-store/shop.html" plainly means the shop. * * @return array */ private function archives( string $slug ): array { $out = []; $named = []; if ( function_exists( 'wc_get_page_id' ) && wc_get_page_id( 'shop' ) > 0 ) { $named[] = (int) wc_get_page_id( 'shop' ); } if ( (int) get_option( 'page_for_posts' ) > 0 ) { $named[] = (int) get_option( 'page_for_posts' ); } foreach ( $named as $page ) { $url = get_permalink( $page ); if ( is_string( $url ) && 'publish' === get_post_status( $page ) ) { $score = self::similarity( $slug, (string) get_post_field( 'post_name', $page ) ); $out[ 'p' . $page ] = $this->post( $page, $score ) ?? []; } } foreach ( get_post_types( [ 'public' => true ], 'objects' ) as $type ) { $url = $type->has_archive ? get_post_type_archive_link( $type->name ) : false; if ( ! is_string( $url ) ) { continue; } $archive = Address::parse( $url )->path; $name = (string) basename( $archive ); $out[ 'a' . $type->name ] = [ 'url' => $url, 'path' => Address::parse( $url )->relative(), 'title' => (string) $type->labels->name, 'type' => 'url', 'id' => 0, 'score' => self::similarity( $slug, $name ), ]; } return array_filter( $out ); } /** @return list */ private function by_words( string $slug ): array { $words = array_filter( explode( '-', $slug ), static fn( string $w ): bool => strlen( $w ) > 2 && ! ctype_digit( $w ) && ! in_array( $w, self::STOP_WORDS, true ) ); if ( [] === $words ) { return []; } $ids = get_posts( [ 's' => implode( ' ', array_slice( $words, 0, 6 ) ), 'post_type' => self::post_types(), 'post_status' => 'publish', 'posts_per_page' => 8, 'fields' => 'ids', 'no_found_rows' => true, ] ); return array_map( 'intval', $ids ); } /** @return list<\WP_Term> */ private function terms( string $slug ): array { $terms = get_terms( [ 'taxonomy' => array_values( get_taxonomies( [ 'public' => true ] ) ), 'slug' => $slug, 'hide_empty' => false, 'number' => 2, ] ); return is_array( $terms ) ? array_values( array_filter( $terms, static fn( $t ): bool => $t instanceof \WP_Term ) ) : []; } /** * @return array{url: string, path: string, title: string, type: string, id: int, score: int}|null */ private function post( int $post_id, int $score ): ?array { $url = get_permalink( $post_id ); if ( ! is_string( $url ) ) { return null; } return [ 'url' => $url, 'path' => Address::parse( $url )->relative(), 'title' => html_entity_decode( get_the_title( $post_id ), ENT_QUOTES, 'UTF-8' ), 'type' => 'post', 'id' => $post_id, 'score' => $score, ]; } /** * 0–100: how alike two slugs are. * * Mostly shared words, partly shared letters. Letters alone rate "qa-test-404" and * "pos-test-mug" as half the same, because both are short and share "test"; words * alone miss "brew-coffee" against "brewing-coffee". Weighted together, a real match * clears the threshold and a coincidence does not. */ public static function similarity( string $a, string $b ): int { if ( '' === $a || '' === $b ) { return 0; } if ( $a === $b ) { return 100; } if ( str_replace( '-', '', $a ) === str_replace( '-', '', $b ) ) { return 95; } similar_text( $a, $b, $chars ); $wa = array_unique( explode( '-', $a ) ); $wb = array_unique( explode( '-', $b ) ); $words = 100 * count( array_intersect( $wa, $wb ) ) / max( count( $wa ), count( $wb ) ); return (int) round( 0.4 * $chars + 0.6 * $words ); } /** @return list */ private static function post_types(): array { return ContentTypes::post_types(); } }