Ordered rule array. */ public static function build( string $from_url, string $to_url ): array { $from = rtrim( $from_url, '/' ); $to = rtrim( $to_url, '/' ); if ( '' === $from || '' === $to || $from === $to ) { return []; } $candidates = self::variants( $from ); $rules = []; $bare_rules = []; $seen = []; $to_bare = self::strip_scheme( $to ); foreach ( $candidates as $candidate ) { // Schemed rules first. A scheme-less needle matches inside a full URL, so // running it first would rewrite the host and starve the scheme-flip rule // of its input. self::push_rule( $rules, $seen, $candidate, $to ); // A protocol-relative reference has no scheme, so no needle anchored on // :// matches it. Both sides are stripped so it stays protocol-relative. $from_bare = self::strip_scheme( $candidate ); if ( $from_bare !== $candidate ) { self::push_rule( $bare_rules, $seen, $from_bare, $to_bare ); } } return self::order_non_cascading( array_merge( $rules, $bare_rules ) ); } /** * Append a rule when it is not a no-op and not already generated. * * @param array $rules Target list. * @param array $seen Finds already used. */ private static function push_rule( array &$rules, array &$seen, string $find, string $replace ): void { if ( '' === $find || $find === $replace || isset( $seen[ $find ] ) ) { return; } $seen[ $find ] = true; $rules[] = [ 'find' => $find, 'replace' => $replace, 'generated' => true ]; } /** * Drop the scheme from a URL, leaving the `//host/path` form. * * @return string Protocol-relative form, or the input when it has no scheme. */ private static function strip_scheme( string $url ): string { $stripped = (string) preg_replace( '#^[a-z][a-z0-9+.\-]*://#i', '//', $url ); return '' === $stripped ? $url : $stripped; } /** * Order generated rules so no rule's find re-matches an earlier rule's * replacement. Otherwise https://example.com to http://example.com/stage would * double to /stage/stage/, since the protocol-flip find is a prefix of the * canonical rule's output. A may run before B only if A's find never appears in * B's replacement. User-defined rules keep their order downstream. * * @param array $rules Rule list from this builder. * * @return array */ private static function order_non_cascading( array $rules ): array { if ( count( $rules ) < 2 ) { return $rules; } // depends_on[i] = indices of rules that must run before i so that i's // replacement cannot be re-matched by a later rule. $depends_on = array_fill( 0, count( $rules ), [] ); foreach ( $rules as $i => $rule_i ) { foreach ( $rules as $j => $rule_j ) { if ( $i === $j ) { continue; } // If rule i's output contains rule j's find, j must run first. if ( '' !== $rule_j['find'] && false !== strpos( $rule_i['replace'], $rule_j['find'] ) ) { $depends_on[ $i ][] = $j; } } } $ordered = []; $placed = array_fill( 0, count( $rules ), false ); while ( count( $ordered ) < count( $rules ) ) { // Pick the earliest unplaced rule whose dependencies are satisfied. for ( $i = 0; $i < count( $rules ); $i++ ) { if ( $placed[ $i ] ) { continue; } $ready = true; foreach ( $depends_on[ $i ] as $dep ) { if ( ! $placed[ $dep ] ) { $ready = false; break; } } if ( $ready ) { $ordered[] = $rules[ $i ]; $placed[ $i ] = true; continue 2; } } // Dependency cycle (not expected for URL variants): emit the remaining // rules as-is to break out of the loop. foreach ( $rules as $i => $rule ) { if ( ! $placed[ $i ] ) { $ordered[] = $rule; $placed[ $i ] = true; } } } return $ordered; } // ------------------------------------------------------------------------- // Private helpers // ------------------------------------------------------------------------- /** * Generate URL variants for a base URL. * * @param string $url Base URL (no trailing slash). * * @return string[] Ordered list of variants, base URL first. */ private static function variants( string $url ): array { $variants = [ $url ]; // 1. Protocol flip: http ↔ https. $proto_flipped = self::flip_protocol( $url ); if ( $proto_flipped !== $url ) { $variants[] = $proto_flipped; } // 2. www toggle. $www_toggled = self::toggle_www( $url ); if ( null !== $www_toggled && $www_toggled !== $url ) { $variants[] = $www_toggled; // Also add the protocol-flipped form of the www variant. $toggled_proto = self::flip_protocol( $www_toggled ); if ( $toggled_proto !== $www_toggled ) { $variants[] = $toggled_proto; } } return $variants; } /** * Flip http and https in a URL. * * @return string Flipped URL (same string if no protocol prefix found). */ private static function flip_protocol( string $url ): string { if ( str_starts_with( $url, 'https://' ) ) { return 'http://' . substr( $url, 8 ); } if ( str_starts_with( $url, 'http://' ) ) { return 'https://' . substr( $url, 7 ); } return $url; } /** * Toggle the www prefix for a URL. * * @return string|null Toggled URL, or null if the host has a subdomain other * than www (left alone to avoid false matches). */ private static function toggle_www( string $url ): ?string { // parse_url returns the host without the port, so no port handling is needed. $host = (string) ( parse_url( $url, PHP_URL_HOST ) ?? '' ); if ( '' === $host ) { return null; } // Rewrite only the first scheme+host occurrence, the authority. Anything // further right is path or query data. $swap = static fn( string $new_host ): string => (string) preg_replace( '#' . preg_quote( '://' . $host, '#' ) . '#', '://' . $new_host, $url, 1 ); if ( str_starts_with( $host, 'www.' ) ) { return $swap( substr( $host, 4 ) ); } // Only add www. for apex domains (exactly one dot). if ( 1 === substr_count( $host, '.' ) ) { return $swap( 'www.' . $host ); } // Subdomains other than www are left alone. return null; } // phpcs:enable WordPress.WP.AlternativeFunctions.parse_url_parse_url }