Commit 97a9c802ec for wordpress.org

commit 97a9c802eccee214a5884e380446c5cd25d2dd77
Author: jorbin <jorbin@git.wordpress.org>
Date:   Fri Oct 16 17:04:46 2026 +0000

    Rewrite Rules: Add support for random content redirects.

    Introduce a `random` query var and `/random/` rewrite rules[1], built on the existing rewrite, WP, and query APIs, that redirect to a randomly selected published post. Random requests[2] work for the whole site, taxonomy terms, authors and post type archives, with the base configurable on the Permalinks screen. A new `randomable` post type argument controls which content is eligible, and the selection runs through the main query so it remains filterable and costs a single database query.

    The Random Base[3] is translatable and stored in the rewrite rules. This means that rewrite rules are regenerated when translations are updated or the Site Language is changed. Also, if a post or page is published that would result in the same slug as the Random Base, that content gets priority and nothing will be random[4].

    This reintroduces the feature committed in r63767 and reverted in r63906.

    1) Literally `/random/`, not just rules that are random. Though that could be fun too!
    2) Not "random" as in unpredictable, but random as in someone wanted it to be random.
    3) It's intentionally "random" but it could also intentionally be "aleatoria" or "karkovice".
    4) The other random permalinks will still work, so not nothing. But is anything really random?

    Developed in: https://github.com/WordPress/wordpress-develop/pull/14018

    Follow-up to r63767, r63906.

    Props jorbin, peterwilsoncc, westonruter, mukesh27, annezazu, mlaetitia, mcsf, johnbillion, TobiasBg, solankisoftware, sajib1223, ellatrix, matt.
    See #64498.

    Built from https://develop.svn.wordpress.org/trunk@64402


    git-svn-id: http://core.svn.wordpress.org/trunk@63552 1a063a9b-81f0-0310-95a4-ce76da25c4cd

diff --git a/wp-admin/includes/schema.php b/wp-admin/includes/schema.php
index 5dc0b43252..d175c65e36 100644
--- a/wp-admin/includes/schema.php
+++ b/wp-admin/includes/schema.php
@@ -563,6 +563,9 @@ function populate_options( array $options = array() ) {

 		// 6.9.0
 		'wp_notes_notify'                 => 1,
+
+		// 7.2.0
+		'wp_random_base'                  => '',
 	);

 	// 3.3.0
diff --git a/wp-admin/includes/upgrade.php b/wp-admin/includes/upgrade.php
index f4315e681e..0f186b4bcd 100644
--- a/wp-admin/includes/upgrade.php
+++ b/wp-admin/includes/upgrade.php
@@ -2892,7 +2892,7 @@ function __get_option( $setting ) { // phpcs:ignore WordPress.NamingConventions.
 		return __get_option( 'siteurl' );
 	}

-	if ( in_array( $setting, array( 'siteurl', 'home', 'category_base', 'tag_base' ), true ) ) {
+	if ( in_array( $setting, array( 'siteurl', 'home', 'category_base', 'tag_base', 'wp_random_base' ), true ) ) {
 		$option = untrailingslashit( $option );
 	}

diff --git a/wp-admin/network/site-settings.php b/wp-admin/network/site-settings.php
index 8b1248f6b1..beecbb3e89 100644
--- a/wp-admin/network/site-settings.php
+++ b/wp-admin/network/site-settings.php
@@ -162,6 +162,7 @@ if ( ! empty( $messages ) ) {
 				'permalink_structure',
 				'category_base',
 				'tag_base',
+				'wp_random_base',
 				'upload_path',
 				'upload_url_path',
 			);
diff --git a/wp-admin/options-permalink.php b/wp-admin/options-permalink.php
index 86111f26ca..dc910aca2c 100644
--- a/wp-admin/options-permalink.php
+++ b/wp-admin/options-permalink.php
@@ -69,6 +69,9 @@ $help_sidebar_content .= '<p>' . __( '<a href="https://wordpress.org/support/for
 get_current_screen()->set_help_sidebar( $help_sidebar_content );
 unset( $help_sidebar_content );

+/** @global WP_Rewrite $wp_rewrite WordPress rewrite component. */
+global $wp_rewrite;
+
 $home_path           = get_home_path();
 $iis7_permalinks     = iis7_supports_permalinks();
 $permalink_structure = get_option( 'permalink_structure' );
@@ -94,6 +97,8 @@ if ( is_multisite() && ! is_subdomain_install() && is_main_site()

 $category_base = get_option( 'category_base' );
 $tag_base      = get_option( 'tag_base' );
+$random_base   = get_option( 'wp_random_base' );
+$random_base   = is_string( $random_base ) ? $random_base : '';

 $structure_updated        = false;
 $htaccess_update_required = false;
@@ -144,6 +149,16 @@ if ( isset( $_POST['permalink_structure'] ) || isset( $_POST['category_base'] )

 		$wp_rewrite->set_tag_base( $tag_base );
 	}
+
+	if ( wp_is_random_content_redirect_enabled() && isset( $_POST['wp_random_base'] ) && is_string( $_POST['wp_random_base'] ) ) {
+		$random_base = $_POST['wp_random_base'];
+
+		if ( ! empty( $random_base ) ) {
+			$random_base = $blog_prefix . preg_replace( '#/+#', '/', '/' . str_replace( '#', '', $random_base ) );
+		}
+
+		$wp_rewrite->set_random_base( $random_base );
+	}
 }

 if ( $iis7_permalinks ) {
@@ -236,6 +251,7 @@ if ( is_multisite() && ! is_subdomain_install() && is_main_site()
 	$permalink_structure = preg_replace( '|^/?blog|', '', $permalink_structure );
 	$category_base       = preg_replace( '|^/?blog|', '', $category_base );
 	$tag_base            = preg_replace( '|^/?blog|', '', $tag_base );
+	$random_base         = preg_replace( '|^/?blog|', '', $random_base ) ?? '';
 }

 $url_base = home_url( $blog_prefix . $index_php_prefix );
@@ -457,6 +473,29 @@ printf(
 		<?php endif; ?>
 		</td>
 	</tr>
+	<?php if ( wp_is_random_content_redirect_enabled() ) : ?>
+	<tr>
+		<th>
+			<label for="wp_random_base">
+				<?php _e( 'Random base' ); ?>
+			</label>
+		</th>
+		<td>
+		<?php if ( '' === $blog_prefix ) : ?>
+			<input name="wp_random_base" id="wp_random_base" type="text"
+				value="<?php echo esc_attr( $random_base ); ?>" class="regular-text code"
+			/>
+		<?php else : ?>
+			<span class="code permalink-structure-has-blog-prefix">
+				<code class="no-break"><?php echo $blog_prefix; ?></code>
+				<input name="wp_random_base" id="wp_random_base" type="text"
+					value="<?php echo esc_attr( $random_base ); ?>" class="regular-text code"
+				/>
+			</span>
+		<?php endif; ?>
+		</td>
+	</tr>
+	<?php endif; ?>
 	<?php do_settings_fields( 'permalink', 'optional' ); ?>
 </table>

diff --git a/wp-includes/class-wp-post-type.php b/wp-includes/class-wp-post-type.php
index 6307cbcd4c..2924f2a02f 100644
--- a/wp-includes/class-wp-post-type.php
+++ b/wp-includes/class-wp-post-type.php
@@ -123,6 +123,19 @@ final class WP_Post_Type {
 	 */
 	public $embeddable = null;

+	/**
+	 * Whether posts of this post type can be selected by random content redirects.
+	 *
+	 * Random content redirects send requests such as `example.com/random/` to a
+	 * randomly selected post.
+	 *
+	 * Default is true when $publicly_queryable is true and the post type has an archive.
+	 *
+	 * @since 7.2.0
+	 * @var bool $randomable
+	 */
+	public $randomable = null;
+
 	/**
 	 * Whether to generate and allow a UI for managing this post type in the admin.
 	 *
@@ -546,6 +559,7 @@ final class WP_Post_Type {
 			'exclude_from_search'             => null,
 			'publicly_queryable'              => null,
 			'embeddable'                      => null,
+			'randomable'                      => null,
 			'show_ui'                         => null,
 			'show_in_menu'                    => null,
 			'show_in_nav_menus'               => null,
@@ -590,6 +604,9 @@ final class WP_Post_Type {
 		// If not set, default to the setting for 'public'.
 		$args['embeddable'] ??= $args['public'];

+		// If not set, default to publicly queryable post types with an archive.
+		$args['randomable'] ??= $args['publicly_queryable'] && (bool) $args['has_archive'];
+
 		// If not set, default rest_namespace to wp/v2 if show_in_rest is true.
 		if ( false === $args['rest_namespace'] && ! empty( $args['show_in_rest'] ) ) {
 			$args['rest_namespace'] = 'wp/v2';
diff --git a/wp-includes/class-wp-query.php b/wp-includes/class-wp-query.php
index a01724ce96..bd114c58af 100644
--- a/wp-includes/class-wp-query.php
+++ b/wp-includes/class-wp-query.php
@@ -417,6 +417,16 @@ class WP_Query {
 	 */
 	public bool $is_sitemap = false;

+	/**
+	 * Signifies whether the current query is for random content.
+	 *
+	 * True for a blog home, archive or search request that includes the `random`
+	 * query variable, for example `example.com/random/` or `example.com/?s=foo&random`.
+	 *
+	 * @since 7.2.0
+	 */
+	public bool $is_random = false;
+
 	/**
 	 * Signifies whether the current query is for the page_for_posts page.
 	 *
@@ -527,6 +537,7 @@ class WP_Query {
 		$this->is_robots            = false;
 		$this->is_favicon           = false;
 		$this->is_sitemap           = false;
+		$this->is_random            = false;
 		$this->is_posts_page        = false;
 		$this->is_post_type_archive = false;
 	}
@@ -1147,6 +1158,10 @@ class WP_Query {

 		$this->is_embed = $this->is_embed && ( $this->is_singular || $this->is_404 );

+		$this->is_random = isset( $query_vars['random'] )
+			&& ! $this->is_feed
+			&& ( $this->is_home || $this->is_archive || $this->is_search );
+
 		$this->query_vars_hash    = md5( serialize( $this->query_vars ) );
 		$this->query_vars_changed = false;

@@ -4653,6 +4668,19 @@ class WP_Query {
 		return $this->is_sitemap;
 	}

+	/**
+	 * Determines whether the query is for random content.
+	 *
+	 * @since 7.2.0
+	 *
+	 * @see wp_is_random_content_query()
+	 *
+	 * @return bool Whether the query is for random content.
+	 */
+	public function is_random(): bool {
+		return $this->is_random;
+	}
+
 	/**
 	 * Determines whether the query is for a search.
 	 *
diff --git a/wp-includes/class-wp-rewrite.php b/wp-includes/class-wp-rewrite.php
index 63d4c57d03..0c571e4d76 100644
--- a/wp-includes/class-wp-rewrite.php
+++ b/wp-includes/class-wp-rewrite.php
@@ -120,6 +120,18 @@ class WP_Rewrite {
 	 */
 	public $feed_base = 'feed';

+	/**
+	 * Custom random content permalink base.
+	 *
+	 * Set from the 'wp_random_base' option by {@see WP_Rewrite::init()}. Empty when no custom
+	 * base is set, in which case the translated default is used.
+	 * Use {@see WP_Rewrite::get_random_base()} to retrieve the base in use.
+	 *
+	 * @since 7.2.0
+	 * @var string
+	 */
+	public string $random_base = '';
+
 	/**
 	 * Comments feed permalink structure.
 	 *
@@ -869,7 +881,9 @@ class WP_Rewrite {
 	 *                                    over and rewrite rules built for each in-turn. Default true.
 	 * @param bool   $endpoints           Optional. Whether endpoints should be applied to the generated rewrite rules.
 	 *                                    Default true.
-	 * @return string[] Array of rewrite rules keyed by their regex pattern.
+	 * @return array<string, string> Array of rewrite rules keyed by their regex pattern.
+	 *
+	 * @phpstan-return array<non-falsy-string, non-falsy-string>
 	 */
 	public function generate_rewrite_rules( $permalink_structure, $ep_mask = EP_NONE, $paged = true, $feed = true, $forcomments = false, $walk_dirs = true, $endpoints = true ) {
 		// Build a regex to match the feed section of URLs, something like (feed|atom|rss|rss2)/?
@@ -1253,7 +1267,9 @@ class WP_Rewrite {
 	 * @param string $permalink_structure The permalink structure to generate rules.
 	 * @param bool   $walk_dirs           Optional. Whether to create list of directories to walk over.
 	 *                                    Default false.
-	 * @return array An array of rewrite rules keyed by their regex pattern.
+	 * @return array<string, string> An array of rewrite rules keyed by their regex pattern.
+	 *
+	 * @phpstan-return array<non-falsy-string, non-falsy-string>
 	 */
 	public function generate_rewrite_rule( $permalink_structure, $walk_dirs = false ) {
 		return $this->generate_rewrite_rules( $permalink_structure, EP_NONE, false, false, false, $walk_dirs );
@@ -1274,7 +1290,7 @@ class WP_Rewrite {
 	 *
 	 * @since 1.5.0
 	 *
-	 * @return string[] An associative array of matches and queries.
+	 * @return array<string, string> An associative array of matches and queries.
 	 */
 	public function rewrite_rules() {
 		$rewrite = array();
@@ -1407,6 +1423,22 @@ class WP_Rewrite {
 		 */
 		$page_rewrite = apply_filters( 'page_rewrite_rules', $page_rewrite );

+		// Random content rewrite rules.
+		$random_rewrite = $this->random_rewrite_rules();
+
+		/**
+		 * Filters rewrite rules used for random content redirects.
+		 *
+		 * Likely random content rules would include `/random/`, as well as
+		 * term, author and post type archives prefixed with the random base,
+		 * for example `/random/category/news/`.
+		 *
+		 * @since 7.2.0
+		 *
+		 * @param array<string, string> $random_rewrite Array of rewrite rules for random content redirects, keyed by their regex pattern.
+		 */
+		$random_rewrite = apply_filters( 'random_rewrite_rules', $random_rewrite );
+
 		// Extra permastructs.
 		foreach ( $this->extra_permastructs as $permastructname => $struct ) {
 			if ( is_array( $struct ) ) {
@@ -1455,9 +1487,9 @@ class WP_Rewrite {

 		// Put them together.
 		if ( $this->use_verbose_page_rules ) {
-			$this->rules = array_merge( $this->extra_rules_top, $robots_rewrite, $favicon_rewrite, $sitemap_rewrite, $deprecated_files, $registration_pages, $root_rewrite, $comments_rewrite, $search_rewrite, $author_rewrite, $date_rewrite, $page_rewrite, $post_rewrite, $this->extra_rules );
+			$this->rules = array_merge( $this->extra_rules_top, $robots_rewrite, $favicon_rewrite, $sitemap_rewrite, $deprecated_files, $registration_pages, $random_rewrite, $root_rewrite, $comments_rewrite, $search_rewrite, $author_rewrite, $date_rewrite, $page_rewrite, $post_rewrite, $this->extra_rules );
 		} else {
-			$this->rules = array_merge( $this->extra_rules_top, $robots_rewrite, $favicon_rewrite, $sitemap_rewrite, $deprecated_files, $registration_pages, $root_rewrite, $comments_rewrite, $search_rewrite, $author_rewrite, $date_rewrite, $post_rewrite, $page_rewrite, $this->extra_rules );
+			$this->rules = array_merge( $this->extra_rules_top, $robots_rewrite, $favicon_rewrite, $sitemap_rewrite, $deprecated_files, $registration_pages, $random_rewrite, $root_rewrite, $comments_rewrite, $search_rewrite, $author_rewrite, $date_rewrite, $post_rewrite, $page_rewrite, $this->extra_rules );
 		}

 		/**
@@ -1474,13 +1506,122 @@ class WP_Rewrite {
 		 *
 		 * @since 1.5.0
 		 *
-		 * @param string[] $rules The compiled array of rewrite rules, keyed by their regex pattern.
+		 * @param array<string, string> $rules The compiled array of rewrite rules, keyed by their regex pattern.
 		 */
 		$this->rules = apply_filters( 'rewrite_rules_array', $this->rules );

 		return $this->rules;
 	}

+	/**
+	 * Retrieves the rewrite rules for random content redirects.
+	 *
+	 * Random content URLs are an existing archive path prefixed with the random base.
+	 * A request to one redirects to a randomly selected post from that archive:
+	 *
+	 * - `/random/` for any post.
+	 * - `/random/{taxonomy-slug}/{term}/` for a post in a term archive, for example `/random/category/news/`.
+	 * - `/random/{author-base}/{author}/` for a post by an author.
+	 * - `/random/{post-type-slug}/` for a post of a post type, using its archive slug if it
+	 *   has one, otherwise its rewrite slug.
+	 *
+	 * Only post types registered as `randomable`, and taxonomies used by them, receive rules.
+	 *
+	 * If a published page exists at the random base, the page takes precedence and the
+	 * `/random/` rule is omitted. The same applies to a published post when posts share
+	 * the URL, see {@see WP_Rewrite::post_permalinks_share_random_base()}. The rules are flushed
+	 * when such a page or post changes, see {@see wp_random_content_flush_rewrite_rules_for_post()}.
+	 *
+	 * The `random` query variable can be added to any other archive or search URL, for example
+	 * `/2026/?random`, to request a random post from that archive.
+	 *
+	 * @since 7.2.0
+	 *
+	 * @see wp_is_random_content_redirect_enabled()
+	 *
+	 * @return array<string, string> Array of rewrite rules for random content redirects, keyed by their regex pattern.
+	 *
+	 * @phpstan-return array<non-falsy-string, non-falsy-string>
+	 */
+	public function random_rewrite_rules(): array {
+		if ( ! wp_is_random_content_redirect_enabled() ) {
+			return array();
+		}
+
+		$random_slug = $this->get_random_base();
+		$random_base = $this->root . $random_slug;
+
+		$rules = array();
+
+		// A published page or post at the random base, for example example.com/random/, takes precedence.
+		$random_base_posts = array( get_page_by_path( $random_slug ) );
+		if ( $this->post_permalinks_share_random_base() ) {
+			$random_base_posts[] = get_page_by_path( $random_slug, OBJECT, 'post' );
+		}
+
+		$random_base_is_taken = false;
+		foreach ( $random_base_posts as $random_base_post ) {
+			if ( $random_base_post instanceof WP_Post && is_post_publicly_viewable( $random_base_post ) ) {
+				$random_base_is_taken = true;
+				break;
+			}
+		}
+
+		if ( ! $random_base_is_taken ) {
+			$rules[ $random_base . '/?$' ] = $this->index . '?random=1';
+		}
+
+		$archive_structures = array(
+			"{$random_base}/{$this->author_base}/%author%",
+		);
+
+		$randomable_post_types = get_post_types( array( 'randomable' => true ) );
+
+		foreach ( get_taxonomies( array( 'publicly_queryable' => true ), 'objects' ) as $taxonomy ) {
+			$taxonomy_slug = is_array( $taxonomy->rewrite ) && isset( $taxonomy->rewrite['slug'] ) && is_string( $taxonomy->rewrite['slug'] ) ? $taxonomy->rewrite['slug'] : '';
+
+			// Only taxonomies with pretty permalinks have a registered rewrite tag.
+			if ( '' === $taxonomy_slug || ! isset( $this->extra_permastructs[ $taxonomy->name ] ) ) {
+				continue;
+			}
+
+			// Only taxonomies used by randomable post types can contain random content.
+			if ( ! array_intersect( $taxonomy->object_type, $randomable_post_types ) ) {
+				continue;
+			}
+
+			$archive_structures[] = "{$random_base}/{$taxonomy_slug}/%{$taxonomy->name}%";
+		}
+
+		foreach ( $archive_structures as $archive_structure ) {
+			$archive_rules = $this->generate_rewrite_rules( $archive_structure, EP_NONE, false, false, false, false, false );
+
+			foreach ( $archive_rules as $regex => $query ) {
+				$rules[ $regex ] = $query . '&random=1';
+			}
+		}
+
+		foreach ( get_post_types( array( 'randomable' => true ), 'objects' ) as $post_type ) {
+			// Post types without rewrites have no pretty permalinks, see {@see WP_Post_Type::add_rewrite_rules()}.
+			if ( ! is_array( $post_type->rewrite ) ) {
+				continue;
+			}
+
+			// Try using `has_archive` if it's a string, then try the rewrite slug, otherwise it shouldn't have gotten here, so just skip the post type.
+			if ( is_string( $post_type->has_archive ) ) {
+				$post_type_slug = $post_type->has_archive;
+			} elseif ( isset( $post_type->rewrite['slug'] ) && is_string( $post_type->rewrite['slug'] ) ) {
+				$post_type_slug = $post_type->rewrite['slug'];
+			} else {
+				continue;
+			}
+
+			$rules[ "{$random_base}/{$post_type_slug}/?$" ] = $this->index . "?post_type={$post_type->name}&random=1";
+		}
+
+		return $rules;
+	}
+
 	/**
 	 * Retrieves the rewrite rules.
 	 *
@@ -1931,6 +2072,9 @@ class WP_Rewrite {
 			$this->root = $this->index . '/';
 		}

+		$random_base       = get_option( 'wp_random_base' );
+		$this->random_base = is_string( $random_base ) ? $random_base : '';
+
 		unset( $this->author_structure );
 		unset( $this->date_structure );
 		unset( $this->page_structure );
@@ -2017,6 +2161,84 @@ class WP_Rewrite {
 		}
 	}

+	/**
+	 * Sets the random base for random content permalinks.
+	 *
+	 * Will update the 'wp_random_base' option, if there is a difference between the
+	 * current random base and the parameter value. Calls {@see WP_Rewrite::init()} after
+	 * the option is updated.
+	 *
+	 * @since 7.2.0
+	 *
+	 * @param string $random_base Random content permalink base.
+	 */
+	public function set_random_base( string $random_base ): void {
+		if ( get_option( 'wp_random_base' ) !== $random_base ) {
+			update_option( 'wp_random_base', $random_base );
+			$this->init();
+		}
+	}
+
+	/**
+	 * Retrieves the random content permalink base.
+	 *
+	 * Requests prefixed with this base redirect to a randomly selected post,
+	 * for example `example.com/random/` or `example.com/random/category/news/`.
+	 * A published page with this slug takes precedence over `example.com/random/`.
+	 *
+	 * Returns the custom base from the 'wp_random_base' option when one is set.
+	 * Otherwise returns the default base, translated into the site language so
+	 * that rewrite rules do not depend on the language of the user who flushes them.
+	 *
+	 * @since 7.2.0
+	 *
+	 * @return string Random content permalink base.
+	 */
+	public function get_random_base(): string {
+		if ( '' !== $this->random_base ) {
+			return $this->random_base;
+		}
+
+		$switched_locale = switch_to_locale( get_locale() );
+
+		/*
+		 * translators: Default base for random content URLs, such as example.com/random/.
+		 * This string is used in URLs, so it must be lowercase and contain only letters,
+		 * numbers and hyphens. Transliterate it to Latin characters if needed.
+		 */
+		$random_base = _x( 'random', 'random content permalink base' );
+
+		if ( $switched_locale ) {
+			restore_previous_locale();
+		}
+
+		return '' !== $random_base ? $random_base : 'random';
+	}
+
+	/**
+	 * Determines whether post permalinks can be at the random content permalink base.
+	 *
+	 * When the permalink structure is only the post name at the root of the site, for
+	 * example `/%postname%/`, a post with the same slug as the random base has the
+	 * same URL as random content requests, for example `example.com/random/`.
+	 *
+	 * Permalink structures with a prefix, such as `/blog/%postname%/` on the main site
+	 * of a subdirectory multisite network, do not conflict with the random base.
+	 *
+	 * @since 7.2.0
+	 *
+	 * @see WP_Rewrite::random_rewrite_rules()
+	 *
+	 * @return bool Whether a post's permalink can be at the random base.
+	 */
+	public function post_permalinks_share_random_base(): bool {
+		if ( ! $this->using_permalinks() ) {
+			return false;
+		}
+
+		return 1 === preg_match( '#^' . preg_quote( '/' . $this->root, '#' ) . '%postname%/?$#', $this->permalink_structure );
+	}
+
 	/**
 	 * Constructor - Calls init(), which runs setup.
 	 *
diff --git a/wp-includes/class-wp.php b/wp-includes/class-wp.php
index 92cf4b9572..1907844e95 100644
--- a/wp-includes/class-wp.php
+++ b/wp-includes/class-wp.php
@@ -13,9 +13,10 @@ class WP {
 	 * Long list of public query variables.
 	 *
 	 * @since 2.0.0
+	 * @since 7.2.0 Added the `random` query variable.
 	 * @var string[]
 	 */
-	public $public_query_vars = array( 'm', 'p', 'posts', 'w', 'cat', 'withcomments', 'withoutcomments', 's', 'search', 'exact', 'sentence', 'calendar', 'page', 'paged', 'more', 'tb', 'pb', 'author', 'order', 'orderby', 'year', 'monthnum', 'day', 'hour', 'minute', 'second', 'name', 'category_name', 'tag', 'feed', 'author_name', 'pagename', 'page_id', 'error', 'attachment', 'attachment_id', 'subpost', 'subpost_id', 'preview', 'robots', 'favicon', 'taxonomy', 'term', 'cpage', 'post_type', 'embed' );
+	public $public_query_vars = array( 'm', 'p', 'posts', 'w', 'cat', 'withcomments', 'withoutcomments', 's', 'search', 'exact', 'sentence', 'calendar', 'page', 'paged', 'more', 'tb', 'pb', 'author', 'order', 'orderby', 'year', 'monthnum', 'day', 'hour', 'minute', 'second', 'name', 'category_name', 'tag', 'feed', 'author_name', 'pagename', 'page_id', 'error', 'attachment', 'attachment_id', 'subpost', 'subpost_id', 'preview', 'robots', 'favicon', 'taxonomy', 'term', 'cpage', 'post_type', 'embed', 'random' );

 	/**
 	 * Private query variables.
@@ -702,6 +703,62 @@ class WP {
 		$wp_the_query->query( $this->query_vars );
 	}

+	/**
+	 * Redirects random content requests to a randomly selected post.
+	 *
+	 * For random content requests, runs the main query to select a single random post
+	 * from the requested archive and redirects to it. For example, `example.com/random/`
+	 * redirects to a random post and `example.com/random/category/news/` redirects to a
+	 * random post in the "news" category.
+	 *
+	 * The random post is selected by the main query, so {@see 'pre_get_posts'} callbacks
+	 * customizing the archive apply to the selection. While the query runs, callbacks
+	 * enforcing the eligible posts, order and limit are added after any other callbacks:
+	 *
+	 * - {@see wp_random_content_pre_get_posts()} on the {@see 'pre_get_posts'} action.
+	 * - {@see wp_random_content_posts_orderby()} on the {@see 'posts_orderby'} filter.
+	 * - {@see wp_random_content_post_limits()} on the {@see 'post_limits'} filter.
+	 *
+	 * If no post is selected, for example on an empty archive, the request is a 404 as
+	 * there is no post to redirect to. If a post is selected but the request is not
+	 * redirected, the request continues with the results of the main query. The main
+	 * query is not run again.
+	 *
+	 * @since 7.2.0
+	 *
+	 * @see wp_is_random_content_query()
+	 * @global WP_Query $wp_the_query WordPress Query object.
+	 *
+	 * @return bool Whether the main query was run for a random content request.
+	 */
+	public function handle_random(): bool {
+		global $wp_the_query;
+
+		if ( ! isset( $this->query_vars['random'] ) || ! wp_is_random_content_redirect_enabled() ) {
+			return false;
+		}
+
+		add_action( 'pre_get_posts', 'wp_random_content_pre_get_posts', 20 );
+		add_filter( 'posts_orderby', 'wp_random_content_posts_orderby', 20, 2 );
+		add_filter( 'post_limits', 'wp_random_content_post_limits', 20, 2 );
+
+		$this->query_posts();
+
+		remove_action( 'pre_get_posts', 'wp_random_content_pre_get_posts', 20 );
+		remove_filter( 'posts_orderby', 'wp_random_content_posts_orderby', 20 );
+		remove_filter( 'post_limits', 'wp_random_content_post_limits', 20 );
+
+		wp_random_content_redirect();
+
+		if ( wp_is_random_content_query( $wp_the_query ) && ! $wp_the_query->post instanceof WP_Post ) {
+			$wp_the_query->set_404();
+			status_header( 404 );
+			nocache_headers();
+		}
+
+		return true;
+	}
+
 	/**
 	 * Set the Headers for 404, if nothing is found for requested URL.
 	 *
@@ -820,7 +877,9 @@ class WP {
 		$parsed = $this->parse_request( $query_args );

 		if ( $parsed ) {
-			$this->query_posts();
+			if ( ! $this->handle_random() ) {
+				$this->query_posts();
+			}
 			$this->handle_404();
 			$this->register_globals();
 		}
diff --git a/wp-includes/default-filters.php b/wp-includes/default-filters.php
index cdeaa169af..bf8ff15df4 100644
--- a/wp-includes/default-filters.php
+++ b/wp-includes/default-filters.php
@@ -319,6 +319,7 @@ add_filter( 'pre_comment_content', '_wp_kses_sanitize_note_mention_classes', 11
 add_filter( 'comment_email', 'antispambot' );
 add_filter( 'option_tag_base', '_wp_filter_taxonomy_base' );
 add_filter( 'option_category_base', '_wp_filter_taxonomy_base' );
+add_filter( 'option_wp_random_base', '_wp_filter_taxonomy_base' );
 add_filter( 'the_posts', '_close_comments_for_old_posts', 10, 2 );
 add_filter( 'comments_open', '_close_comments_for_old_post', 10, 2 );
 add_filter( 'pings_open', '_close_comments_for_old_post', 10, 2 );
@@ -494,6 +495,15 @@ add_action( 'attachment_updated', 'wp_check_for_changed_slugs', 12, 3 );
 add_action( 'post_updated', 'wp_check_for_changed_dates', 12, 3 );
 add_action( 'attachment_updated', 'wp_check_for_changed_dates', 12, 3 );

+// Random content redirects, see WP::handle_random().
+add_filter( 'split_the_query', 'wp_random_content_split_the_query', 10, 2 );
+add_action( 'wp_after_insert_post', 'wp_random_content_flush_rewrite_rules_for_post', 10, 4 );
+add_action( 'after_delete_post', 'wp_random_content_flush_rewrite_rules_for_post', 10, 2 );
+
+// Regenerate rewrite rules in the new site language, they can contain translated slugs.
+add_action( 'add_option_WPLANG', 'wp_flush_rewrite_rules_on_site_language_change', 10, 0 );
+add_action( 'update_option_WPLANG', 'wp_flush_rewrite_rules_on_site_language_change', 10, 0 );
+
 // Nonce check for post previews.
 add_action( 'init', '_show_post_preview' );

diff --git a/wp-includes/formatting.php b/wp-includes/formatting.php
index f277c126ba..58d39a98a7 100644
--- a/wp-includes/formatting.php
+++ b/wp-includes/formatting.php
@@ -5184,6 +5184,7 @@ function sanitize_option( $option, $value ) {
 		case 'permalink_structure':
 		case 'category_base':
 		case 'tag_base':
+		case 'wp_random_base':
 			$value = $wpdb->strip_invalid_text_for_column( $wpdb->options, 'option_value', $value );
 			if ( is_wp_error( $value ) ) {
 				$error = $value->get_error_message();
diff --git a/wp-includes/option.php b/wp-includes/option.php
index 874e674d63..af954466a8 100644
--- a/wp-includes/option.php
+++ b/wp-includes/option.php
@@ -236,7 +236,7 @@ function get_option( $option, $default_value = false ) {
 		return get_option( 'siteurl' );
 	}

-	if ( in_array( $option, array( 'siteurl', 'home', 'category_base', 'tag_base' ), true ) ) {
+	if ( in_array( $option, array( 'siteurl', 'home', 'category_base', 'tag_base', 'wp_random_base' ), true ) ) {
 		$value = untrailingslashit( $value );
 	}

diff --git a/wp-includes/post.php b/wp-includes/post.php
index 041e71e31a..576c0bfdb1 100644
--- a/wp-includes/post.php
+++ b/wp-includes/post.php
@@ -27,6 +27,7 @@ function create_initial_post_types() {
 				'name_admin_bar' => _x( 'Post', 'add new from admin bar' ),
 			),
 			'public'                => true,
+			'randomable'            => true,
 			'_builtin'              => true, /* internal use only. don't use this when registering your own post type. */
 			'_edit_link'            => 'post.php?post=%d', /* internal use only. don't use this when registering your own post type. */
 			'capability_type'       => 'post',
@@ -64,6 +65,7 @@ function create_initial_post_types() {
 			),
 			'public'                => true,
 			'publicly_queryable'    => false,
+			'randomable'            => false,
 			'_builtin'              => true, /* internal use only. don't use this when registering your own post type. */
 			'_edit_link'            => 'post.php?post=%d', /* internal use only. don't use this when registering your own post type. */
 			'capability_type'       => 'page',
@@ -104,6 +106,7 @@ function create_initial_post_types() {
 				'attributes'     => __( 'Attachment Attributes' ),
 			),
 			'public'                => true,
+			'randomable'            => false,
 			'show_ui'               => true,
 			'_builtin'              => true, /* internal use only. don't use this when registering your own post type. */
 			'_edit_link'            => 'post.php?post=%d', /* internal use only. don't use this when registering your own post type. */
@@ -1725,6 +1728,9 @@ function get_post_types( $args = array(), $output = 'names', $operator = 'and' )
  *                                                          * ?{post_type_key}={single_post_slug}
  *                                                          * ?{post_type_query_var}={single_post_slug}
  *                                                         If not set, the default is inherited from $public.
+ *     @type bool         $randomable                      Whether posts of this post type can be selected by random content
+ *                                                         redirects, such as example.com/random/. If not set, the default is
+ *                                                         true when $publicly_queryable is true and $has_archive is set.
  *     @type bool         $show_ui                         Whether to generate and allow a UI for managing this post type in the
  *                                                         admin. Default is value of $public.
  *     @type bool|string  $show_in_menu                    Where to show the post type in the admin menu. To work, $show_ui
diff --git a/wp-includes/query.php b/wp-includes/query.php
index 42592002c1..212ac1619e 100644
--- a/wp-includes/query.php
+++ b/wp-includes/query.php
@@ -700,6 +700,30 @@ function is_sitemap(): bool {
 	return $wp_query->is_sitemap();
 }

+/**
+ * Determines whether the query is for random content.
+ *
+ * True for a blog home, archive or search request that includes the `random` query
+ * variable, for example `example.com/random/` or `example.com/random/category/news/`.
+ *
+ * @since 7.2.0
+ *
+ * @see wp_is_random_content_query()
+ * @global WP_Query $wp_query WordPress Query object.
+ *
+ * @return bool Whether the query is for random content.
+ */
+function is_random(): bool {
+	global $wp_query;
+
+	if ( ! isset( $wp_query ) ) {
+		_doing_it_wrong( __FUNCTION__, __( 'Conditional query tags do not work before the query is run. Before then, they always return false.' ), '7.2.0' );
+		return false;
+	}
+
+	return $wp_query->is_random();
+}
+
 /**
  * Determines whether the query is for a search.
  *
@@ -1239,6 +1263,310 @@ function _find_post_by_old_date( $post_type ) {
 	return $id;
 }

+/**
+ * Determines whether random content redirects are enabled.
+ *
+ * @since 7.2.0
+ *
+ * @return bool True if random content redirects are enabled, false otherwise.
+ */
+function wp_is_random_content_redirect_enabled(): bool {
+	/**
+	 * Filters whether random content redirects are enabled.
+	 *
+	 * Random content redirects add rewrite rules for URLs such as `/random/`, so the
+	 * rewrite rules need to be flushed after changing the value returned by this filter.
+	 *
+	 * @since 7.2.0
+	 *
+	 * @param bool $enabled Whether random content redirects are enabled. Default true.
+	 */
+	return (bool) apply_filters( 'wp_enable_random_content_redirect', true );
+}
+
+/**
+ * Determines whether a query is a request for random content.
+ *
+ * A random content request is a front end, main query for the blog home, an archive,
+ * or search results that includes the `random` query variable and at least one
+ * `randomable` post type. For example
+ * `example.com/random/`, `example.com/random/category/news/` or `example.com/?s=foo&random`.
+ *
+ * Singular (including embeds) and feed requests are never random content requests,
+ * nor are requests using an HTTP method other than GET or HEAD.
+ *
+ * @since 7.2.0
+ *
+ * @param WP_Query $query The query to check.
+ * @return bool True if the query is a request for random content, false otherwise.
+ */
+function wp_is_random_content_query( WP_Query $query ): bool {
+	if (
+		! $query->is_random()
+		|| ! $query->is_main_query()
+		|| is_admin()
+		|| ! wp_is_random_content_redirect_enabled()
+	) {
+		return false;
+	}
+
+	$request_method = isset( $_SERVER['REQUEST_METHOD'] ) && is_string( $_SERVER['REQUEST_METHOD'] ) ? strtoupper( $_SERVER['REQUEST_METHOD'] ) : '';
+	if ( 'GET' !== $request_method && 'HEAD' !== $request_method ) {
+		return false;
+	}
+
+	return array() !== wp_get_random_content_post_types( $query );
+}
+
+/**
+ * Retrieves the post types eligible for selection by a random content request.
+ *
+ * These are the post types the query would otherwise return, limited to those
+ * registered as `randomable`.
+ *
+ * @since 7.2.0
+ *
+ * @see register_post_type()
+ *
+ * @param WP_Query $query The query to retrieve the post types for.
+ * @return string[] Post type names eligible for random selection.
+ *
+ * @phpstan-return list<string>
+ */
+function wp_get_random_content_post_types( WP_Query $query ): array {
+	$post_types = $query->get( 'post_type' );
+
+	if ( empty( $post_types ) && $query->is_tax() && $query->tax_query instanceof WP_Tax_Query ) {
+		// Match WP_Query::get_posts(): searchable post types using any of the queried taxonomies.
+		$taxonomies = array_keys( $query->tax_query->queried_terms );
+		$post_types = array();
+
+		foreach ( get_post_types( array( 'exclude_from_search' => false ) ) as $post_type ) {
+			$object_taxonomies = 'attachment' === $post_type ? get_taxonomies_for_attachments() : get_object_taxonomies( $post_type );
+			if ( array_intersect( $taxonomies, $object_taxonomies ) ) {
+				$post_types[] = $post_type;
+			}
+		}
+	} elseif ( empty( $post_types ) && ! $query->is_search() ) {
+		$post_types = array( 'post' );
+	}
+
+	if ( empty( $post_types ) || 'any' === $post_types ) {
+		$post_types = get_post_types( array( 'exclude_from_search' => false ) );
+	}
+
+	$random_post_types = array();
+
+	foreach ( (array) $post_types as $post_type ) {
+		// Plugins may set invalid values on the `post_type` query variable.
+		if ( ! is_string( $post_type ) ) {
+			continue;
+		}
+
+		$post_type_object = get_post_type_object( $post_type );
+
+		if ( $post_type_object && $post_type_object->randomable ) {
+			$random_post_types[] = $post_type_object->name;
+		}
+	}
+
+	return $random_post_types;
+}
+
+/**
+ * Limits the main query to posts eligible for random content requests.
+ *
+ * The main query is used to select the random post so the request runs the same
+ * query, and the same filters, as the archive it is randomizing. As such, callbacks
+ * on the {@see 'pre_get_posts'} action may modify the posts eligible for selection,
+ * for example to limit the post types or exclude a category:
+ *
+ *     add_action(
+ *         'pre_get_posts',
+ *         static function ( WP_Query $query ) {
+ *             if ( wp_is_random_content_query( $query ) ) {
+ *                 $query->set( 'category__not_in', array( 1 ) );
+ *             }
+ *         }
+ *     );
+ *
+ * Only randomable post types and published posts without a password are eligible.
+ * The number and order of posts are enforced by {@see wp_random_content_post_limits()} and
+ * {@see wp_random_content_posts_orderby()}, so callbacks modifying them on archives do not
+ * affect the selection.
+ *
+ * Added to the {@see 'pre_get_posts'} action by {@see WP::handle_random()} while the
+ * random content query runs, so it runs after other callbacks.
+ *
+ * @since 7.2.0
+ *
+ * @param WP_Query $query The WP_Query instance (passed by reference).
+ */
+function wp_random_content_pre_get_posts( $query ): void {
+	if ( ! wp_is_random_content_query( $query ) ) {
+		return;
+	}
+
+	$query->set( 'post_type', wp_get_random_content_post_types( $query ) );
+	$query->set( 'post_status', 'publish' );
+	$query->set( 'has_password', false );
+	$query->set( 'ignore_sticky_posts', true );
+	$query->set( 'no_found_rows', true );
+	$query->set( 'update_post_meta_cache', false );
+	$query->set( 'update_post_term_cache', false );
+	// The order and number of posts are enforced by filters, which a suppressed query skips.
+	$query->set( 'suppress_filters', false );
+}
+
+/**
+ * Orders the random content query randomly.
+ *
+ * Added to the {@see 'posts_orderby'} filter by {@see WP::handle_random()} while the random
+ * content query runs. Enforcing the order in SQL prevents {@see 'pre_get_posts'}
+ * callbacks setting the order of posts on archives from affecting the selection.
+ *
+ * @since 7.2.0
+ *
+ * @param string   $orderby The ORDER BY clause of the query.
+ * @param WP_Query $query   The WP_Query instance.
+ * @return string The ORDER BY clause of the query.
+ */
+function wp_random_content_posts_orderby( $orderby, WP_Query $query ): string {
+	if ( wp_is_random_content_query( $query ) ) {
+		return 'RAND()';
+	}
+
+	return (string) $orderby;
+}
+
+/**
+ * Limits the random content query to a single post.
+ *
+ * Added to the {@see 'post_limits'} filter by {@see WP::handle_random()} while the random
+ * content query runs. Enforcing the limit in SQL prevents {@see 'pre_get_posts'}
+ * callbacks setting the number of posts, offset or page on archives from affecting
+ * the selection.
+ *
+ * @since 7.2.0
+ *
+ * @param string   $limits The LIMIT clause of the query.
+ * @param WP_Query $query  The WP_Query instance.
+ * @return string The LIMIT clause of the query.
+ */
+function wp_random_content_post_limits( $limits, WP_Query $query ): string {
+	if ( wp_is_random_content_query( $query ) ) {
+		return 'LIMIT 0, 1';
+	}
+
+	return (string) $limits;
+}
+
+/**
+ * Prevents the random content query from being split into multiple database queries.
+ *
+ * Randomly ordered queries are not cached, so there is no benefit to querying the post
+ * IDs separately to the post objects. Disabling the split allows the random post to be
+ * retrieved in a single database query.
+ *
+ * @since 7.2.0
+ *
+ * @param bool     $split_the_query Whether or not to split the query.
+ * @param WP_Query $query           The WP_Query instance.
+ * @return bool Whether or not to split the query.
+ */
+function wp_random_content_split_the_query( $split_the_query, WP_Query $query ): bool {
+	if ( wp_is_random_content_query( $query ) ) {
+		return false;
+	}
+
+	return (bool) $split_the_query;
+}
+
+/**
+ * Redirects random content requests to the randomly selected post.
+ *
+ * Called by {@see WP::handle_random()} immediately after the main query selects the
+ * random post, to avoid unnecessary processing before the redirect. As the redirect
+ * is sent before {@see WP::send_headers()}, the headers preventing the redirect being
+ * cached or indexed are sent with it.
+ *
+ * If no post is selected, for example on an empty archive, {@see WP::handle_random()}
+ * handles the request as a 404. If the post's permalink is on another site, the
+ * request is handled as a regular archive request.
+ *
+ * @since 7.2.0
+ *
+ * @global WP_Query $wp_query WordPress Query object.
+ */
+function wp_random_content_redirect(): void {
+	global $wp_query;
+
+	if ( ! wp_is_random_content_query( $wp_query ) || ! $wp_query->post instanceof WP_Post ) {
+		return;
+	}
+
+	$location = get_permalink( $wp_query->post );
+
+	// Only consider redirecting permitted locations so wp_safe_redirect doesn't redirect to the fallback.
+	if ( ! $location || ! wp_validate_redirect( $location, '' ) ) {
+		return;
+	}
+
+	nocache_headers();
+
+	if ( ! headers_sent() ) {
+		header( 'X-Robots-Tag: noindex, follow' );
+	}
+
+	if ( wp_safe_redirect( $location, 302 ) ) {
+		exit;
+	}
+}
+
+/**
+ * Flushes the rewrite rules when a page or post at the random content base changes.
+ *
+ * A published page at the random base, for example `example.com/random/`, takes
+ * precedence over random content redirects. So does a published post when the
+ * permalink structure places posts at the same URL, for example `/%postname%/`,
+ * see {@see WP_Rewrite::post_permalinks_share_random_base()}. As the rewrite rules account
+ * for the page or post, they need to be regenerated when it is published,
+ * unpublished, renamed, moved or deleted.
+ *
+ * Runs on the {@see 'wp_after_insert_post'} and {@see 'after_delete_post'} actions.
+ *
+ * @since 7.2.0
+ *
+ * @see WP_Rewrite::random_rewrite_rules()
+ * @global WP_Rewrite $wp_rewrite WordPress rewrite component.
+ *
+ * @param int          $post_id     Post ID.
+ * @param WP_Post      $post        Post object.
+ * @param bool         $update      Optional. Whether this is an existing post being updated. Default false.
+ * @param WP_Post|null $post_before Optional. Post object before the update, null for new posts. Default null.
+ */
+function wp_random_content_flush_rewrite_rules_for_post( int $post_id, WP_Post $post, bool $update = false, ?WP_Post $post_before = null ): void {
+	global $wp_rewrite;
+
+	if ( ! $wp_rewrite->using_permalinks() || ! wp_is_random_content_redirect_enabled() ) {
+		return;
+	}
+
+	foreach ( array( $post, $post_before ) as $page ) {
+		if (
+			$page instanceof WP_Post
+			&& $wp_rewrite->get_random_base() === $page->post_name
+			&& (
+				( 'page' === $page->post_type && 0 === (int) $page->post_parent )
+				|| ( 'post' === $page->post_type && $wp_rewrite->post_permalinks_share_random_base() )
+			)
+		) {
+			flush_rewrite_rules( false );
+			return;
+		}
+	}
+}
+
 /**
  * Set up global post data.
  *
diff --git a/wp-includes/rewrite.php b/wp-includes/rewrite.php
index 06c4a96507..93e2b23b50 100644
--- a/wp-includes/rewrite.php
+++ b/wp-includes/rewrite.php
@@ -285,6 +285,25 @@ function flush_rewrite_rules( $hard = true ) {
 	}
 }

+/**
+ * Clears the rewrite rules when the site language changes.
+ *
+ * Rewrite rules can contain translated slugs, such as the default random content
+ * base, see {@see WP_Rewrite::get_random_base()}. Clearing the rules causes them to be
+ * regenerated in the new site language.
+ *
+ * The rules are regenerated on the next request rather than immediately, because
+ * the new language is not loaded until after the option is saved. Clearing the
+ * rules also avoids rewriting the .htaccess file.
+ *
+ * Runs on the {@see 'add_option_WPLANG'} and {@see 'update_option_WPLANG'} actions.
+ *
+ * @since 7.2.0
+ */
+function wp_flush_rewrite_rules_on_site_language_change(): void {
+	update_option( 'rewrite_rules', '' );
+}
+
 /**
  * Adds an endpoint, like /trackback/.
  *
diff --git a/wp-includes/version.php b/wp-includes/version.php
index 1b9a792a0d..074cc42a4d 100644
--- a/wp-includes/version.php
+++ b/wp-includes/version.php
@@ -16,14 +16,14 @@
  *
  * @global string $wp_version
  */
-$wp_version = '7.2-alpha-64401';
+$wp_version = '7.2-alpha-64402';

 /**
  * Holds the WordPress DB revision, increments when changes are made to the WordPress DB schema.
  *
  * @global int $wp_db_version
  */
-$wp_db_version = 64337;
+$wp_db_version = 64342;

 /**
  * Holds the TinyMCE version.