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:
kate522andClaude Opus 5 committed 2026-11-02 19:50:24 +08:00
1 parent 39aed0d430
commit e30de6562c
5 files changed
+334 -25

No files matched your search

+3 -16
View File
@@ -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();
+3 -9
View File
@@ -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);
+94
View File
@@ -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;
}