diff --git a/bin/dev-router.php b/bin/dev-router.php index 3ca4bcd..c1f1c8c 100644 --- a/bin/dev-router.php +++ b/bin/dev-router.php @@ -13,6 +13,7 @@ use Glaze\Http\Middleware\ErrorHandlingMiddleware; use Glaze\Http\Middleware\PublicAssetMiddleware; use Glaze\Http\Middleware\StaticAssetMiddleware; +use Glaze\Http\StaticPageRequestHandler; require dirname(__DIR__) . '/vendor/autoload.php'; @@ -28,6 +29,7 @@ } $includeDrafts = getenv('GLAZE_INCLUDE_DRAFTS') === '1'; +$staticMode = getenv('GLAZE_STATIC_MODE') === '1'; $application = new Application(); $application->bootstrap(); @@ -45,8 +47,14 @@ $staticAssetMiddleware = $container->get(StaticAssetMiddleware::class); /** @var \Glaze\Http\Middleware\ContentAssetMiddleware $contentAssetMiddleware */ $contentAssetMiddleware = $container->get(ContentAssetMiddleware::class); -/** @var \Glaze\Http\DevPageRequestHandler $devPageRequestHandler */ -$devPageRequestHandler = $container->get(DevPageRequestHandler::class); + +if ($staticMode) { + /** @var \Glaze\Http\StaticPageRequestHandler $fallbackHandler */ + $fallbackHandler = $container->get(StaticPageRequestHandler::class); +} else { + /** @var \Glaze\Http\DevPageRequestHandler $fallbackHandler */ + $fallbackHandler = $container->get(DevPageRequestHandler::class); +} $requestMethod = $_SERVER['REQUEST_METHOD'] ?? null; if (!is_string($requestMethod) || $requestMethod === '') { @@ -74,7 +82,7 @@ $queue->add($staticAssetMiddleware); $queue->add($contentAssetMiddleware); -$response = (new Runner())->run($queue, $request, $devPageRequestHandler); +$response = (new Runner())->run($queue, $request, $fallbackHandler); (new ResponseEmitter())->emit($response); diff --git a/src/Command/ServeCommand.php b/src/Command/ServeCommand.php index 342b391..4732564 100644 --- a/src/Command/ServeCommand.php +++ b/src/Command/ServeCommand.php @@ -128,16 +128,15 @@ public function execute(Arguments $args, ConsoleIo $io): int $isStaticMode = (bool)$args->getOption('static'); $includeDrafts = !$isStaticMode || (bool)$args->getOption('drafts'); - $viteConfiguration = $this->resolveViteConfiguration($args); - /** @var array{enabled: bool, host: string, port: int, command: string} $viteConfiguration */ - if ($viteConfiguration['enabled'] && $isStaticMode) { + if ((bool)$args->getOption('vite') && $isStaticMode) { $io->err('--vite can only be used in live mode (without --static).'); return self::CODE_ERROR; } - $docRoot = $isStaticMode ? $projectRoot . DIRECTORY_SEPARATOR . 'public' : $projectRoot; + $viteConfiguration = $this->resolveViteConfiguration($args, $isStaticMode); + /** @var array{enabled: bool, host: string, port: int, command: string} $viteConfiguration */ if ((bool)$args->getOption('build')) { if (!$isStaticMode) { @@ -158,8 +157,9 @@ public function execute(Arguments $args, ConsoleIo $io): int } } - if (!is_dir($docRoot)) { - $io->err(sprintf('Public directory not found: %s', $docRoot)); + $staticPublicDir = $projectRoot . DIRECTORY_SEPARATOR . 'public'; + if ($isStaticMode && !is_dir($staticPublicDir)) { + $io->err(sprintf('Public directory not found: %s', $staticPublicDir)); return self::CODE_ERROR; } @@ -168,11 +168,9 @@ public function execute(Arguments $args, ConsoleIo $io): int $phpServerConfiguration = $this->resolvePhpServerConfiguration( $args, $projectRoot, - $docRoot, - $isStaticMode, $verbose, ); - /** @var array{host: string, port: int, docRoot: string, projectRoot: string, staticMode: bool, streamOutput: bool} $phpServerConfiguration */ + /** @var array{host: string, port: int, docRoot: string, projectRoot: string, streamOutput: bool} $phpServerConfiguration */ $this->phpServerProcess->assertCanRun($phpServerConfiguration); } catch (InvalidArgumentException $invalidArgumentException) { $io->err(sprintf('%s', $invalidArgumentException->getMessage())); @@ -183,7 +181,7 @@ public function execute(Arguments $args, ConsoleIo $io): int $address = $this->phpServerProcess->address($phpServerConfiguration); if ($verbose) { if ($isStaticMode) { - $io->out(sprintf('Serving static output from %s at http://%s', $docRoot, $address)); + $io->out(sprintf('Serving static output from %s at http://%s', $staticPublicDir, $address)); } else { $io->out(sprintf('Serving live templates/content from %s at http://%s', $projectRoot, $address)); } @@ -197,12 +195,9 @@ public function execute(Arguments $args, ConsoleIo $io): int $io->out(sprintf('Glaze development server: http://%s', $address)); } - $previousEnvironment = []; - if (!$isStaticMode) { - $previousEnvironment = $this->applyEnvironment( - $this->buildLiveEnvironment($projectRoot, $includeDrafts, $viteConfiguration), - ); - } + $previousEnvironment = $this->applyEnvironment( + $this->buildRouterEnvironment($projectRoot, $includeDrafts, $viteConfiguration, $isStaticMode), + ); $viteProcess = null; if (!$isStaticMode && $viteConfiguration['enabled']) { @@ -239,30 +234,33 @@ static function (string $type, string $buffer) use ($io): void { ); } finally { $this->viteServeProcess->stop($viteProcess); - - if (!$isStaticMode) { - $this->restoreEnvironment($previousEnvironment); - } + $this->restoreEnvironment($previousEnvironment); } return $exitCode; } /** - * Build environment variables for live router execution. + * Build environment variables for the router process. + * + * Sets all variables the dev-router reads at boot time. GLAZE_STATIC_MODE + * tells the router to use StaticPageRequestHandler instead of live rendering. * * @param string $projectRoot Project root directory. * @param bool $includeDrafts Whether draft pages should be included. * @param array{enabled: bool, host: string, port: int, command: string} $viteConfiguration Vite runtime configuration. + * @param bool $isStaticMode Whether static serving mode is active. * @return array */ - protected function buildLiveEnvironment( + protected function buildRouterEnvironment( string $projectRoot, bool $includeDrafts, array $viteConfiguration, + bool $isStaticMode, ): array { return [ 'GLAZE_PROJECT_ROOT' => $projectRoot, + 'GLAZE_STATIC_MODE' => $isStaticMode ? '1' : '0', 'GLAZE_INCLUDE_DRAFTS' => $includeDrafts ? '1' : '0', 'GLAZE_VITE_ENABLED' => $viteConfiguration['enabled'] ? '1' : '0', 'GLAZE_VITE_URL' => $viteConfiguration['enabled'] ? $this->viteServeProcess->url($viteConfiguration) : '', @@ -274,19 +272,21 @@ protected function buildLiveEnvironment( * * Reads devServer.vite values from Configure (populated by the * ProjectConfigurationReader call in execute()) and merges with - * any CLI overrides. + * any CLI overrides. When $isStaticMode is true, vite is always disabled + * regardless of configuration, since static serving does not use Vite. * * @param \Cake\Console\Arguments $args Parsed CLI arguments. + * @param bool $isStaticMode Whether static mode is active. * @return array{enabled: bool, host: string, port: int, command: string} */ - protected function resolveViteConfiguration(Arguments $args): array + protected function resolveViteConfiguration(Arguments $args, bool $isStaticMode = false): array { $viteConfig = Configure::read('devServer.vite'); if (!is_array($viteConfig)) { $viteConfig = []; } - $enabledFromConfig = is_bool($viteConfig['enabled'] ?? null) && $viteConfig['enabled']; + $enabledFromConfig = !$isStaticMode && is_bool($viteConfig['enabled'] ?? null) && $viteConfig['enabled']; $enabled = (bool)$args->getOption('vite') || $enabledFromConfig; $host = Normalization::optionalString($args->getOption('vite-host')) @@ -319,17 +319,13 @@ protected function resolveViteConfiguration(Arguments $args): array * Resolve PHP server runtime configuration from Configure state and CLI options. * * @param \Cake\Console\Arguments $args Parsed CLI arguments. - * @param string $projectRoot Project root directory. - * @param string $docRoot PHP server document root. - * @param bool $isStaticMode Whether static mode is enabled. + * @param string $projectRoot Project root directory (used as both docRoot and projectRoot). * @param bool $streamOutput Whether process output should be streamed. - * @return array{host: string, port: int, docRoot: string, projectRoot: string, staticMode: bool, streamOutput: bool} + * @return array{host: string, port: int, docRoot: string, projectRoot: string, streamOutput: bool} */ protected function resolvePhpServerConfiguration( Arguments $args, string $projectRoot, - string $docRoot, - bool $isStaticMode, bool $streamOutput = false, ): array { $phpConfig = Configure::read('devServer.php'); @@ -354,9 +350,8 @@ protected function resolvePhpServerConfiguration( return [ 'host' => $host, 'port' => $port, - 'docRoot' => $docRoot, + 'docRoot' => $projectRoot, 'projectRoot' => $projectRoot, - 'staticMode' => $isStaticMode, 'streamOutput' => $streamOutput, ]; } diff --git a/src/Http/Concern/BasePathAwareTrait.php b/src/Http/Concern/BasePathAwareTrait.php new file mode 100644 index 0000000..d3ba6d5 --- /dev/null +++ b/src/Http/Concern/BasePathAwareTrait.php @@ -0,0 +1,65 @@ +basePath` attribute holds the configured base path (or null when + * no base path is configured). + * + * Example: + * + * ```php + * final class MyHandler implements RequestHandlerInterface + * { + * use BasePathAwareTrait; + * + * public function __construct(protected BuildConfig $config) {} + * } + * ``` + */ +trait BasePathAwareTrait +{ + /** + * Strip the configured base path prefix from an incoming request path. + * + * Returns a normalized path that starts with `/` and has the base path + * prefix removed, or the original path when no base path is configured or + * the path does not start with the prefix. + * + * @param string $requestPath Incoming request path. + */ + protected function stripBasePathFromRequestPath(string $requestPath): string + { + $basePath = $this->config->site->basePath; + $normalizedPath = '/' . ltrim($requestPath, '/'); + + if ($basePath === null || $basePath === '') { + return $normalizedPath; + } + + return $this->stripPathPrefix($normalizedPath, $basePath); + } + + /** + * Strip a leading prefix from a path, returning the remainder or `/` when the path matches exactly. + * + * @param string $path Normalized request path. + * @param string $prefix Prefix to strip (e.g. `/docs` or `/static`). + */ + protected function stripPathPrefix(string $path, string $prefix): string + { + if ($path === $prefix) { + return '/'; + } + + if (str_starts_with($path, $prefix . '/')) { + return substr($path, strlen($prefix)) ?: '/'; + } + + return $path; + } +} diff --git a/src/Http/DevPageRequestHandler.php b/src/Http/DevPageRequestHandler.php index e67c8ff..8bca246 100644 --- a/src/Http/DevPageRequestHandler.php +++ b/src/Http/DevPageRequestHandler.php @@ -6,6 +6,7 @@ use Cake\Http\Response; use Glaze\Build\SiteBuilder; use Glaze\Config\BuildConfig; +use Glaze\Http\Concern\BasePathAwareTrait; use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\ServerRequestInterface; use Psr\Http\Server\RequestHandlerInterface; @@ -15,6 +16,8 @@ */ final class DevPageRequestHandler implements RequestHandlerInterface { + use BasePathAwareTrait; + /** * Per-instance cache of rendered not-found page HTML keyed by lookup path. * Null indicates the path was attempted but yielded no rendered page. @@ -163,29 +166,4 @@ protected function redirectLocation(string $path, string $query): string return $path . '?' . $query; } - - /** - * Strip configured base path prefix from incoming request path. - * - * @param string $requestPath Incoming request path. - */ - protected function stripBasePathFromRequestPath(string $requestPath): string - { - $basePath = $this->config->site->basePath; - $normalizedPath = '/' . ltrim($requestPath, '/'); - - if ($basePath === null || $basePath === '') { - return $normalizedPath; - } - - if ($normalizedPath === $basePath) { - return '/'; - } - - if (str_starts_with($normalizedPath, $basePath . '/')) { - return substr($normalizedPath, strlen($basePath)) ?: '/'; - } - - return $normalizedPath; - } } diff --git a/src/Http/Middleware/AbstractAssetMiddleware.php b/src/Http/Middleware/AbstractAssetMiddleware.php index 775b696..c64ee81 100644 --- a/src/Http/Middleware/AbstractAssetMiddleware.php +++ b/src/Http/Middleware/AbstractAssetMiddleware.php @@ -5,6 +5,7 @@ use Glaze\Config\BuildConfig; use Glaze\Http\AssetResponder; +use Glaze\Http\Concern\BasePathAwareTrait; use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\ServerRequestInterface; use Psr\Http\Server\MiddlewareInterface; @@ -15,6 +16,8 @@ */ abstract class AbstractAssetMiddleware implements MiddlewareInterface { + use BasePathAwareTrait; + /** * Constructor. * @@ -102,42 +105,6 @@ protected function urlPrefix(): ?string return null; } - /** - * Strip configured base path from request path for filesystem resolution. - * - * @param string $requestPath Request URI path. - */ - protected function stripBasePathFromRequestPath(string $requestPath): string - { - $basePath = $this->config->site->basePath; - $normalizedPath = '/' . ltrim($requestPath, '/'); - - if ($basePath === null || $basePath === '') { - return $normalizedPath; - } - - return $this->stripPathPrefix($normalizedPath, $basePath); - } - - /** - * Strip a leading prefix from a path, returning the remainder or `/` when the path matches exactly. - * - * @param string $path Normalized request path. - * @param string $prefix Prefix to strip (e.g. `/glaze` or `/static`). - */ - protected function stripPathPrefix(string $path, string $prefix): string - { - if ($path === $prefix) { - return '/'; - } - - if (str_starts_with($path, $prefix . '/')) { - return substr($path, strlen($prefix)) ?: '/'; - } - - return $path; - } - /** * Resolve filesystem root path for the middleware asset scope. */ diff --git a/src/Http/StaticPageRequestHandler.php b/src/Http/StaticPageRequestHandler.php new file mode 100644 index 0000000..1a4b71e --- /dev/null +++ b/src/Http/StaticPageRequestHandler.php @@ -0,0 +1,197 @@ +getUri()->getPath(); + $requestPath = $requestPath !== '' ? $requestPath : '/'; + + $lookupPath = $this->stripBasePathFromRequestPath($requestPath); + + // Redirect extensionless non-root paths without a trailing slash to their + // canonical form so that relative URLs in the served HTML resolve correctly. + $isExtensionlessNonRoot = $lookupPath !== '/' + && !str_ends_with($requestPath, '/') + && pathinfo($lookupPath, PATHINFO_EXTENSION) === ''; + if ($isExtensionlessNonRoot) { + $canonicalPath = $requestPath . '/'; + + return (new Response(['charset' => 'UTF-8'])) + ->withStatus(301) + ->withHeader('Location', $this->redirectLocation($canonicalPath, $request->getUri()->getQuery())); + } + + $indexHtmlPath = $this->resolveIndexHtmlPath($lookupPath); + if ($indexHtmlPath !== null) { + $content = file_get_contents($indexHtmlPath); + + return (new Response(['charset' => 'UTF-8'])) + ->withStatus(200) + ->withHeader('Content-Type', 'text/html; charset=UTF-8') + ->withStringBody($content !== false ? $content : ''); + } + + $notFoundPath = $this->resolveNotFoundPath($lookupPath); + $notFoundHtml = $this->readOutputFile($notFoundPath); + if ($notFoundHtml === null && $notFoundPath !== '/404.html') { + $notFoundHtml = $this->readOutputFile('/404.html'); + } + + return (new Response(['charset' => 'UTF-8'])) + ->withStatus(404) + ->withHeader('Content-Type', 'text/html; charset=UTF-8') + ->withStringBody($notFoundHtml ?? '

404 Not Found

'); + } + + /** + * Resolve the pre-built index.html path for a request path within the output directory. + * + * Returns null when no matching index.html exists or the resolved path escapes + * the output root (path traversal protection). + * + * @param string $lookupPath Basepath-stripped request path. + */ + protected function resolveIndexHtmlPath(string $lookupPath): ?string + { + $outputRoot = realpath($this->config->outputPath()); + if (!is_string($outputRoot)) { + return null; + } + + $relativePath = ltrim($lookupPath, '/'); + $dirCandidate = $outputRoot + . ($relativePath !== '' ? DIRECTORY_SEPARATOR . str_replace('/', DIRECTORY_SEPARATOR, $relativePath) : ''); + + $resolvedDir = realpath($dirCandidate); + if (!is_string($resolvedDir) || !is_dir($resolvedDir)) { + return null; + } + + // Path traversal protection: resolved directory must remain within the output root. + if ($resolvedDir !== $outputRoot && !str_starts_with($resolvedDir, $outputRoot . DIRECTORY_SEPARATOR)) { + return null; + } + + $indexPath = $resolvedDir . DIRECTORY_SEPARATOR . 'index.html'; + + return is_file($indexPath) ? $indexPath : null; + } + + /** + * Determine which 404 page to serve for a failed lookup path. + * + * When i18n is enabled and the request begins with a known language URL + * prefix (e.g. `/nl/…`), returns the language-scoped 404 path + * (e.g. `/nl/404.html`). Falls back to `/404.html` for all other cases. + * + * @param string $lookupPath Basepath-stripped request path. + */ + protected function resolveNotFoundPath(string $lookupPath): string + { + if ($this->config->i18n->isEnabled()) { + foreach ($this->config->i18n->languages as $language) { + if ($language->urlPrefix === '') { + continue; + } + + $prefix = '/' . $language->urlPrefix; + if ($lookupPath === $prefix || str_starts_with($lookupPath, $prefix . '/')) { + return $prefix . '/404.html'; + } + } + } + + return '/404.html'; + } + + /** + * Read a pre-built file from the output directory by its URL path. + * + * Returns null when the file does not exist or cannot be read. + * + * @param string $urlPath URL path relative to the output root (e.g. `/404.html`). + */ + protected function readOutputFile(string $urlPath): ?string + { + $outputRoot = realpath($this->config->outputPath()); + if (!is_string($outputRoot)) { + return null; + } + + $relative = ltrim($urlPath, '/'); + $candidate = $outputRoot . DIRECTORY_SEPARATOR . str_replace('/', DIRECTORY_SEPARATOR, $relative); + $resolved = realpath($candidate); + + if (!is_string($resolved) || !is_file($resolved)) { + return null; + } + + if (!str_starts_with($resolved, $outputRoot . DIRECTORY_SEPARATOR)) { + return null; + } + + $content = file_get_contents($resolved); + + return $content !== false ? $content : null; + } + + /** + * Build redirect location preserving query string. + * + * @param string $path Canonical path. + * @param string $query Original query string. + */ + protected function redirectLocation(string $path, string $query): string + { + if ($query === '') { + return $path; + } + + return $path . '?' . $query; + } +} diff --git a/src/Process/PhpServerProcess.php b/src/Process/PhpServerProcess.php index a2e26b7..30e6734 100644 --- a/src/Process/PhpServerProcess.php +++ b/src/Process/PhpServerProcess.php @@ -63,6 +63,7 @@ protected function forwardedEnvironmentVariables(): array { $variables = [ 'GLAZE_PROJECT_ROOT', + 'GLAZE_STATIC_MODE', 'GLAZE_INCLUDE_DRAFTS', 'GLAZE_VITE_ENABLED', 'GLAZE_VITE_URL', @@ -124,24 +125,20 @@ public function address(array $configuration): string /** * Build command string for the PHP built-in server. * + * Both live and static modes use the dev-router.php script. The router reads + * the GLAZE_STATIC_MODE environment variable to select the appropriate + * fallback handler at runtime. + * * @param array $configuration Server configuration. */ protected function buildCommand(array $configuration): string { $this->assertConfiguration($configuration); - if ($configuration['staticMode']) { - return sprintf( - 'php -S %s -t %s', - escapeshellarg($this->addressFromConfiguration($configuration)), - escapeshellarg($configuration['docRoot']), - ); - } - - $routerPath = $this->resolveLiveRouterPath($configuration['projectRoot']); + $routerPath = $this->resolveRouterPath($configuration['projectRoot']); if (!is_string($routerPath)) { throw new InvalidArgumentException(sprintf( - 'Live router script not found: %s', + 'Router script not found: %s', $configuration['projectRoot'] . DIRECTORY_SEPARATOR . 'bin' . DIRECTORY_SEPARATOR . 'dev-router.php', )); } @@ -170,7 +167,7 @@ protected function addressFromConfiguration(array $configuration): string * Validate configuration payload. * * @param array $configuration Server configuration. - * @phpstan-assert array{host: string, port: int, docRoot: string, projectRoot: string, staticMode: bool, streamOutput?: bool} $configuration + * @phpstan-assert array{host: string, port: int, docRoot: string, projectRoot: string, streamOutput?: bool} $configuration */ protected function assertConfiguration(array $configuration): void { @@ -195,13 +192,6 @@ protected function assertConfiguration(array $configuration): void )); } - if (!array_key_exists('staticMode', $configuration) || !is_bool($configuration['staticMode'])) { - throw new InvalidArgumentException(sprintf( - 'Invalid configuration for %s. Missing or invalid value for "staticMode".', - self::class, - )); - } - if (array_key_exists('streamOutput', $configuration) && !is_bool($configuration['streamOutput'])) { throw new InvalidArgumentException(sprintf( 'Invalid configuration for %s. Missing or invalid value for "streamOutput".', @@ -211,11 +201,14 @@ protected function assertConfiguration(array $configuration): void } /** - * Resolve live router path for the given project root. + * Resolve the router script path for the given project root. + * + * Checks the project's own bin/ directory first, then falls back to the + * CLI package root (via GLAZE_CLI_ROOT) and finally the package itself. * * @param string $projectRoot Project root directory. */ - protected function resolveLiveRouterPath(string $projectRoot): ?string + protected function resolveRouterPath(string $projectRoot): ?string { $projectRouterPath = $projectRoot . DIRECTORY_SEPARATOR . 'bin' . DIRECTORY_SEPARATOR . 'dev-router.php'; if (is_file($projectRouterPath)) { diff --git a/tests/Integration/Command/ServeCommandTest.php b/tests/Integration/Command/ServeCommandTest.php index 860e31f..cc93b51 100644 --- a/tests/Integration/Command/ServeCommandTest.php +++ b/tests/Integration/Command/ServeCommandTest.php @@ -71,7 +71,7 @@ public function testServeCommandBuildRequiresStaticMode(): void } /** - * Ensure Vite integration is rejected when static mode is enabled. + * Ensure Vite integration is rejected when --vite is explicitly passed with --static. */ public function testServeCommandViteRequiresLiveMode(): void { @@ -138,7 +138,7 @@ public function testServeCommandLiveModeFailsWhenRouterIsMissing(): void $this->exec(sprintf('serve --root "%s"', $projectRoot)); $this->assertExitCode(1); - $this->assertErrorContains('Live router script not found'); + $this->assertErrorContains('Router script not found'); } /** diff --git a/tests/Unit/Command/ServeCommandTest.php b/tests/Unit/Command/ServeCommandTest.php index 5d4fd82..2fd5a51 100644 --- a/tests/Unit/Command/ServeCommandTest.php +++ b/tests/Unit/Command/ServeCommandTest.php @@ -84,9 +84,9 @@ public function testNormalizeHelpers(): void } /** - * Ensure live environment payload reflects includeDrafts state. + * Ensure router environment payload reflects includeDrafts and staticMode state. */ - public function testBuildLiveEnvironmentContainsExpectedValues(): void + public function testBuildRouterEnvironmentContainsExpectedValues(): void { $command = $this->createCommand(); $viteEnabled = [ @@ -102,23 +102,25 @@ public function testBuildLiveEnvironmentContainsExpectedValues(): void 'command' => 'npm run dev -- --host {host} --port {port} --strictPort', ]; - $enabled = $this->callProtected($command, 'buildLiveEnvironment', '/tmp/project', true, $viteDisabled); - $disabled = $this->callProtected($command, 'buildLiveEnvironment', '/tmp/project', false, $viteDisabled); - $enabledWithVite = $this->callProtected($command, 'buildLiveEnvironment', '/tmp/project', true, $viteEnabled); - - $this->assertIsArray($enabled); - $this->assertIsArray($disabled); - $this->assertIsArray($enabledWithVite); - /** @var array $enabled */ - /** @var array $disabled */ - /** @var array $enabledWithVite */ - $this->assertSame('/tmp/project', $enabled['GLAZE_PROJECT_ROOT']); - $this->assertSame('1', $enabled['GLAZE_INCLUDE_DRAFTS']); - $this->assertSame('0', $disabled['GLAZE_INCLUDE_DRAFTS']); - $this->assertSame('0', $disabled['GLAZE_VITE_ENABLED']); - $this->assertSame('', $disabled['GLAZE_VITE_URL']); - $this->assertSame('1', $enabledWithVite['GLAZE_VITE_ENABLED']); - $this->assertSame('http://127.0.0.1:5173', $enabledWithVite['GLAZE_VITE_URL']); + $live = $this->callProtected($command, 'buildRouterEnvironment', '/tmp/project', true, $viteDisabled, false); + $static = $this->callProtected($command, 'buildRouterEnvironment', '/tmp/project', false, $viteDisabled, true); + $liveWithVite = $this->callProtected($command, 'buildRouterEnvironment', '/tmp/project', true, $viteEnabled, false); + + $this->assertIsArray($live); + $this->assertIsArray($static); + $this->assertIsArray($liveWithVite); + /** @var array $live */ + /** @var array $static */ + /** @var array $liveWithVite */ + $this->assertSame('/tmp/project', $live['GLAZE_PROJECT_ROOT']); + $this->assertSame('0', $live['GLAZE_STATIC_MODE']); + $this->assertSame('1', $live['GLAZE_INCLUDE_DRAFTS']); + $this->assertSame('1', $static['GLAZE_STATIC_MODE']); + $this->assertSame('0', $static['GLAZE_INCLUDE_DRAFTS']); + $this->assertSame('0', $live['GLAZE_VITE_ENABLED']); + $this->assertSame('', $live['GLAZE_VITE_URL']); + $this->assertSame('1', $liveWithVite['GLAZE_VITE_ENABLED']); + $this->assertSame('http://127.0.0.1:5173', $liveWithVite['GLAZE_VITE_URL']); } /** @@ -175,6 +177,34 @@ public function testResolveViteConfigurationFromConfigAndCliOverrides(): void $this->assertSame('pnpm dev --host {host} --port {port}', $resolvedFromCli['command']); } + /** + * Ensure Vite is silently disabled when static mode is active, even if enabled in config. + */ + public function testResolveViteConfigurationDisabledInStaticMode(): void + { + $projectRoot = $this->createTempDirectory(); + file_put_contents( + $projectRoot . '/glaze.neon', + "devServer:\n vite:\n enabled: true\n host: 0.0.0.0\n port: 5175\n", + ); + + (new ProjectConfigurationReader())->read($projectRoot); + + $command = $this->createCommand(); + + $args = new Arguments([], [ + 'vite' => false, + 'vite-host' => null, + 'vite-port' => null, + 'vite-command' => null, + ], []); + + $resolved = $this->callProtected($command, 'resolveViteConfiguration', $args, true); + + $this->assertIsArray($resolved); + $this->assertFalse($resolved['enabled']); + } + /** * Ensure PHP server configuration can be loaded from glaze.neon and overridden by CLI options. */ @@ -200,8 +230,6 @@ public function testResolvePhpServerConfigurationFromConfigAndCliOverrides(): vo 'resolvePhpServerConfiguration', $argsFromConfig, $projectRoot, - $projectRoot, - false, ); $this->assertIsArray($resolvedFromConfig); @@ -218,8 +246,6 @@ public function testResolvePhpServerConfigurationFromConfigAndCliOverrides(): vo 'resolvePhpServerConfiguration', $argsFromCli, $projectRoot, - $projectRoot, - false, ); $this->assertIsArray($resolvedFromCli); @@ -271,8 +297,6 @@ public function testResolvePhpServerConfigurationRejectsInvalidCliPort(): void 'resolvePhpServerConfiguration', $args, $projectRoot, - $projectRoot, - false, ); } diff --git a/tests/Unit/Http/StaticPageRequestHandlerTest.php b/tests/Unit/Http/StaticPageRequestHandlerTest.php new file mode 100644 index 0000000..c8ce53c --- /dev/null +++ b/tests/Unit/Http/StaticPageRequestHandlerTest.php @@ -0,0 +1,297 @@ + $outputFiles Relative output paths and their HTML content. + * @param list $neonOverrides Extra glaze.neon lines appended to base config. + */ + protected function createStaticProject( + array $outputFiles = [], + array $neonOverrides = [], + ): string { + $projectRoot = $this->copyFixtureToTemp('projects/basic'); + + $neon = "output:\n path: public\n"; + foreach ($neonOverrides as $line) { + $neon .= $line . "\n"; + } + + file_put_contents($projectRoot . '/glaze.neon', $neon); + + $outputDir = $projectRoot . '/public'; + if (!is_dir($outputDir)) { + mkdir($outputDir, 0755, true); + } + + // Default pages + $defaults = [ + 'index.html' => 'Homepage', + '404.html' => 'Custom 404', + ]; + + foreach (array_merge($defaults, $outputFiles) as $relativePath => $content) { + $fullPath = $outputDir . '/' . $relativePath; + $dir = dirname($fullPath); + if (!is_dir($dir)) { + mkdir($dir, 0755, true); + } + + file_put_contents($fullPath, $content); + } + + return $projectRoot; + } + + /** + * Ensure the root path serves the pre-built homepage. + */ + public function testHandleServesRootIndexHtml(): void + { + $projectRoot = $this->createStaticProject(); + $config = BuildConfig::fromProjectRoot($projectRoot, false); + $handler = new StaticPageRequestHandler($config); + + $request = (new ServerRequestFactory())->createServerRequest('GET', '/'); + $response = $handler->handle($request); + + $this->assertSame(200, $response->getStatusCode()); + $this->assertStringContainsString('text/html', $response->getHeaderLine('Content-Type')); + $this->assertStringContainsString('Homepage', (string)$response->getBody()); + } + + /** + * Ensure sub-pages are served from their pre-built index.html. + */ + public function testHandleServesSubPageIndexHtml(): void + { + $projectRoot = $this->createStaticProject([ + 'about/index.html' => 'About', + ]); + $config = BuildConfig::fromProjectRoot($projectRoot, false); + $handler = new StaticPageRequestHandler($config); + + $request = (new ServerRequestFactory())->createServerRequest('GET', '/about/'); + $response = $handler->handle($request); + + $this->assertSame(200, $response->getStatusCode()); + $this->assertStringContainsString('About', (string)$response->getBody()); + } + + /** + * Ensure extensionless paths without a trailing slash redirect to the canonical form. + */ + public function testHandleRedirectsDirectoryPathToTrailingSlash(): void + { + $projectRoot = $this->createStaticProject([ + 'about/index.html' => 'About', + ]); + $config = BuildConfig::fromProjectRoot($projectRoot, false); + $handler = new StaticPageRequestHandler($config); + + $request = (new ServerRequestFactory())->createServerRequest('GET', '/about'); + $response = $handler->handle($request); + + $this->assertSame(301, $response->getStatusCode()); + $this->assertSame('/about/', $response->getHeaderLine('Location')); + } + + /** + * Ensure unmatched paths return 404 with the custom 404.html content. + */ + public function testHandleReturnsNotFoundForMissingPage(): void + { + $projectRoot = $this->createStaticProject(); + $config = BuildConfig::fromProjectRoot($projectRoot, false); + $handler = new StaticPageRequestHandler($config); + + $request = (new ServerRequestFactory())->createServerRequest('GET', '/missing/'); + $response = $handler->handle($request); + + $this->assertSame(404, $response->getStatusCode()); + $this->assertStringContainsString('Custom 404', (string)$response->getBody()); + } + + /** + * Ensure 404 falls back to the plain HTML skeleton when no 404.html is present. + */ + public function testHandleReturnsPlain404WhenNoCustomPageExists(): void + { + $projectRoot = $this->createStaticProject(); + unlink($projectRoot . '/public/404.html'); + $config = BuildConfig::fromProjectRoot($projectRoot, false); + $handler = new StaticPageRequestHandler($config); + + $request = (new ServerRequestFactory())->createServerRequest('GET', '/missing/'); + $response = $handler->handle($request); + + $this->assertSame(404, $response->getStatusCode()); + $this->assertStringContainsString('404 Not Found', (string)$response->getBody()); + } + + /** + * Ensure the configured basePath prefix is stripped before resolving output files. + */ + public function testHandleResolvesCorrectlyWithBasePath(): void + { + $projectRoot = $this->createStaticProject( + ['about/index.html' => 'About'], + ['site:', ' basePath: /docs'], + ); + $config = BuildConfig::fromProjectRoot($projectRoot, false); + $handler = new StaticPageRequestHandler($config); + + $request = (new ServerRequestFactory())->createServerRequest('GET', '/docs/about/'); + $response = $handler->handle($request); + + $this->assertSame(200, $response->getStatusCode()); + $this->assertStringContainsString('About', (string)$response->getBody()); + } + + /** + * Ensure basePath-prefixed extensionless paths redirect to canonical trailing-slash form. + */ + public function testHandleRedirectsPrefixedDirectoryPathWithBasePath(): void + { + $projectRoot = $this->createStaticProject( + ['about/index.html' => 'About'], + ['site:', ' basePath: /docs'], + ); + $config = BuildConfig::fromProjectRoot($projectRoot, false); + $handler = new StaticPageRequestHandler($config); + + $request = (new ServerRequestFactory())->createServerRequest('GET', '/docs/about'); + $response = $handler->handle($request); + + $this->assertSame(301, $response->getStatusCode()); + $this->assertSame('/docs/about/', $response->getHeaderLine('Location')); + } + + /** + * Ensure basePath root path serves the pre-built homepage correctly. + */ + public function testHandleServesBasePathRootAsHomepage(): void + { + $projectRoot = $this->createStaticProject( + [], + ['site:', ' basePath: /docs'], + ); + $config = BuildConfig::fromProjectRoot($projectRoot, false); + $handler = new StaticPageRequestHandler($config); + + $request = (new ServerRequestFactory())->createServerRequest('GET', '/docs/'); + $response = $handler->handle($request); + + $this->assertSame(200, $response->getStatusCode()); + $this->assertStringContainsString('Homepage', (string)$response->getBody()); + } + + /** + * Ensure query strings are preserved in redirect Location headers. + */ + public function testHandlePreservesQueryStringInRedirect(): void + { + $projectRoot = $this->createStaticProject([ + 'about/index.html' => 'About', + ]); + $config = BuildConfig::fromProjectRoot($projectRoot, false); + $handler = new StaticPageRequestHandler($config); + + $request = (new ServerRequestFactory())->createServerRequest('GET', '/about'); + $request = $request->withUri($request->getUri()->withQuery('ref=nav')); + + $response = $handler->handle($request); + + $this->assertSame(301, $response->getStatusCode()); + $this->assertSame('/about/?ref=nav', $response->getHeaderLine('Location')); + } + + /** + * Ensure i18n-enabled sites serve language-scoped 404.html for requests under a language prefix. + * + * When i18n is enabled and the project has a pre-built `public/nl/404.html`, a request + * to a missing page under `/nl/` must serve that file instead of the root `/404.html`. + */ + public function testHandleServesLanguageScopedNotFoundPageForI18nPrefix(): void + { + $projectRoot = $this->createStaticProject( + ['nl/404.html' => 'Dutch 404'], + [ + 'i18n:', + ' defaultLanguage: en', + ' languages:', + ' en:', + ' label: English', + ' urlPrefix: ""', + ' nl:', + ' label: Nederlands', + ' urlPrefix: nl', + ], + ); + $config = BuildConfig::fromProjectRoot($projectRoot, false); + $handler = new StaticPageRequestHandler($config); + + $response = $handler->handle( + (new ServerRequestFactory())->createServerRequest('GET', '/nl/missing/'), + ); + + $this->assertSame(404, $response->getStatusCode()); + $this->assertStringContainsString('Dutch 404', (string)$response->getBody()); + } + + /** + * Ensure root-language requests fall back to /404.html even when i18n is enabled. + * + * The default language has an empty urlPrefix, so requests that are not prefixed + * with a known language code should still serve the global public/404.html. + */ + public function testHandleServesRootNotFoundPageForDefaultLanguageWhenI18nEnabled(): void + { + $projectRoot = $this->createStaticProject( + ['nl/404.html' => 'Dutch 404'], + [ + 'i18n:', + ' defaultLanguage: en', + ' languages:', + ' en:', + ' label: English', + ' urlPrefix: ""', + ' nl:', + ' label: Nederlands', + ' urlPrefix: nl', + ], + ); + $config = BuildConfig::fromProjectRoot($projectRoot, false); + $handler = new StaticPageRequestHandler($config); + + $response = $handler->handle( + (new ServerRequestFactory())->createServerRequest('GET', '/missing/'), + ); + + $this->assertSame(404, $response->getStatusCode()); + $this->assertStringContainsString('Custom 404', (string)$response->getBody()); + } +} diff --git a/tests/Unit/Process/PhpServerProcessTest.php b/tests/Unit/Process/PhpServerProcessTest.php index ddc42fb..85b67be 100644 --- a/tests/Unit/Process/PhpServerProcessTest.php +++ b/tests/Unit/Process/PhpServerProcessTest.php @@ -20,7 +20,7 @@ final class PhpServerProcessTest extends TestCase /** * Ensure live server command is shell-agnostic and does not rely on inline env assignments. */ - public function testBuildCommandForLiveModeUsesRouterWithoutInlineEnvironmentAssignments(): void + public function testBuildCommandUsesRouterWithoutInlineEnvironmentAssignments(): void { $projectRoot = $this->createTempDirectory(); mkdir($projectRoot . '/bin', 0755, true); @@ -32,7 +32,6 @@ public function testBuildCommandForLiveModeUsesRouterWithoutInlineEnvironmentAss 'port' => 8080, 'docRoot' => $projectRoot, 'projectRoot' => $projectRoot, - 'staticMode' => false, ]; $builtCommand = $this->callProtected($process, 'buildCommand', $config); @@ -45,35 +44,12 @@ public function testBuildCommandForLiveModeUsesRouterWithoutInlineEnvironmentAss } /** - * Ensure static mode command generation does not require router script. + * Ensure the router is required and throws when not found. */ - public function testBuildCommandForStaticModeUsesDocumentRootOnly(): void - { - $projectRoot = $this->createTempDirectory(); - - $process = new PhpServerProcess(); - $config = [ - 'host' => '127.0.0.1', - 'port' => 8080, - 'docRoot' => $projectRoot, - 'projectRoot' => $projectRoot, - 'staticMode' => true, - ]; - - $builtCommand = $this->callProtected($process, 'buildCommand', $config); - - $this->assertIsString($builtCommand); - $this->assertStringStartsWith('php -S ', $builtCommand); - $this->assertStringNotContainsString('dev-router.php', $builtCommand); - } - - /** - * Ensure live mode fails when router file is missing. - */ - public function testBuildCommandThrowsWhenLiveRouterIsMissing(): void + public function testBuildCommandThrowsWhenRouterIsMissing(): void { $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessage('Live router script not found'); + $this->expectExceptionMessage('Router script not found'); $projectRoot = $this->createTempDirectory(); $process = new PhpServerProcess(); @@ -82,7 +58,6 @@ public function testBuildCommandThrowsWhenLiveRouterIsMissing(): void 'port' => 8080, 'docRoot' => $projectRoot, 'projectRoot' => $projectRoot, - 'staticMode' => false, ]; $this->callProtected($process, 'buildCommand', $config); @@ -110,7 +85,6 @@ public function testBuildCommandUsesCliRouterFallbackWhenProjectHasConfig(): voi 'port' => 8080, 'docRoot' => $projectRoot, 'projectRoot' => $projectRoot, - 'staticMode' => false, ]; $builtCommand = $this->callProtected($process, 'buildCommand', $config); @@ -148,6 +122,8 @@ public function testStartThrowsForInvalidConfigurationArray(): void public function testStartInvokesCustomOutputCallbackForStreamOutput(): void { $projectRoot = $this->createTempDirectory(); + mkdir($projectRoot . DIRECTORY_SEPARATOR . 'bin', 0755, true); + file_put_contents($projectRoot . DIRECTORY_SEPARATOR . 'bin' . DIRECTORY_SEPARATOR . 'dev-router.php', ' 8099, 'docRoot' => $projectRoot, 'projectRoot' => $projectRoot, - 'staticMode' => true, 'streamOutput' => true, ]; @@ -197,6 +172,7 @@ public function testForwardedEnvironmentVariablesIncludesGlazeKeys(): void putenv('GLAZE_VITE_ENABLED=1'); putenv('GLAZE_VITE_URL=http://127.0.0.1:5174'); putenv('GLAZE_CLI_ROOT=/tmp/glaze-cli'); + putenv('GLAZE_STATIC_MODE=1'); $environment = $this->callProtected($process, 'forwardedEnvironmentVariables'); } finally { @@ -205,6 +181,7 @@ public function testForwardedEnvironmentVariablesIncludesGlazeKeys(): void $this->restoreVariable('GLAZE_VITE_ENABLED', $originalViteEnabled); $this->restoreVariable('GLAZE_VITE_URL', $originalViteUrl); $this->restoreVariable('GLAZE_CLI_ROOT', $originalCliRoot); + putenv('GLAZE_STATIC_MODE'); } $this->assertIsArray($environment); @@ -213,6 +190,7 @@ public function testForwardedEnvironmentVariablesIncludesGlazeKeys(): void $this->assertSame('1', $environment['GLAZE_VITE_ENABLED'] ?? null); $this->assertSame('http://127.0.0.1:5174', $environment['GLAZE_VITE_URL'] ?? null); $this->assertSame('/tmp/glaze-cli', $environment['GLAZE_CLI_ROOT'] ?? null); + $this->assertSame('1', $environment['GLAZE_STATIC_MODE'] ?? null); } /** @@ -233,7 +211,6 @@ public function testBuildCommandUsesPackageRouterFallbackWhenProjectHasConfig(): 'port' => 8080, 'docRoot' => $projectRoot, 'projectRoot' => $projectRoot, - 'staticMode' => false, ]; $builtCommand = $this->callProtected($process, 'buildCommand', $config);