prefix . 'gc_paths'; } /** * Creates or upgrades the table. Cheap when current: one autoloaded option compared. * * @return void */ public static function install() { if ( get_option( self::DB_OPTION ) === self::DB_VERSION ) { return; } global $wpdb; require_once ABSPATH . 'wp-admin/includes/upgrade.php'; $table = self::table(); $charset = $wpdb->get_charset_collate(); dbDelta( "CREATE TABLE {$table} ( id bigint(20) unsigned NOT NULL AUTO_INCREMENT, path varchar(191) NOT NULL, post_id bigint(20) unsigned NOT NULL, state varchar(10) NOT NULL DEFAULT 'current', created_at datetime NOT NULL, replaced_at datetime NULL, PRIMARY KEY (id), UNIQUE KEY path (path), KEY post_id (post_id) ) {$charset};" ); update_option( self::DB_OPTION, self::DB_VERSION, true ); } /** * The one normal form of an address: decoded, NFC, lowercase, single slashes, no slash at either end. * Returns '' for the home page or anything that is not a path. * * @param string $raw A path or a full URL of this site. * @return string */ public static function normalize( $raw ) { // Not parse_url(): it turns bytes of non-latin text into "_" (seen 10-22: गो → ?_), so the path is cut by hand. $path = (string) $raw; if ( preg_match( '#^[a-z][a-z0-9+.-]*://[^/?\#]*(.*)$#is', $path, $m ) ) { $path = $m[1]; } $path = (string) strtok( $path, '?#' ); $home = (string) wp_parse_url( home_url( '/' ), PHP_URL_PATH ); if ( '/' !== $home && 0 === strpos( $path, rtrim( $home, '/' ) . '/' ) ) { $path = substr( $path, strlen( rtrim( $home, '/' ) ) ); // A site in a subfolder: paths are relative to it. } $path = rawurldecode( $path ); if ( class_exists( '\Normalizer' ) ) { $nfc = \Normalizer::normalize( $path, \Normalizer::FORM_C ); $path = false === $nfc ? $path : $nfc; } $path = function_exists( 'mb_strtolower' ) ? mb_strtolower( $path, 'UTF-8' ) : strtolower( $path ); $path = preg_replace( '#/+#', '/', $path ); return trim( (string) $path, '/' ); } /** * Every row, `path => {path, post_id, state, post_type}`, read once per request in ONE query (and kept in the object * cache when the site has one). Routing, links, breadcrumbs and refusals all read this map: links used to read post * meta (+8 queries on a plain post page, measured 10-22), and routing used a lookup query of its own on every page * (one more, measured 10-23). A post's STATUS is deliberately not in it: that changes without an address change, * so it is always read live. * * @var array|null */ private static $rows = null; /** * The current addresses, `post_id => path`, built from the same map. * * @var array|null */ private static $current = null; /** * Loads the map (once per request). * * @return array */ private static function rows() { if ( null === self::$rows ) { $key = 'rows:' . self::generation(); $rows = wp_cache_get( $key, 'growth_combinator' ); if ( ! is_array( $rows ) ) { global $wpdb; // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery -- Our own table; cached here and in the object cache. $found = (array) $wpdb->get_results( $wpdb->prepare( 'SELECT g.path, g.post_id, g.state, w.post_type FROM %i g LEFT JOIN %i w ON w.ID = g.post_id', self::table(), $wpdb->posts ) ); $rows = array(); foreach ( $found as $row ) { $rows[ (string) $row->path ] = $row; } wp_cache_set( $key, $rows, 'growth_combinator', HOUR_IN_SECONDS ); } self::$rows = $rows; self::$current = array(); foreach ( $rows as $row ) { if ( 'current' === $row->state ) { self::$current[ (int) $row->post_id ] = (string) $row->path; } } } return self::$rows; } /** * The post's current path, '' when it has none. * * @param int $post_id Post id. * @return string */ public static function of( $post_id ) { self::rows(); return isset( self::$current[ (int) $post_id ] ) ? self::$current[ (int) $post_id ] : ''; } /** * The post whose current address is this normalized path, 0 when none. * * @param string $path Normalized path. * @return int */ public static function at( $path ) { $rows = self::rows(); return isset( $rows[ (string) $path ] ) && 'current' === $rows[ (string) $path ]->state ? (int) $rows[ (string) $path ]->post_id : 0; } /** * The rows that answer for any of these normalized paths, keyed by path. * * @param string[] $paths Normalized paths. * @return array */ public static function lookup( array $paths ) { return array_intersect_key( self::rows(), array_flip( array_filter( array_map( 'strval', $paths ), 'strlen' ) ) ); } /** * Every row for one post, newest first. * * @param int $post_id Post id. * @return object[] */ public static function history( $post_id ) { global $wpdb; // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Our own table, read on admin and REST requests only. return (array) $wpdb->get_results( $wpdb->prepare( 'SELECT path, state, created_at, replaced_at FROM %i WHERE post_id = %d ORDER BY id DESC', self::table(), (int) $post_id ) ); } /** * The addresses of published pages with how many old ones each has, newest first, for the status page. * A trashed or unpublished post keeps its rows (restoring it restores its address) but is not live, so not listed. * * @param int $limit At most this many. * @return object[] Rows of post_id, path, moved. */ public static function listing( $limit ) { global $wpdb; // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Our own table; the wp-admin status page only. return (array) $wpdb->get_results( $wpdb->prepare( "SELECT c.post_id, c.path, (SELECT COUNT(*) FROM %i p WHERE p.post_id = c.post_id AND p.state = 'previous') AS moved FROM %i c INNER JOIN %i w ON w.ID = c.post_id AND w.post_status = 'publish' WHERE c.state = 'current' ORDER BY c.id DESC LIMIT %d", self::table(), self::table(), $wpdb->posts, (int) $limit ) ); } /** * How many published pages live at a Studio address, and how many old addresses redirect to them. * * @return array{live: int, moved: int} */ public static function counts() { global $wpdb; // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Our own table; status page and status route only. $row = $wpdb->get_row( $wpdb->prepare( "SELECT SUM(c.state = 'current') AS live, SUM(c.state = 'previous') AS moved FROM %i c INNER JOIN %i w ON w.ID = c.post_id AND w.post_status = 'publish'", self::table(), $wpdb->posts ) ); return array( 'live' => (int) ( $row ? $row->live : 0 ), 'moved' => (int) ( $row ? $row->moved : 0 ), ); } /** * Why `$path` cannot be this post's address, or null when it can. Refuses; never invents another path. * * @param int $post_id The post. * @param string $path Normalized path. * @return array{code: string, message: string}|null */ public static function refusal( $post_id, $path ) { $post_id = (int) $post_id; if ( '' === $path || strlen( $path ) > self::MAX_LENGTH ) { return self::why( 'invalid', __( 'An address needs at least one part and at most 191 characters.', 'growth-combinator' ) ); } $segments = explode( '/', $path ); foreach ( $segments as $segment ) { if ( ! preg_match( '/^[\p{L}\p{M}\p{N}_-]+$/u', $segment ) ) { // \p{M}: vowel signs and other marks (ो, ी in Hindi). /* translators: %s: one part of the address. */ return self::why( 'invalid', sprintf( __( '"%s" may only hold letters, digits, - and _.', 'growth-combinator' ), $segment ) ); } if ( preg_match( '/^\d+$/', $segment ) ) { /* translators: %s: one part of the address. */ return self::why( 'invalid', sprintf( __( '"%s" is only digits; WordPress reads digits as a page number or a date.', 'growth-combinator' ), $segment ) ); } } $first = $segments[0]; $last = end( $segments ); $bases = array_filter( array( trim( (string) get_option( 'category_base' ), '/' ), trim( (string) get_option( 'tag_base' ), '/' ) ) ); if ( in_array( $first, array_merge( self::RESERVED_FIRST, array( 'category', 'tag' ), $bases ), true ) || 0 === strpos( $first, 'wp-' ) || 0 === strpos( $first, 'sitemap' ) ) { /* translators: %s: first part of the address. */ return self::why( 'reserved_prefix', sprintf( __( 'Addresses starting with "%s/" belong to WordPress itself.', 'growth-combinator' ), $first ) ); } if ( in_array( $last, array_merge( self::RESERVED_LAST, self::endpoints() ), true ) || preg_match( '/^comment-page-/', $last ) ) { /* translators: %s: last part of the address. */ return self::why( 'reserved_suffix', sprintf( __( 'An address cannot end in "%s": WordPress adds that to a post\'s address.', 'growth-combinator' ), $last ) ); } $page = get_page_by_path( $path, OBJECT, 'page' ); if ( $page && (int) $page->ID !== $post_id ) { /* translators: %s: the page's title. */ return self::why( 'page_exists', sprintf( __( 'The page "%s" already lives at this address.', 'growth-combinator' ), get_the_title( $page ) ) ); } if ( 1 === count( $segments ) ) { $native = array_values( array_diff( get_posts( array( 'name' => $path, 'post_type' => 'post', 'post_status' => array( 'publish', 'future', 'private' ), 'posts_per_page' => 2, 'fields' => 'ids', ) ), array( $post_id ) ) ); if ( $native ) { /* translators: %s: the other post's title. */ return self::why( 'slug_clash', sprintf( __( 'The post "%s" already lives at this address.', 'growth-combinator' ), get_the_title( $native[0] ) ) ); } } if ( get_category_by_path( $path, true ) ) { return self::why( 'term_exists', __( 'A category already lives at this address.', 'growth-combinator' ) ); } $row = self::lookup( array( $path ) ); if ( isset( $row[ $path ] ) && (int) $row[ $path ]->post_id !== $post_id ) { $other = get_the_title( (int) $row[ $path ]->post_id ); return 'current' === $row[ $path ]->state /* translators: %s: the other post's title. */ ? self::why( 'path_taken', sprintf( __( '"%s" already lives at this address.', 'growth-combinator' ), $other ) ) /* translators: %s: the other post's title. */ : self::why( 'path_taken', sprintf( __( 'This was the address of "%s" and now sends readers to it. Taking it would break that redirect.', 'growth-combinator' ), $other ) ); } if ( self::rank_math_redirects( $path ) ) { return self::why( 'redirect_exists', __( 'A Rank Math redirection already starts at this address; it would win over the post. Remove it in Rank Math first.', 'growth-combinator' ) ); } return null; } /** * Makes `$path` the post's address. The old address, if any, becomes a permanent redirect to the new one. * The table claims the address first (its unique index settles a race between two saves); only then do the old row * and the meta change, so a lost race changes nothing. * * @param int $post_id The post. * @param string $path Normalized path, already checked by refusal(). * @return array{code: string, message: string}|null A refusal when another save took the address meanwhile. */ public static function assign( $post_id, $path ) { global $wpdb; $post_id = (int) $post_id; $table = self::table(); if ( self::of( $post_id ) === $path ) { return null; } $old_url = get_permalink( $post_id ); $now = current_time( 'mysql', true ); // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Our own table; the lookup cache is retired in changed(). $mine = $wpdb->get_var( $wpdb->prepare( 'SELECT id FROM %i WHERE path = %s AND post_id = %d', $table, $path, $post_id ) ); if ( $mine ) { $wpdb->update( $table, array( 'state' => 'current', 'replaced_at' => null ), array( 'id' => (int) $mine ) ); // Back to an address it had before. } elseif ( false === $wpdb->insert( $table, array( 'path' => $path, 'post_id' => $post_id, 'state' => 'current', 'created_at' => $now ) ) ) { return self::why( 'path_taken', __( 'Another page took this address a moment ago.', 'growth-combinator' ) ); } $wpdb->query( $wpdb->prepare( "UPDATE %i SET state = 'previous', replaced_at = %s WHERE post_id = %d AND state = 'current' AND path <> %s", $table, $now, $post_id, $path ) ); // phpcs:enable update_post_meta( $post_id, self::META, $path ); self::changed( $post_id, $old_url ); return null; } /** * Takes the post's address away: it lives at WordPress's own address again, and the old one redirects there. * * @param int $post_id The post. * @return void */ public static function clear( $post_id ) { global $wpdb; $post_id = (int) $post_id; if ( '' === self::of( $post_id ) ) { return; } $old_url = get_permalink( $post_id ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Our own table; the lookup cache is retired in changed(). $wpdb->query( $wpdb->prepare( "UPDATE %i SET state = 'previous', replaced_at = %s WHERE post_id = %d AND state = 'current'", self::table(), current_time( 'mysql', true ), $post_id ) ); delete_post_meta( $post_id, self::META ); self::changed( $post_id, $old_url ); } /** * After an address changes, every cache that remembers the old one forgets it (plan D190 §11.2 decision 6). * * @param int $post_id The post. * @param string|false $old_url Its link before the change. * @return void */ private static function changed( $post_id, $old_url ) { wp_cache_set( 'generation', sprintf( '%.6F', microtime( true ) ), 'growth_combinator' ); // Retires every cached lookup; works on any object cache. self::$rows = null; self::$current = null; clean_post_cache( $post_id ); // Also makes WP Rocket purge the new address (rocket_clean_post). $new_url = get_permalink( $post_id ); if ( function_exists( 'rocket_clean_files' ) ) { rocket_clean_files( array_values( array_filter( array( $old_url, $new_url ) ) ) ); // WP Rocket keeps the old page otherwise, and our 301 never runs. } if ( is_callable( array( '\RankMath\Sitemap\Cache', 'invalidate_storage' ) ) ) { \RankMath\Sitemap\Cache::invalidate_storage( get_post_type( $post_id ) ); } if ( function_exists( 'YoastSEO' ) && class_exists( '\Yoast\WP\SEO\Repositories\Indexable_Repository' ) ) { try { YoastSEO()->classes->get( \Yoast\WP\SEO\Repositories\Indexable_Repository::class )->reset_permalink( 'post', get_post_type( $post_id ), $post_id ); } catch ( \Throwable $e ) { unset( $e ); // Yoast then refreshes on the post's next save; the address itself already works. } } do_action( 'growth_combinator_path_changed', $post_id, $old_url, $new_url ); } /** * The lookup cache's generation; a new one retires every cached lookup at once. A moment, not a counter (WordPress's * own `last_changed` pattern): a persistent object cache that evicts this key must never start again at a value * whose old entries are still cached, which a counter restarting at 1 would. * * @return string */ public static function generation() { $gen = wp_cache_get( 'generation', 'growth_combinator' ); if ( false === $gen ) { $gen = sprintf( '%.6F', microtime( true ) ); wp_cache_set( 'generation', $gen, 'growth_combinator' ); } return (string) $gen; } /** * Endpoint names other plugins added to post addresses (`add_rewrite_endpoint` with EP_PERMALINK). * * @return string[] */ public static function endpoints() { global $wp_rewrite; $out = array(); foreach ( (array) ( $wp_rewrite ? $wp_rewrite->endpoints : array() ) as $endpoint ) { if ( $endpoint[0] & EP_PERMALINK ) { $out[] = $endpoint[1]; } } return $out; } /** * Whether Rank Math holds an active redirection that starts at this path. * * @param string $path Normalized path. * @return bool */ private static function rank_math_redirects( $path ) { if ( ! is_callable( array( '\RankMath\Redirections\DB', 'match_redirections' ) ) || ! is_callable( array( '\RankMath\Helper', 'is_module_active' ) ) || ! \RankMath\Helper::is_module_active( 'redirections' ) ) { return false; } try { return false !== \RankMath\Redirections\DB::match_redirections( $path ); } catch ( \Throwable $e ) { return false; } } /** * A refusal. * * @param string $code Stable code Studio keys on. * @param string $message Words for a person. * @return array{code: string, message: string} */ private static function why( $code, $message ) { return array( 'code' => $code, 'message' => $message, ); } }