From f9692543afc37b0072f82cf44323d868822c33c9 Mon Sep 17 00:00:00 2001 From: Abdul Kaioum Date: Wed, 28 Oct 2026 14:26:23 +0600 Subject: [PATCH 1/3] fix(router): match StaticRouter root route on the page root 851a497 made requestPath() trim slashes off WP::$request, so /portal/ arrives as /portal. isRouteMatched() still built "portal/" for a root route, compiling to ~^/portal//?$~, which needs a slash after the page name. Root routes, and empty paths inside a prefix group, stopped matching and SSR content was lost on the page root. Route paths are now joined by joinPath(), which skips empty segments, so a root route compiles to ~^/portal/?$~ and matches with or without the trailing slash. Fixes #20 Assisted-By: AI --- src/Http/Router/StaticRouter.php | 12 +++- tests/Router/StaticRoutingTest.php | 100 ++++++++++++++++++++++++++++- 2 files changed, 108 insertions(+), 4 deletions(-) diff --git a/src/Http/Router/StaticRouter.php b/src/Http/Router/StaticRouter.php index 3a3963e..9c5ead4 100644 --- a/src/Http/Router/StaticRouter.php +++ b/src/Http/Router/StaticRouter.php @@ -215,12 +215,20 @@ private function routePath(RouteRegister $route): string $prefix = trim((string) $route->getRoutePrefix(), '/'); $path = trim((string) $route->getPath(), '/'); - return $prefix === '' ? $path : $prefix . '/' . $path; + return self::joinPath($prefix, $path); + } + + /** + * Joins path segments with '/', skipping empty ones so a root route leaves no trailing slash. + */ + private static function joinPath(string ...$segments): string + { + return implode('/', array_filter($segments, static fn (string $segment): bool => $segment !== '')); } private function isRouteMatched(RouteRegister $route, string $requestPath): bool { - $path = $this->pageName . '/' . $this->routePath($route); + $path = self::joinPath($this->pageName, $this->routePath($route)); $compiled = RoutePattern::compile($path); $pattern = $compiled === null ? preg_quote($path, '~') : $compiled['regex']; diff --git a/tests/Router/StaticRoutingTest.php b/tests/Router/StaticRoutingTest.php index fae7889..8add960 100644 --- a/tests/Router/StaticRoutingTest.php +++ b/tests/Router/StaticRoutingTest.php @@ -558,13 +558,109 @@ public function testRootRuleCoexistsWithOtherRoutes(): void assertTest(isset(WpKitTestState::$rewriteRules['^landing/entries/([^/]+)/?$']), 'route rule missing next to the root rule'); } - private function makeTransport(array $routes = []): StaticRouter + public function testRootRouteMatchesTheParsedPageRoot(): void + { + $this->makeTransport(['/' => static fn () => 'home']); + $this->setParsedRequest('landing'); + + do_action('template_redirect'); + + assertSameValue('page-home', apply_filters('the_content', 'page-'), 'root route did not match the parsed page root'); + } + + public function testRootRouteMatchesThePageRootOnSubdirectoryInstalls(): void + { + $this->makeTransport(['/' => static fn () => 'home']); + WpKitTestState::$options['home'] = 'https://example.test/blog'; + $_SERVER['REQUEST_URI'] = '/blog/landing'; + + do_action('template_redirect'); + + assertSameValue('page-home', apply_filters('the_content', 'page-'), 'root route did not match on a subdirectory install'); + } + + public function testRootRouteMatchesThePageRootFromTheRequestUri(): void + { + $this->makeTransport(['/' => static fn () => 'home']); + $_SERVER['REQUEST_URI'] = '/landing'; + + do_action('template_redirect'); + + assertSameValue('page-home', apply_filters('the_content', 'page-'), 'root route did not match a page root without a trailing slash'); + } + + public function testRootRouteMatchesThePageRootFromATrailingSlashRequestUri(): void + { + $this->makeTransport(['/' => static fn () => 'home']); + $_SERVER['REQUEST_URI'] = '/landing/?preview=1'; + + do_action('template_redirect'); + + assertSameValue('page-home', apply_filters('the_content', 'page-'), 'root route did not match a page root with a trailing slash'); + } + + public function testRootRouteDoesNotMatchDeeperPaths(): void + { + $this->makeTransport(['/' => static fn () => 'home']); + $this->setParsedRequest('landing/entries/42'); + + do_action('template_redirect'); + + assertTest(!isset(WpKitTestState::$filters['the_content']), 'root route matched a deeper path'); + } + + public function testRootRouteDoesNotMatchAPageSharingItsNamePrefix(): void + { + $this->makeTransport(['/' => static fn () => 'home']); + $this->setParsedRequest('landing-archive'); + + do_action('template_redirect'); + + assertTest(!isset(WpKitTestState::$filters['the_content']), 'root route matched another page sharing its name prefix'); + } + + public function testRootRouteDoesNotShadowSiblingRoutes(): void + { + $this->makeTransport([ + '/' => static fn () => 'home', + 'user/{id}' => static fn ($id) => 'user ' . $id, + ]); + $this->setParsedRequest('landing/user/5'); + + do_action('template_redirect'); + + assertSameValue('page-user 5', apply_filters('the_content', 'page-'), 'root route shadowed a sibling route'); + } + + public function testEmptyPathInsideAPrefixMatchesThePrefixRoot(): void + { + $this->makeTransport(['/' => static fn () => 'account'], 'account'); + $this->setParsedRequest('landing/account'); + + do_action('template_redirect'); + + assertSameValue('page-account', apply_filters('the_content', 'page-'), 'empty path inside a prefix did not match the prefix root'); + } + + /** + * Registers the given GET routes, optionally under a prefix, on a static router for the landing page. + */ + private function makeTransport(array $routes = [], ?string $prefix = null): StaticRouter { new Router('static', 'landing', null); foreach ($routes as $path => $action) { - (new RouteBase())->get($path, $action); + (new RouteBase())->prefix($prefix)->get($path, $action); } return new StaticRouter('landing', 'plugin_activate', 'plugin_deactivate'); } + + /** + * Sets the parsed WP request path that the router reads from the global WP object. + */ + private function setParsedRequest(string $path): void + { + $GLOBALS['wp'] = new \WP(); + $GLOBALS['wp']->request = $path; + } } From 963fd00d48ad778cfd5e56f1e71372169b7a769b Mon Sep 17 00:00:00 2001 From: Abdul Kaioum Date: Wed, 28 Oct 2026 14:33:44 +0600 Subject: [PATCH 2/3] chore(request): add return types required by rector 2.7 InteractsWithHeaders landed (#19) after the rector 2.7 bump (#18), so the CI rector dry-run has failed on main since. The types are safe to add: the trait is unreleased, so no consumer overrides exist yet. Assisted-By: AI --- src/Http/Request/InteractsWithHeaders.php | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/Http/Request/InteractsWithHeaders.php b/src/Http/Request/InteractsWithHeaders.php index f0f43e7..2cf4fbf 100644 --- a/src/Http/Request/InteractsWithHeaders.php +++ b/src/Http/Request/InteractsWithHeaders.php @@ -19,7 +19,7 @@ trait InteractsWithHeaders * * @return array */ - public function headers() + public function headers(): array { return array_map('sanitize_text_field', $this->rawHeaders()); } @@ -51,7 +51,7 @@ public function rawHeader(string $name, ?string $default = null) * * @return bool */ - public function hasHeader(string $name) + public function hasHeader(string $name): bool { return \array_key_exists(self::canonicalHeaderName($name), $this->rawHeaders()); } @@ -64,7 +64,7 @@ public function hasHeader(string $name) private function rawHeaders(): array { if (isset($this->rest) && $this->rest instanceof WP_REST_Request) { - return array_map(static fn ($values) => implode(', ', (array) $values), (array) $this->rest->get_headers()); + return array_map(static fn ($values): string => implode(', ', (array) $values), (array) $this->rest->get_headers()); } return $this->serverHeaders(); From 7936cd31abc406144ba2c6b380020a7d5be2f7f0 Mon Sep 17 00:00:00 2001 From: Abdul Kaioum Date: Wed, 28 Oct 2026 14:36:46 +0600 Subject: [PATCH 3/3] chore(router): type processParameters reflection params as array Rector 2.7 with phpstan 2.3 on PHP 8.4+ infers the array type from the getParameters() callers and fails the CI dry-run. The method is private, so the type is BC-safe. Assisted-By: AI --- src/Http/Router/RouteRegister.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Http/Router/RouteRegister.php b/src/Http/Router/RouteRegister.php index b9018e9..cf09340 100644 --- a/src/Http/Router/RouteRegister.php +++ b/src/Http/Router/RouteRegister.php @@ -526,7 +526,7 @@ private function invokeAsReflectionFunction(callable $method): mixed return $reflectionFunction->invoke(...$params); } - private function processParameters($reflectionParams, array $params = []): array + private function processParameters(array $reflectionParams, array $params = []): array { $requestParams = []; foreach ($reflectionParams as $param) {