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);