Repository navigation
Add randomness for post selection - #14018
aaronjorbin wants to merge 18 commits into
Conversation
Random content redirects are built on the existing rewrite and query layers. random is a public query var, so ?random works on any archive, search or home URL. WP_Rewrite::random_rewrite_rules(), prefixed by the new $random_base, uses generate_rewrite_rules() to build /random/, /random/{taxonomy}/{term}/, /random/author/{name}/ and /random/{post-type-archive}/. These rules are merged ahead of the greedy page and post rules. Which content can be picked is set by a new randomable post type argument, which falls back to publicly_queryable and then to public. Posts are randomable; pages and attachments are not. Rules are only generated for randomable types and for taxonomies attached to them. A published page at the random base takes precedence over /random/. That check happens when the rules are generated, and wp_after_insert_post and after_delete_post flush the rules whenever such a page changes. The trade-off is that request time stays free of the extra lookup, at the cost of a rule flush whenever that one page changes. It also makes the rules depend on content, so a stale cache could briefly show the wrong winner. Separately, rules for a disabled feature or a newly registered type only appear after a flush, which matches how the rest of the rewrite API behaves.
The main query does the random pick. A late pre_get_posts callback at priority 1000 sets one row, orderby=rand, published and non-password posts, no stickies, no found rows and no cache priming. It also neutralizes the overrides themes commonly set: nopaging, posts_per_archive_page, showposts and offset. A split_the_query callback stops WP_Query from splitting the request, because random queries are never cached. The result is that /random/ costs exactly one database query, and nothing is queried before it. Every WP_Query filter still applies, which was Peter's main objection to the reverted version. The late priority protects randomness from theme archive tweaks. The cost is that developers who want to change the selection must use wp_is_random_content_query() inside pre_get_posts, and they can't widen eligibility past randomable. ORDER BY RAND() is a full scan of the eligible rows. It was accepted over a COUNT plus random OFFSET, which takes two queries, because one query was the stated goal; very large sites may eventually want a cheaper strategy behind a filter. The redirect itself is a 302 sent on send_headers, before the wp action and template loading, with no-cache and noindex headers added through wp_headers. Singular, feed, admin, non-GET/HEAD and empty-archive requests fall through to normal rendering. A permalink on a host wp_safe_redirect() won't allow is skipped instead of being replaced by its dashboard fallback. That favors safety over following plugins that point permalinks to other domains.
Trac Ticket: https://core.trac.wordpress.org/ticket/64498
|
The following accounts have interacted with this PR and/or linked issues. I will continue to update these lists as activity occurs. You can also manually ask me to refresh this list by adding the Core Committers: Use this line as a base for the props when committing in SVN: To understand the WordPress project's expectations around crediting contributors, please review the Contributor Attribution page in the Core Handbook. |
Test using WordPress PlaygroundThe changes in this pull request can previewed and tested using a WordPress Playground instance. WordPress Playground is an experimental project that creates a full WordPress instance entirely within the browser. Some things to be aware of
For more details about these limitations and more, check out the Limitations page in the WordPress Playground documentation. |
Parameter #2 $fallback_url of function wp_validate_redirect expects string, false given.
|
Gave this a spin locally and it's working well! I used AI to put in some filler content. Testing with just /random: Screen.Recording.2026-10-05.at.12.39.51.PM.movTesting with /random/author/[name]: Screen.Recording.2026-10-05.at.12.40.31.PM.movI also tested with an author without any posts and it showed no results: Screen.Recording.2026-10-05.at.12.42.51.PM.mov |
peterwilsoncc
left a comment
There was a problem hiding this comment.
I've done a first pass review and left a few comments inline.
This is looking pretty good generally.
| $args['embeddable'] ??= $args['public']; | ||
|
|
||
| // If not set, default to the setting for 'publicly_queryable'. | ||
| $args['randomable'] ??= $args['publicly_queryable']; |
There was a problem hiding this comment.
I used publicly_queryable as the default since I felt this was a logical extension. As this is getting a single item, I'm not sure I see the same connection to has_archive
There was a problem hiding this comment.
My thinking here is that the condition in the rewrite loop only adds the rewrite if the post type has an archive and a rewrite slug.
| * @since 7.2.0 | ||
| * @var string | ||
| */ | ||
| public $random_base = 'random'; |
There was a problem hiding this comment.
This will need to be an option on the permalink screen for i18n: French users may want to change this to aléatoire, for example.
I can't find if the default values are translatable for the category and tag bases but it will be good to check in with the polyglot team. If the other bases aren't translatable it would be good to check with the team if that was a backward compatibility choice at the time to avoid breaking URLs.
There was a problem hiding this comment.
Category and tag bases not translatable. I'll check in Polyglots on if continuing this is desirable. I also am adding this to option to the permalink options page.
wordpress-develop/src/wp-includes/taxonomy.php
Lines 46 to 58 in 0a106cd
There was a problem hiding this comment.
Polyglots check - https://wordpress.slack.com/archives/C02RP50LK/p1791315932840629
@tobifjellner responded and requested it be translated. Making that so.
There was a problem hiding this comment.
Some implementation notes:
- If the translation runs in
populate_option, it will likely be inserted untranslated since language packs are downloaded after the core update. - Multilingual plugins are going to need to test this. Maybe worth a special email to committers of https://wordpress.org/plugins/tags/multilingual/ plugins?
- if the translated string is updated outside of the WordPress upgrade, then the default "random" will get cached in the rewrite rules. We could flush the cache on updates to translated strings, but on multisite, that would mean needing to trigger an update that loops through every site since each has a unique cache.
| add_filter( 'wp_redirect', array( $this, 'filter_wp_redirect' ), 10, 2 ); | ||
| } | ||
|
|
||
| public function tear_down() { |
There was a problem hiding this comment.
I think all of these are reset in the base tear down. Leaving confirmation as homework.
Post types certainly are.
| array_merge( | ||
| array( | ||
| 'post_status' => 'publish', | ||
| 'post_date' => '2022-01-01 00:00:00', |
There was a problem hiding this comment.
All I am saying is that:
- Hamilton debut was on August 6, 2015
- Dear Evan Hansen debut was December 4, 2016 (I'm still crying)
- Shucked debut was April 4, 2023
|
i forgot to mention: the db version will need a bump in order to trigger the upgrade routine and a rewrite rules update. |
Co-authored-by: Peter Wilson <519727+peterwilsoncc@users.noreply.github.com> Co-authored-by: Weston Ruter <westonruter@gmail.com> Co-authored-by: Mukesh Panchal <mukeshpanchal27@users.noreply.github.com>
Settings and the random base - New "Random base" field on Settings → Permalinks, beside Category base and Tag base. It only appears when random content redirects are enabled. The new random_base option is cleaned and saved the same way as the category and tag bases, including on multisite site settings. New WP_Rewrite::set_random_base(). New WP_Rewrite::get_random_base(). It returns the custom base if one is set; otherwise it translates the default (random) into the site's language when the rules are built. The $random_base property now holds only the custom value. No default random_base option is saved on install or upgrade. quest handling New WP::handle_random(), called from WP::main() in place of query_posts() for random requests. - It runs the random query as the main query, so theme and plugin pre_get_posts customisations still apply. - While the query runs, it attaches callbacks at PHP_INT_MAX that enforce eligible posts (pre_get_posts), random order (posts_orderby) and a limit of one (post_limits), then removes them. - It redirects to the selected post, sending no-cache and noindex headers. - When no post is selected, the request becomes a 404. - The main query runs only once. - Off-site permalinks don't redirect: a selected post whose permalink is on a host that isn't allowed is shown in place, rather than sending the visitor to the wp_safe_redirect() fallback (/wp-admin/). - The global pre_get_posts hook at priority 1000 and the send_headers redirect hook are removed. - New is_random() and WP_Query::is_random(). The flag is set in parse_query() for random home, archive and search requests, and cleared by set_404(). wp_is_random_content_query() now uses it. Conflicts with pages and posts - A published post named random now takes precedence over /random/ when posts sit at the site root (/%postname%/ or /index.php/%postname%/), as pages already did. - New WP_Rewrite::post_permalinks_share_random_base() checks for that root-level permalink setup. - The flush function is renamed to wp_random_content_flush_rewrite_rules_for_post() and now also rebuilds the rules when a conflicting post changes. Rewrite rule refreshes - Changing the site language clears the stored rewrite rules, so they're rebuilt in the new language on the next request. - $wp_db_version is bumped, so rules are rebuilt after upgrading. The number is a placeholder to set before committing. Code quality - The new code passes phpstan level 10, with tighter types: wp_random_content_headers() now allows false header values, and non-string post types and rewrite slugs are skipped. - options-permalink.php now has a documented global $wp_rewrite, so its baselined phpstan entry is removed. Tests - Resets the base test class already performs are no longer repeated in the test classes. - Every assertion has a unique message, apart from assertQueryTrue(), which can't take one.
| * @since 7.2.0 | ||
| * @var string | ||
| */ | ||
| public $random_base = 'random'; |
There was a problem hiding this comment.
Some implementation notes:
- If the translation runs in
populate_option, it will likely be inserted untranslated since language packs are downloaded after the core update. - Multilingual plugins are going to need to test this. Maybe worth a special email to committers of https://wordpress.org/plugins/tags/multilingual/ plugins?
- if the translated string is updated outside of the WordPress upgrade, then the default "random" will get cached in the rewrite rules. We could flush the cache on updates to translated strings, but on multisite, that would mean needing to trigger an update that loops through every site since each has a unique cache.
peterwilsoncc
left a comment
There was a problem hiding this comment.
Some quick night brain thoughts after looking at this yesterday, I'll do a full review during the week.
| $this->root = $this->index . '/'; | ||
| } | ||
|
|
||
| $random_base = get_option( 'random_base' ); |
There was a problem hiding this comment.
| $random_base = get_option( 'random_base' ); | |
| $random_base = get_option( 'wp_random_base' ); |
Rename throughout and add to schema.php
Add to sanitize_option
wordpress-develop/src/wp-includes/formatting.php
Lines 5202 to 5204 in f670e8a
Co-authored-by: Weston Ruter <westonruter@gmail.com>
|
@peterwilsoncc pulling the discussion for the best way to store the Previously: #14018 (comment) and #14018 (comment) Doing it in the upgrade routine means it's likely that an untranslated then since the language packs are downloaded after the core update. My thinking with changing it each time the default site language is changed is that people may install WordPress and then switch to the language they want. I know there is the language chooser as the first step of the Famous 5-Minute Install™, but I think it's likely not uncommon for someone to choose their preferred language there and then later change the site language to whatever they intend the site language to be. We could store "old_random_bases" and also use it then, but that feels like overkill. Also, I think this explains why no one has attempted to make category base or tag base translatable :) |
There was a problem hiding this comment.
I've added a few notes inline.
I also took the liberty of pushing c6f475f to add the new option to the schema.
Edit:
In fbc09a5 I fixed the assertion I broke. My understanding is that the options are best populated so they end up in alloptions which avoids additional DB queries to populate notoptions.
As WP_Rewrite defaults the value to an empty string if it's not set, no need to change the value in there.
| $args['embeddable'] ??= $args['public']; | ||
|
|
||
| // If not set, default to the setting for 'publicly_queryable'. | ||
| $args['randomable'] ??= $args['publicly_queryable']; |
There was a problem hiding this comment.
My thinking here is that the condition in the rewrite loop only adds the rewrite if the post type has an archive and a rewrite slug.
Thanks @aaronjorbin, OOO issues makes a great deal of sense. |
> Method WP_Rewrite::random_rewrite_rules() should return array<string, string> but returns array<string>.
Fixes these PHPStan errors: Line | Error | Identifier -----|-----------------------------------------------------------------|------------------- 1533 | Offset 0 of array assigned to variable $post_ids is never read. | array.unusedOffset 1533 | Offset 1 of array assigned to variable $post_ids is never read. | array.unusedOffset 1535 | Value assigned to $post_ids[] is never read. | assign.unused
| } | ||
|
|
||
| if ( wp_is_random_content_redirect_enabled() && isset( $_POST['wp_random_base'] ) && is_string( $_POST['wp_random_base'] ) ) { | ||
| $random_base = $_POST['wp_random_base']; |
There was a problem hiding this comment.
| $random_base = $_POST['wp_random_base']; | |
| $random_base = wp_unslash( $_POST['wp_random_base'] ); |
There was a problem hiding this comment.
We aren't using wp_unslash for either the tag_base or the category_base. Is there a reason you think it's necessary here?
There was a problem hiding this comment.
Mainly as a correctness issue. All such input vars get slashed by WordPress, so they should be unslashed when read out.
Also, WPCS will flag such input superglobals as a MissingUnslash error that are accessed without slashing: https://github.com/WordPress/WordPress-Coding-Standards/blob/510f4f678ac00b65c162aa0ad57d4768940a0409/WordPress/Sniffs/Security/ValidatedSanitizedInputSniff.php#L224
So all getters of these globals should be unslashed to prevent other devs from seeing it as a problem.
There was a problem hiding this comment.
WordPress.Security.ValidatedSanitizedInput isn't currently in the core ruleset and I think that is a separate conversation. I think consistency within core is more important and this builds off the rest of the file.
The chain for this getting unslashed is set_random_base() → update_option() → sanitize_option( 'wp_random_base' ) → sanitize_url() → esc_url() which has an allow list that doesn't include backslashes.
| * @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 { |
There was a problem hiding this comment.
PHPStan at higher rule levels is erroring here:
------ ------------------------------------------------------------------------------------------------------------------------------------------------------
Line src/wp-includes/query.php
------ ------------------------------------------------------------------------------------------------------------------------------------------------------
1583 Function wp_random_content_flush_rewrite_rules_for_post() has an unused parameter $post_id.
🪪 function.unusedParameter
1583 Function wp_random_content_flush_rewrite_rules_for_post() has an unused parameter $update.
🪪 function.unusedParameter
------ ------------------------------------------------------------------------------------------------------------------------------------------------------
However, given that these parameters are passed by the action, I think this is something we just have to ignore.
|
|
||
| // Random content redirects, see WP::handle_random(). | ||
| add_filter( 'split_the_query', 'wp_random_content_split_the_query', 10, 2 ); | ||
| add_filter( 'wp_headers', 'wp_random_content_headers' ); |
There was a problem hiding this comment.
Is this needed at all? Won't WP::handle_random() already be doing the redirection and setting the no-cache headers? The wp_random_content_headers() function seems redundant.
| add_filter( 'wp_headers', 'wp_random_content_headers' ); |
There was a problem hiding this comment.
Good catch. Not putting in the batch I'm accepting so I can also remove that function.
Co-authored-by: Peter Wilson <519727+peterwilsoncc@users.noreply.github.com> Co-authored-by: Weston Ruter <westonruter@gmail.com>
- Update documentation to clarify the conditions for the `randomable` property. - Modify logic in `WP_Post_Type` and `WP_Rewrite` to ensure only post types with archives are considered randomable. - Adjust tests to reflect changes in post type registration and random content selection. - Add new tests for randomable post types without archives and ensure proper rewrite rules are applied.
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: #14018 Follow-up to r63767, r63906. Props jorbin, peterwilsoncc, westonruter, mukesh27, annezazu, mlaetitia, mcsf, johnbillion, TobiasBg, solankisoftware, sajib1223, ellatrix, matt. See #64498. git-svn-id: https://develop.svn.wordpress.org/trunk@64402 602fd350-edb4-49c9-b593-d223f7449a82
|
committed in https://core.trac.wordpress.org/changeset/64402 |
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: WordPress/wordpress-develop#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
|
Thanks for seeing this through, all! |
Random content redirects are built on the existing rewrite and query layers. random is a public query var, so ?random works on any archive, search or home URL. WP_Rewrite::random_rewrite_rules(), prefixed by the new $random_base, uses generate_rewrite_rules() to build /random/, /random/{taxonomy}/{term}/, /random/author/{name}/ and /random/{post-type-archive}/. These rules are merged ahead of the greedy page and post rules. Which content can be picked is set by a new randomable post type argument, which falls back to publicly_queryable and then to public. Posts are randomable; pages and attachments are not. Rules are only generated for randomable types and for taxonomies attached to them. A published page at the random base takes precedence over /random/. That check happens when the rules are generated, and wp_after_insert_post and after_delete_post flush the rules whenever such a page changes. The trade-off is that request time stays free of the extra lookup, at the cost of a rule flush whenever that one page changes. It also makes the rules depend on content, so a stale cache could briefly show the wrong winner. Separately, rules for a disabled feature or a newly registered type only appear after a flush, which matches how the rest of the rewrite API behaves.
The main query does the random pick. A late pre_get_posts callback at priority 1000 sets one row, orderby=rand, published and non-password posts, no stickies, no found rows and no cache priming. It also neutralizes the overrides themes commonly set: nopaging, posts_per_archive_page, showposts and offset. A split_the_query callback stops WP_Query from splitting the request, because random queries are never cached. The result is that /random/ costs exactly one database query, and nothing is queried before it. Every WP_Query filter still applies, which was Peter's main objection to the reverted version. The late priority protects randomness from theme archive tweaks. The cost is that developers who want to change the selection must use wp_is_random_content_query() inside pre_get_posts, and they can't widen eligibility past randomable. ORDER BY RAND() is a full scan of the eligible rows. It was accepted over a COUNT plus random OFFSET, which takes two queries, because one query was the stated goal; very large sites may eventually want a cheaper strategy behind a filter. The redirect itself is a 302 sent on send_headers, before the wp action and template loading, with no-cache and noindex headers added through wp_headers. Singular, feed, admin, non-GET/HEAD and empty-archive requests fall through to normal rendering. A permalink on a host wp_safe_redirect() won't allow is skipped instead of being replaced by its dashboard fallback. That favors safety over following plugins that point permalinks to other domains.
Trac Ticket: https://core.trac.wordpress.org/ticket/64498
Use of AI Tools
AI assistance: Yes
Tool(s): Claude Code
Model(s): Opus 5.5
Used for: basically everything
This Pull Request is for code review only. Please keep all other discussion in the Trac ticket. Do not merge this Pull Request. See GitHub Pull Requests for Code Review in the Core Handbook for more details.