fix(routing): bootstrap permalink structure so fresh installs resolve single posts
A fresh install leaves `permalink_structure` empty, so WordPress snapshots
$wp_rewrite with no post/page permalink rules and every single post resolves
to 404 until someone re-saves Settings -> Permalinks.
The existing mitigation fired too late to help: routing.php wrote the option
and flushed from `init`, but WP_Rewrite was already constructed with the empty
structure, and core defers pre-`wp_loaded` flushes to a `wp_loaded` callback
that regenerates from that stale snapshot. The rewrite_rules option ended up
non-empty but broken, so the 404 persisted on every request.
New \Jio\Core\RewriteBootstrap owns this: on `after_setup_theme` (still inside
wp-settings.php, before wp-blog-header.php parses the query) it calls
$wp_rewrite->set_permalink_structure() — the same API wp-admin/options-permalink.php
uses — which persists the option *and* refreshes the in-memory snapshot, so both
the current request and the rules written for the next one are correct. A
structure an administrator chose is never overridden.
Also consolidates the two duplicated flush sites that lived inline in
functions.php (`after_switch_theme` closure and the `jio_flushed_version`
block, whose `delete_option('rewrite_rules')` forced a needless regenerate),
and drops the now-redundant permalink write from routing.php. The version
stamp moves to `wp_loaded` priority 20 so it sees the complete rule set
instead of the pre-`add_rewrite_rule()` snapshot.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
39aed0d430
commit
e30de6562c
5 files changed
+334
-25
No files matched your search
+3
-16
@@ -94,13 +94,6 @@ if (!class_exists(\Jio\Core\DomainModel::class, true)) {
|
|||||||
// ============================================================
|
// ============================================================
|
||||||
require_once JIO_DIR . '/inc/bootstrap.php';
|
require_once JIO_DIR . '/inc/bootstrap.php';
|
||||||
|
|
||||||
// ============================================================
|
|
||||||
// 主题激活时刷新重写规则
|
|
||||||
// ============================================================
|
|
||||||
add_action('after_switch_theme', function () {
|
|
||||||
flush_rewrite_rules();
|
|
||||||
});
|
|
||||||
|
|
||||||
// §3.4 custom_filter: register custom query vars for archive filter
|
// §3.4 custom_filter: register custom query vars for archive filter
|
||||||
add_filter('query_vars', function ($vars) {
|
add_filter('query_vars', function ($vars) {
|
||||||
$vars[] = 'jio_filter_cat';
|
$vars[] = 'jio_filter_cat';
|
||||||
@@ -121,18 +114,12 @@ add_action('switch_theme', function () {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// 兜底:检测版本变更时自动刷新重写规则
|
|
||||||
if (get_option('jio_flushed_version') !== JIO_VERSION) {
|
|
||||||
delete_option('rewrite_rules');
|
|
||||||
add_action('init', function () {
|
|
||||||
flush_rewrite_rules();
|
|
||||||
update_option('jio_flushed_version', JIO_VERSION);
|
|
||||||
}, 99);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
// 初始化所有单例实例
|
// 初始化所有单例实例
|
||||||
// ============================================================
|
// ============================================================
|
||||||
|
// RewriteBootstrap 的 after_setup_theme 回调需在 Setup 的 init 注册之前入队,
|
||||||
|
// 让 permalink 快照在主题规则注册后、主查询解析前就已刷新。
|
||||||
|
\Jio\Core\RewriteBootstrap::get_instance()->init();
|
||||||
\Jio\Core\Setup::get_instance()->init();
|
\Jio\Core\Setup::get_instance()->init();
|
||||||
\Jio\WidgetSystem\Runtime::init();
|
\Jio\WidgetSystem\Runtime::init();
|
||||||
\Jio\Core\Scripts::get_instance()->init();
|
\Jio\Core\Scripts::get_instance()->init();
|
||||||
|
|||||||
@@ -189,20 +189,14 @@ add_action('init', function () {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 统一 flush 检查 — bump when user-home rewrite / author override changes
|
// 统一 flush 检查 — bump when user-home rewrite / author override changes
|
||||||
|
// 固定链接结构的兜底写入由 \Jio\Core\RewriteBootstrap 负责(after_setup_theme),
|
||||||
|
// 此处只关心本模块自己的规则版本与规则缺失。
|
||||||
$rewrite_version = JIO_VERSION . ':message-routing:' . jio_get_messages_rewrite_slug();
|
$rewrite_version = JIO_VERSION . ':message-routing:' . jio_get_messages_rewrite_slug();
|
||||||
$flushed = get_option('jio_user_rules_flushed');
|
$flushed = get_option('jio_user_rules_flushed');
|
||||||
$rules = get_option('rewrite_rules');
|
$rules = get_option('rewrite_rules');
|
||||||
$need_flush = ($flushed !== $rewrite_version)
|
$need_flush = ($flushed !== $rewrite_version)
|
||||||
|| !is_array($rules)
|
|| !is_array($rules)
|
||||||
|| $rules === []
|
|| $rules === [];
|
||||||
|| !get_option('permalink_structure');
|
|
||||||
|
|
||||||
// Dev/plain installs often have empty permalink_structure → pretty /user/ 404s at Apache.
|
|
||||||
// Force a usable structure so registered rewrite rules actually apply.
|
|
||||||
if (!get_option('permalink_structure')) {
|
|
||||||
update_option('permalink_structure', '/%postname%/');
|
|
||||||
$need_flush = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($need_flush) {
|
if ($need_flush) {
|
||||||
flush_rewrite_rules(true);
|
flush_rewrite_rules(true);
|
||||||
|
|||||||
@@ -0,0 +1,94 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Permalink bootstrap + rewrite rule flush owner.
|
||||||
|
*
|
||||||
|
* WordPress snapshots `permalink_structure` into `$wp_rewrite` before the theme loads
|
||||||
|
* (wp-settings.php, ahead of functions.php). On a fresh install that option is empty, so
|
||||||
|
* no post/page permalink rules exist and every single post resolves to 404 until someone
|
||||||
|
* re-saves Settings → Permalinks.
|
||||||
|
*
|
||||||
|
* `ensure_permalink_structure()` mirrors what wp-admin/options-permalink.php does:
|
||||||
|
* `WP_Rewrite::set_permalink_structure()` persists the option *and* refreshes the in-memory
|
||||||
|
* snapshot for the current request. Hooking it on `after_setup_theme` (still inside
|
||||||
|
* wp-settings.php, before the main query is parsed) fixes the very first request instead of
|
||||||
|
* only the next one.
|
||||||
|
*
|
||||||
|
* @package Jio
|
||||||
|
*/
|
||||||
|
|
||||||
|
namespace Jio\Core;
|
||||||
|
|
||||||
|
defined('ABSPATH') || exit;
|
||||||
|
|
||||||
|
class RewriteBootstrap {
|
||||||
|
|
||||||
|
private static $instance = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Structure installed on a site that never chose one. `/%postname%/` is the CMS-friendly
|
||||||
|
* default and is what the theme's own routing has always assumed.
|
||||||
|
*/
|
||||||
|
public const DEFAULT_STRUCTURE = '/%postname%/';
|
||||||
|
|
||||||
|
private function __construct() {}
|
||||||
|
|
||||||
|
public static function get_instance(): self {
|
||||||
|
if (null === self::$instance) {
|
||||||
|
self::$instance = new self();
|
||||||
|
}
|
||||||
|
return self::$instance;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function init(): void {
|
||||||
|
// Runs inside wp-settings.php, after the theme's rules are registered but before
|
||||||
|
// wp-blog-header.php parses the query — the last point this can still affect the
|
||||||
|
// current request.
|
||||||
|
add_action('after_setup_theme', [$this, 'ensure_permalink_structure'], 1);
|
||||||
|
add_action('after_switch_theme', [$this, 'flush_on_activate']);
|
||||||
|
// Version bumps land here rather than on `init`: `init` runs before every
|
||||||
|
// add_rewrite_rule() callback, and core defers pre-wp_loaded flushes anyway.
|
||||||
|
add_action('wp_loaded', [$this, 'flush_when_version_changes'], 20);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Guarantee a usable permalink structure so pretty rules actually apply.
|
||||||
|
*
|
||||||
|
* A structure the administrator chose is never overridden — only an empty one is filled.
|
||||||
|
*/
|
||||||
|
public function ensure_permalink_structure(): void {
|
||||||
|
$structure = (string) get_option('permalink_structure');
|
||||||
|
if ($structure !== '') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
global $wp_rewrite;
|
||||||
|
if ($wp_rewrite instanceof \WP_Rewrite) {
|
||||||
|
$wp_rewrite->set_permalink_structure(self::DEFAULT_STRUCTURE);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// No rewrite object (CLI/tests): persist the option so the next request boots sane.
|
||||||
|
update_option('permalink_structure', self::DEFAULT_STRUCTURE);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Theme activation: persist the theme's own rewrite rules immediately.
|
||||||
|
*/
|
||||||
|
public function flush_on_activate(): void {
|
||||||
|
flush_rewrite_rules();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Self-heal after a theme version bump without the repeated flush the old
|
||||||
|
* `init`-hook version stamp caused on every first request of a new version.
|
||||||
|
*/
|
||||||
|
public function flush_when_version_changes(): void {
|
||||||
|
$flushed = (string) get_option('jio_rewrite_flushed_version', '');
|
||||||
|
if ($flushed === JIO_VERSION) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
flush_rewrite_rules();
|
||||||
|
update_option('jio_rewrite_flushed_version', JIO_VERSION);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
const assert = require('node:assert/strict');
|
||||||
|
const fs = require('node:fs');
|
||||||
|
const path = require('node:path');
|
||||||
|
const { spawnSync } = require('node:child_process');
|
||||||
|
const test = require('node:test');
|
||||||
|
|
||||||
|
const root = path.resolve(__dirname, '..', '..');
|
||||||
|
const read = (file) => fs.readFileSync(path.join(root, file), 'utf8');
|
||||||
|
|
||||||
|
// A fresh WordPress install leaves `permalink_structure` empty, so WP_Rewrite is snapshotted
|
||||||
|
// with no post/page rules and every single post resolves to 404 until someone re-saves
|
||||||
|
// Settings → Permalinks. RewriteBootstrap must refresh that snapshot early enough to matter.
|
||||||
|
test('permalink structure is bootstrapped before the main query is parsed', () => {
|
||||||
|
const bootstrap = read('src/Core/RewriteBootstrap.php');
|
||||||
|
const functions = read('functions.php');
|
||||||
|
|
||||||
|
assert.match(bootstrap, /namespace Jio\\Core;/);
|
||||||
|
assert.match(bootstrap, /class RewriteBootstrap/);
|
||||||
|
assert.match(bootstrap, /const DEFAULT_STRUCTURE = '\/%postname%\/';/);
|
||||||
|
|
||||||
|
// The hook that makes the current request correct: after_setup_theme still runs inside
|
||||||
|
// wp-settings.php, before wp-blog-header.php parses the query.
|
||||||
|
assert.match(bootstrap, /add_action\('after_setup_theme', \[\$this, 'ensure_permalink_structure'\], 1\)/);
|
||||||
|
assert.match(bootstrap, /add_action\('after_switch_theme', \[\$this, 'flush_on_activate'\]\)/);
|
||||||
|
assert.match(bootstrap, /add_action\('wp_loaded', \[\$this, 'flush_when_version_changes'\], 20\)/);
|
||||||
|
|
||||||
|
// set_permalink_structure is what wp-admin/options-permalink.php uses: it persists the
|
||||||
|
// option AND refreshes the in-memory WP_Rewrite state for the current request.
|
||||||
|
assert.match(bootstrap, /\$wp_rewrite->set_permalink_structure\(self::DEFAULT_STRUCTURE\)/);
|
||||||
|
assert.match(bootstrap, /\$wp_rewrite instanceof \\WP_Rewrite/);
|
||||||
|
|
||||||
|
// Version bumps must not land on `init`: that runs before every add_rewrite_rule()
|
||||||
|
// callback, and core defers pre-wp_loaded flushes anyway.
|
||||||
|
assert.doesNotMatch(bootstrap, /add_action\('init'/);
|
||||||
|
assert.match(bootstrap, /get_option\('jio_rewrite_flushed_version'/);
|
||||||
|
|
||||||
|
// A structure the administrator chose is never overridden.
|
||||||
|
const ensure = bootstrap.slice(
|
||||||
|
bootstrap.indexOf('public function ensure_permalink_structure'),
|
||||||
|
bootstrap.indexOf('public function flush_on_activate'),
|
||||||
|
);
|
||||||
|
assert.match(ensure, /get_option\('permalink_structure'\)[\s\S]*?!== ''[\s\S]*?return;/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('functions.php delegates rewrite flushing to RewriteBootstrap', () => {
|
||||||
|
const functions = read('functions.php');
|
||||||
|
|
||||||
|
assert.match(functions, /\\Jio\\Core\\RewriteBootstrap::get_instance\(\)->init\(\);/);
|
||||||
|
// The old duplicated sites are gone.
|
||||||
|
assert.doesNotMatch(functions, /delete_option\('rewrite_rules'\)/);
|
||||||
|
assert.doesNotMatch(functions, /jio_flushed_version/);
|
||||||
|
assert.doesNotMatch(functions, /add_action\('after_switch_theme', function \(\) \{\s*flush_rewrite_rules\(\);/);
|
||||||
|
|
||||||
|
// RewriteBootstrap must be initialized before Setup, whose init registers the rewrite
|
||||||
|
// callbacks on the same request.
|
||||||
|
assert.ok(
|
||||||
|
functions.indexOf('\\Jio\\Core\\RewriteBootstrap::get_instance()->init();')
|
||||||
|
< functions.indexOf('\\Jio\\Core\\Setup::get_instance()->init();'),
|
||||||
|
'RewriteBootstrap must initialize before Setup',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('user routing no longer writes permalink_structure itself', () => {
|
||||||
|
const routing = read('inc/modules/user-parts/routing.php');
|
||||||
|
|
||||||
|
// Single owner: the late init-time write is what made the first request 404 while leaving
|
||||||
|
// a non-empty but broken rewrite_rules option behind. (The read-only author_link fallback
|
||||||
|
// below is a separate, legitimate query-arg path.)
|
||||||
|
assert.doesNotMatch(routing, /update_option\('permalink_structure'/);
|
||||||
|
|
||||||
|
const flushCallback = routing.slice(
|
||||||
|
routing.indexOf('// 统一 flush 检查'),
|
||||||
|
routing.indexOf('if ($need_flush)'),
|
||||||
|
);
|
||||||
|
assert.doesNotMatch(flushCallback, /permalink_structure/);
|
||||||
|
|
||||||
|
// Its own message-routing version stamp stays — that is a separate self-heal concern.
|
||||||
|
assert.match(routing, /message-routing:' \. jio_get_messages_rewrite_slug\(\)/);
|
||||||
|
assert.match(routing, /flush_rewrite_rules\(true\)/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('fresh install gets a usable structure, chosen structures are left alone', () => {
|
||||||
|
const result = spawnSync(process.env.PHP_BINARY || 'php', [
|
||||||
|
path.join(__dirname, 'rewrite-bootstrap-harness.php'),
|
||||||
|
], { cwd: root, encoding: 'utf8' });
|
||||||
|
|
||||||
|
assert.equal(result.status, 0, result.stderr || result.stdout);
|
||||||
|
const data = JSON.parse(result.stdout);
|
||||||
|
|
||||||
|
// Hook wiring.
|
||||||
|
assert.deepEqual(data.hooks, [
|
||||||
|
{ hook: 'after_setup_theme', priority: 1, method: 'ensure_permalink_structure' },
|
||||||
|
{ hook: 'after_switch_theme', priority: 10, method: 'flush_on_activate' },
|
||||||
|
{ hook: 'wp_loaded', priority: 20, method: 'flush_when_version_changes' },
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Fresh install: option written AND the in-memory WP_Rewrite snapshot refreshed.
|
||||||
|
assert.equal(data.fresh_install.option, '/%postname%/');
|
||||||
|
assert.deepEqual(data.fresh_install.set_permalink, ['/%postname%/']);
|
||||||
|
|
||||||
|
// An admin's own structure wins, untouched.
|
||||||
|
assert.equal(data.chosen_structure.option, '/%category%/%postname%.html');
|
||||||
|
assert.deepEqual(data.chosen_structure.set_permalink, []);
|
||||||
|
|
||||||
|
// Version stamp flushes exactly once, never per request.
|
||||||
|
assert.equal(data.version_flush.flush_count, 1);
|
||||||
|
assert.equal(data.version_flush.stamp, '9.9.9-test');
|
||||||
|
assert.equal(data.version_flush.repeat_flush, 0);
|
||||||
|
});
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Behavioural harness for \Jio\Core\RewriteBootstrap.
|
||||||
|
*
|
||||||
|
* WordPress stubs only: an option bag, a WP_Rewrite stub recording what the theme asked of
|
||||||
|
* it, and an add_action/do_action pair so the hook wiring is exercised rather than assumed.
|
||||||
|
*/
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
define('ABSPATH', __DIR__);
|
||||||
|
define('JIO_VERSION', '9.9.9-test');
|
||||||
|
|
||||||
|
$GLOBALS['jio_test_options'] = [
|
||||||
|
'permalink_structure' => '',
|
||||||
|
];
|
||||||
|
$GLOBALS['jio_test_set_permalink_calls'] = [];
|
||||||
|
$GLOBALS['jio_test_flush_calls'] = [];
|
||||||
|
$GLOBALS['jio_test_hooks'] = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
function jio_test_reset(array $options): void {
|
||||||
|
$GLOBALS['jio_test_options'] = $options;
|
||||||
|
$GLOBALS['jio_test_set_permalink_calls'] = [];
|
||||||
|
$GLOBALS['jio_test_flush_calls'] = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function get_option(string $key, $default = false) {
|
||||||
|
return array_key_exists($key, $GLOBALS['jio_test_options'])
|
||||||
|
? $GLOBALS['jio_test_options'][$key]
|
||||||
|
: $default;
|
||||||
|
}
|
||||||
|
|
||||||
|
function update_option(string $key, $value): bool {
|
||||||
|
$GLOBALS['jio_test_options'][$key] = $value;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function delete_option(string $key): bool {
|
||||||
|
unset($GLOBALS['jio_test_options'][$key]);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function add_action(string $hook, $callback, int $priority = 10, int $args = 1): void {
|
||||||
|
$GLOBALS['jio_test_hooks'][] = [
|
||||||
|
'hook' => $hook,
|
||||||
|
'priority' => $priority,
|
||||||
|
'method' => is_array($callback) ? (string) $callback[1] : (string) $callback,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
function flush_rewrite_rules(bool $hard = true): void {
|
||||||
|
$GLOBALS['jio_test_flush_calls'][] = $hard;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
if (!class_exists('WP_Rewrite')) {
|
||||||
|
final class WP_Rewrite {
|
||||||
|
public array $structures = [];
|
||||||
|
|
||||||
|
public function set_permalink_structure(string $structure): void {
|
||||||
|
$this->structures[] = $structure;
|
||||||
|
// Core persists the option and refreshes in-memory state; the stub mirrors the
|
||||||
|
// persistence half so assertions on the option bag stay meaningful.
|
||||||
|
$GLOBALS['jio_test_options']['permalink_structure'] = $structure;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
require_once dirname(__DIR__, 2) . '/src/Core/RewriteBootstrap.php';
|
||||||
|
|
||||||
|
$bootstrap = \Jio\Core\RewriteBootstrap::get_instance();
|
||||||
|
$bootstrap->init();
|
||||||
|
|
||||||
|
$result = [
|
||||||
|
'hooks' => $GLOBALS['jio_test_hooks'],
|
||||||
|
];
|
||||||
|
|
||||||
|
// --- Case 1: fresh install, no permalink structure chosen yet.
|
||||||
|
jio_test_reset(['permalink_structure' => '']);
|
||||||
|
$wp_rewrite = new WP_Rewrite();
|
||||||
|
$GLOBALS['wp_rewrite'] = $wp_rewrite;
|
||||||
|
$bootstrap->ensure_permalink_structure();
|
||||||
|
|
||||||
|
$result['fresh_install'] = [
|
||||||
|
'option' => get_option('permalink_structure'),
|
||||||
|
'set_permalink' => $wp_rewrite->structures,
|
||||||
|
];
|
||||||
|
|
||||||
|
// --- Case 2: administrator already chose a structure — must not be overridden.
|
||||||
|
jio_test_reset(['permalink_structure' => '/%category%/%postname%.html']);
|
||||||
|
$wp_rewrite = new WP_Rewrite();
|
||||||
|
$GLOBALS['wp_rewrite'] = $wp_rewrite;
|
||||||
|
$bootstrap->ensure_permalink_structure();
|
||||||
|
|
||||||
|
$result['chosen_structure'] = [
|
||||||
|
'option' => get_option('permalink_structure'),
|
||||||
|
'set_permalink' => $wp_rewrite->structures,
|
||||||
|
];
|
||||||
|
|
||||||
|
// --- Case 3: version bump flushes once, then is a no-op.
|
||||||
|
jio_test_reset(['permalink_structure' => '/%postname%/']);
|
||||||
|
$bootstrap->flush_when_version_changes();
|
||||||
|
$first_flush = count($GLOBALS['jio_test_flush_calls']);
|
||||||
|
$stamp = get_option('jio_rewrite_flushed_version');
|
||||||
|
$bootstrap->flush_when_version_changes();
|
||||||
|
|
||||||
|
$result['version_flush'] = [
|
||||||
|
'flush_count' => $first_flush,
|
||||||
|
'stamp' => $stamp,
|
||||||
|
'repeat_flush' => count($GLOBALS['jio_test_flush_calls']) - $first_flush,
|
||||||
|
];
|
||||||
|
|
||||||
|
// --- Case 4: activation flush.
|
||||||
|
$bootstrap->flush_on_activate();
|
||||||
|
$result['activate'] = [
|
||||||
|
'flush_count' => count($GLOBALS['jio_test_flush_calls']),
|
||||||
|
];
|
||||||
|
|
||||||
|
echo json_encode($result, JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT);
|
||||||
|
echo PHP_EOL;
|
||||||
|
}
|
||||||
Reference in new issue
Block a user