Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion src/Route.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down
52 changes: 49 additions & 3 deletions src/Url.php
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
class Url
{
private static ?string $baseUrl = null;
private static ?string $basePath = null;
private static ?array $parsedUrl = null;

/**
Expand Down Expand Up @@ -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();

Expand All @@ -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;
}

/**
Expand Down Expand Up @@ -694,6 +739,7 @@ public static function userAgent(): string
public static function reset(): void
{
self::$baseUrl = null;
self::$basePath = null;
self::$parsedUrl = null;
}

Expand Down
17 changes: 14 additions & 3 deletions src/Vite.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
*/
Expand Down Expand Up @@ -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(
'<link rel="stylesheet" href="%s%s">' . PHP_EOL,
self::PRODUCTION_ASSET_BASE_PATH,
$assetUrlPrefix,
$cssFile
);
}
}

// 2. Main JS file
if (isset($entryData['file'])) {
$output .= sprintf(
'<script type="module" src="%s%s"></script>',
self::PRODUCTION_ASSET_BASE_PATH,
$assetUrlPrefix,
$entryData['file']
);
}
Expand Down
31 changes: 31 additions & 0 deletions tests/RouteTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -1119,6 +1119,37 @@ public function testMiddlewareClassAtMethodCanReturnViewString(): void
$this->assertSame('<h1>Stub view</h1>', $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.'
);
}
}

/**
Expand Down
94 changes: 94 additions & 0 deletions tests/UrlTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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)
// =========================================================================
Expand Down
Loading