diff --git a/src/Route.php b/src/Route.php index 9d7636d..2de2c8f 100644 --- a/src/Route.php +++ b/src/Route.php @@ -300,7 +300,11 @@ public static function setNotFoundHandler($handler): void */ public static function run(): void { - $uri = Url::uri(); + // Match against the request path relative to the app's own + // subdirectory (Url::requestPath()), not the raw request URI, so + // routes keep matching whether the app is installed at the domain + // root or under a subfolder (e.g. http://example.com/blog/). + $uri = Url::requestPath(); $method = self::resolveMethod(); $matchedRoute = null; diff --git a/src/Url.php b/src/Url.php index f6d6069..90f6802 100644 --- a/src/Url.php +++ b/src/Url.php @@ -16,6 +16,7 @@ class Url { private static ?string $baseUrl = null; + private static ?string $basePath = null; private static ?array $parsedUrl = null; /** @@ -99,6 +100,26 @@ public static function base(): string return self::$baseUrl; } + return self::$baseUrl = self::home() . self::basePath(); + } + + /** + * Get the subdirectory the application is installed under, relative to + * the web server's document root (e.g. "/blog" for an app installed at + * http://example.com/blog/, or "" when installed at the domain root). + * + * Derived by diffing the application's root path against DOCUMENT_ROOT, + * so it works regardless of how deep the install sits under the document + * root. + * + * @return string Subdirectory path, without a trailing slash + */ + public static function basePath(): string + { + if (self::$basePath !== null) { + return self::$basePath; + } + $documentRoot = self::documentRoot(); $scriptPath = App::getRootPath(); @@ -108,10 +129,34 @@ public static function base(): string $scriptPath = substr($scriptPath, $position); } - $subdirectory = substr($scriptPath, strlen($documentRoot)); - self::$baseUrl = self::home() . $subdirectory; + return self::$basePath = substr($scriptPath, strlen($documentRoot)); + } + + /** + * Get the current request URI relative to the application's own + * subdirectory, i.e. with the base path from {@see basePath()} stripped + * off the front. + * + * This is what routing should match against: for an app installed at + * http://example.com/blog/, a request for /blog/about resolves here to + * /about, exactly as it would if the app were installed at the domain + * root. When the app is installed at the domain root, this is identical + * to {@see uri()}. + * + * @return string Request path relative to the application base path + */ + public static function requestPath(): string + { + $uri = self::uri(); + $basePath = self::basePath(); + + if ($basePath === '' || !str_starts_with($uri, $basePath)) { + return $uri; + } + + $path = substr($uri, strlen($basePath)); - return self::$baseUrl; + return $path === '' ? '/' : $path; } /** @@ -694,6 +739,7 @@ public static function userAgent(): string public static function reset(): void { self::$baseUrl = null; + self::$basePath = null; self::$parsedUrl = null; } diff --git a/src/Vite.php b/src/Vite.php index ef6a813..6adac56 100644 --- a/src/Vite.php +++ b/src/Vite.php @@ -78,6 +78,16 @@ protected function getManifestPath(): string return $this->basePath . '/public/build/.vite/manifest.json'; } + /** + * Get the URL prefix for built assets, prepending the application's + * subdirectory (if any) so asset tags resolve correctly whether the app + * is installed at the domain root or under a subfolder. + */ + protected function getAssetUrlPrefix(): string + { + return Url::basePath() . self::PRODUCTION_ASSET_BASE_PATH; + } + /** * Generate HTML tags for assets */ @@ -154,23 +164,24 @@ protected function renderProductionTags(string $entryPoint): string $entryData = $manifest[$entryPoint]; $output = ''; + $assetUrlPrefix = $this->getAssetUrlPrefix(); // 1. CSS files (if styles are extracted) if (isset($entryData['css']) && is_array($entryData['css'])) { foreach ($entryData['css'] as $cssFile) { $output .= sprintf( '' . PHP_EOL, - self::PRODUCTION_ASSET_BASE_PATH, + $assetUrlPrefix, $cssFile ); } } - + // 2. Main JS file if (isset($entryData['file'])) { $output .= sprintf( '', - self::PRODUCTION_ASSET_BASE_PATH, + $assetUrlPrefix, $entryData['file'] ); } diff --git a/tests/RouteTest.php b/tests/RouteTest.php index b91e2f3..79aca14 100644 --- a/tests/RouteTest.php +++ b/tests/RouteTest.php @@ -1119,6 +1119,37 @@ public function testMiddlewareClassAtMethodCanReturnViewString(): void $this->assertSame('

Stub view

', $result); $this->assertNotTrue($result); } + + // ========================================================================= + // 13. Subfolder installs (regression: run() must match against the + // request path relative to the app's own base path, not the raw URI) + // ========================================================================= + + /** + * Regression guard for subfolder installs: when the app is not installed + * at the domain root (e.g. http://example.com/blog/), REQUEST_URI carries + * the "/blog" prefix, but registered routes never do. run() must resolve + * its match target through Url::requestPath() — which strips that prefix + * — rather than the raw Url::uri(), or every route would need to be + * hand-prefixed with the install subdirectory. + * + * run() itself calls Header::respond(), which echoes and exits, so it + * cannot be invoked in-process; this asserts on the source the same way + * testRunReturnsAfterMiddlewareFailureSoHandlerNeverRuns() does above. + */ + public function testRunMatchesAgainstRequestPathNotRawUri(): void + { + $source = (new \ReflectionMethod(Route::class, 'run'))->getFileName(); + $start = (new \ReflectionMethod(Route::class, 'run'))->getStartLine(); + $end = (new \ReflectionMethod(Route::class, 'run'))->getEndLine(); + $body = implode('', array_slice(file($source), $start - 1, $end - $start + 1)); + + $this->assertStringContainsString( + 'Url::requestPath()', + $body, + 'run() must match against Url::requestPath(), which is relative to the app\'s base path, so subfolder installs route correctly.' + ); + } } /** diff --git a/tests/UrlTest.php b/tests/UrlTest.php index 9ecda16..164cc61 100644 --- a/tests/UrlTest.php +++ b/tests/UrlTest.php @@ -1001,6 +1001,100 @@ public function testInputPutJsonWithNullableFields(): void $this->assertSame('Test', $result['title']); } + // ========================================================================= + // 23. basePath() / requestPath() / base() — subfolder installs + // ========================================================================= + // + // App::setRootPath() is a separate static that Url::reset() does not + // touch, so every test in this section sets it explicitly (same pattern + // as DirectoryTest) rather than relying on state left over by another + // test. + + public function testBasePathIsEmptyWhenAppRootIsTheDocumentRoot(): void + { + $root = sys_get_temp_dir() . '/webrium_url_test_' . uniqid(); + mkdir($root, 0755, true); + \Webrium\App::setRootPath($root); + $_SERVER['DOCUMENT_ROOT'] = $root; + + $this->assertSame('', Url::basePath()); + + rmdir($root); + } + + public function testBasePathReturnsSubdirectoryWhenAppIsInstalledUnderIt(): void + { + $documentRoot = sys_get_temp_dir() . '/webrium_url_test_' . uniqid(); + $appRoot = $documentRoot . '/blog'; + mkdir($appRoot, 0755, true); + \Webrium\App::setRootPath($appRoot); + $_SERVER['DOCUMENT_ROOT'] = $documentRoot; + + $this->assertSame('/blog', Url::basePath()); + + rmdir($appRoot); + rmdir($documentRoot); + } + + public function testBaseAppendsBasePathToHome(): void + { + $documentRoot = sys_get_temp_dir() . '/webrium_url_test_' . uniqid(); + $appRoot = $documentRoot . '/blog'; + mkdir($appRoot, 0755, true); + \Webrium\App::setRootPath($appRoot); + $_SERVER['DOCUMENT_ROOT'] = $documentRoot; + $_SERVER['HTTP_HOST'] = 'example.com'; + + $this->assertSame('http://example.com/blog', Url::base()); + + rmdir($appRoot); + rmdir($documentRoot); + } + + public function testRequestPathStripsBasePathForSubfolderInstall(): void + { + $documentRoot = sys_get_temp_dir() . '/webrium_url_test_' . uniqid(); + $appRoot = $documentRoot . '/blog'; + mkdir($appRoot, 0755, true); + \Webrium\App::setRootPath($appRoot); + $_SERVER['DOCUMENT_ROOT'] = $documentRoot; + $_SERVER['REQUEST_URI'] = '/blog/posts/42'; + + $this->assertSame('/posts/42', Url::requestPath()); + + rmdir($appRoot); + rmdir($documentRoot); + } + + public function testRequestPathOfSubfolderRootResolvesToSlash(): void + { + $documentRoot = sys_get_temp_dir() . '/webrium_url_test_' . uniqid(); + $appRoot = $documentRoot . '/blog'; + mkdir($appRoot, 0755, true); + \Webrium\App::setRootPath($appRoot); + $_SERVER['DOCUMENT_ROOT'] = $documentRoot; + $_SERVER['REQUEST_URI'] = '/blog'; + + $this->assertSame('/', Url::requestPath()); + + rmdir($appRoot); + rmdir($documentRoot); + } + + public function testRequestPathMatchesUriWhenAppIsAtDocumentRoot(): void + { + $root = sys_get_temp_dir() . '/webrium_url_test_' . uniqid(); + mkdir($root, 0755, true); + \Webrium\App::setRootPath($root); + $_SERVER['DOCUMENT_ROOT'] = $root; + $_SERVER['REQUEST_URI'] = '/posts/42'; + + $this->assertSame(Url::uri(), Url::requestPath()); + $this->assertSame('/posts/42', Url::requestPath()); + + rmdir($root); + } + // ========================================================================= // Test Infrastructure (private helpers) // =========================================================================