diff --git a/functions.php b/functions.php index 335ecd23..8dd51395 100644 --- a/functions.php +++ b/functions.php @@ -94,13 +94,6 @@ if (!class_exists(\Jio\Core\DomainModel::class, true)) { // ============================================================ 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 add_filter('query_vars', function ($vars) { $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\WidgetSystem\Runtime::init(); \Jio\Core\Scripts::get_instance()->init(); diff --git a/inc/modules/user-parts/routing.php b/inc/modules/user-parts/routing.php index 520ee8cf..1e0e0cdd 100644 --- a/inc/modules/user-parts/routing.php +++ b/inc/modules/user-parts/routing.php @@ -189,20 +189,14 @@ add_action('init', function () { } // 统一 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(); $flushed = get_option('jio_user_rules_flushed'); $rules = get_option('rewrite_rules'); $need_flush = ($flushed !== $rewrite_version) || !is_array($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; - } + || $rules === []; if ($need_flush) { flush_rewrite_rules(true); diff --git a/src/Core/RewriteBootstrap.php b/src/Core/RewriteBootstrap.php new file mode 100644 index 00000000..06668b37 --- /dev/null +++ b/src/Core/RewriteBootstrap.php @@ -0,0 +1,94 @@ +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); + } +} diff --git a/tests/widget-system/rewrite-bootstrap-contract.test.cjs b/tests/widget-system/rewrite-bootstrap-contract.test.cjs new file mode 100644 index 00000000..36bea6cd --- /dev/null +++ b/tests/widget-system/rewrite-bootstrap-contract.test.cjs @@ -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); +}); diff --git a/tests/widget-system/rewrite-bootstrap-harness.php b/tests/widget-system/rewrite-bootstrap-harness.php new file mode 100644 index 00000000..84f75b85 --- /dev/null +++ b/tests/widget-system/rewrite-bootstrap-harness.php @@ -0,0 +1,125 @@ + '', + ]; + $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; +}