diff --git a/EPIC_NON_HTML_PAGES.md b/EPIC_NON_HTML_PAGES.md index d39252545f4..62abaae5a54 100644 --- a/EPIC_NON_HTML_PAGES.md +++ b/EPIC_NON_HTML_PAGES.md @@ -165,7 +165,7 @@ Implementation notes (branch `v3/non-html-pages-foundation`): as non-HTML. Both recorded in the v3 release notes as breaking changes, though the old outputs were almost certainly never intended or relied upon. -### PR 2 — Realtime compiler: route-first resolution for non-HTML paths +### PR 2 — Realtime compiler: route-first resolution for non-HTML paths ✅ Implemented Goal: `hyde serve` serves any registered route regardless of extension; no filename special cases. @@ -178,6 +178,26 @@ filename special cases. - `PageRouter::getContentType()` already handles txt/xml/json; extend the map only if new types come up. +Implementation notes (branch `v3/non-html-pages-realtime-compiler`): + +- The route lookup needs the booted application, but `shouldProxy()` ran before + booting, so the predicate was dissolved into `Router::handle()` instead of + booting inside it: the `/media/` prefix remains the only boot-free fast path, + and any other asset-like path is proxied only when no registered route matches. + Missing assets fall through to the 404 in `proxyStatic()`, which absorbed the + previous separate missing-asset branch (same response either way). +- Perf consequence: requests for existing static files outside `media/` now boot + the app before being proxied, since routes must be consulted first. Such files + are rare (Hyde assets live under `media/`), and every non-proxied request + already booted. +- Behavior fix beyond the search.json generalization: a static file whose path + shadowed a registered dotted route (like a `_media/9.x` file next to a + `9.x/index` page) was previously served instead of the page; the page now wins. + Conversely, a routeless file like `_media/search.json` requested as + `/search.json` was previously 404'd by the suffix special case and is now + proxied like any other asset. +- `getContentType()` untouched — no new content types came up. + ### PR 3 — `TextPage` class (the headline feature) Goal: drop `_pages/robots.txt` in, get `_site/robots.txt` out. diff --git a/HYDEPHP_V3_PLANNING.md b/HYDEPHP_V3_PLANNING.md index c2f8ac83280..98a0b7f7109 100644 --- a/HYDEPHP_V3_PLANNING.md +++ b/HYDEPHP_V3_PLANNING.md @@ -33,6 +33,7 @@ Having this document in code lets us know the devlopment state at any given poin ### Minor Changes and Cleanup - The `Redirect` page class constructor now accepts an optional `$matter` parameter, used by the framework to hide the generated documentation root redirect from navigation menus. Existing usages are unaffected. +- The realtime compiler now resolves registered page routes before proxying static assets, replacing the hardcoded `search.json` exemption, so `hyde serve` serves any registered route regardless of its output extension. Registered pages now always win over a static file at the same path; the previous behavior of serving such a shadowing file only affected the dev server and no real setups are expected to be affected. - Removed the legacy `checkForDeprecatedRunMixCommandUsage` check and the placeholder `--run-dev`/`--run-prod` options from the `build` command, which were kept in v2 only to surface a helpful error message. ([#2461](https://github.com/hydephp/develop/pull/2461)) - Removed the deprecated `hyde.server.dashboard` boolean config check from `DashboardController::enabled()`, which was kept in v2 for backwards compatibility but had since then been replaced with `hyde.server.dashboard.enabled`. ([#2461](https://github.com/hydephp/develop/pull/2462)) diff --git a/packages/realtime-compiler/src/Routing/Router.php b/packages/realtime-compiler/src/Routing/Router.php index 315e6bf3eaf..4638194660b 100644 --- a/packages/realtime-compiler/src/Routing/Router.php +++ b/packages/realtime-compiler/src/Routing/Router.php @@ -19,9 +19,6 @@ class Router protected Request $request; - protected bool $assetPathResolved = false; - protected ?string $resolvedAssetPath = null; - public function __construct(Request $request) { $this->request = $request; @@ -29,7 +26,9 @@ public function __construct(Request $request) public function handle(): Response { - if ($this->shouldProxy()) { + // Media files are always static assets, so we proxy them + // directly without paying for booting the application. + if (str_starts_with($this->request->path, '/media/')) { return $this->proxyStatic(); } @@ -43,41 +42,16 @@ public function handle(): Response return $virtualRoutes[$this->request->path]($this->request); } - // A path with a file extension that matches neither a static file nor a page (like a - // missing stylesheet or source map) is a missing asset, and not a missing web page, - // so we send a normal 404 response instead of the pretty page not found error. + // A path with a file extension that isn't a web page is a static asset request, + // unless a page route is registered for the path (like `docs/search.json`), + // as pages take precedence over the on-disk files the proxy serves. if ($this->hasAssetLikeExtension() && ! PageRouter::hasRoute($this->request)) { - return $this->notFound(); + return $this->proxyStatic(); } return PageRouter::handle($this->request); } - /** - * If the request is not for a web page, we assume it's - * a static asset, which we instead want to proxy. - */ - protected function shouldProxy(): bool - { - // Always proxy media files. This condition is just to improve performance - // without having to check the file extension. - if (str_starts_with($this->request->path, '/media/')) { - return true; - } - - if (! $this->hasAssetLikeExtension()) { - return false; - } - - // Don't proxy the search.json file, as it's generated on the fly. - if (str_ends_with($this->request->path, 'search.json')) { - return false; - } - - // Dotted page routes (like documentation version folders) are proxied only when a matching asset exists. - return $this->resolveAssetPath() !== null; - } - /** * Does the request path have an extension that isn't a web page? * @@ -90,16 +64,6 @@ protected function hasAssetLikeExtension(): bool return $extension !== null && $extension !== 'html'; } - protected function resolveAssetPath(): ?string - { - if (! $this->assetPathResolved) { - $this->resolvedAssetPath = AssetFileLocator::find($this->request->path); - $this->assetPathResolved = true; - } - - return $this->resolvedAssetPath; - } - /** * Override the configured site URL so compiled pages reference the local * server instead of the production URL. Without this, assets such as @@ -182,7 +146,7 @@ protected function getConfiguredServerHost(string $scheme): string */ protected function proxyStatic(): Response { - $path = $this->resolveAssetPath(); + $path = AssetFileLocator::find($this->request->path); if ($path === null) { return $this->notFound(); diff --git a/packages/realtime-compiler/tests/RealtimeCompilerTest.php b/packages/realtime-compiler/tests/RealtimeCompilerTest.php index 31f18636046..662b957ed45 100644 --- a/packages/realtime-compiler/tests/RealtimeCompilerTest.php +++ b/packages/realtime-compiler/tests/RealtimeCompilerTest.php @@ -216,6 +216,65 @@ public function testErrorThrownWhileCompilingExistingDottedPageIsNotSentAsA404() } } + public function testServesRegisteredPageRouteEvenWhenMatchingAssetExists() + { + $this->mockCompilerRoute('9.x'); + + Filesystem::ensureDirectoryExists('_pages/9.x'); + Filesystem::put('_pages/9.x/index.md', '# Hello World!'); + Filesystem::put('_media/9.x', 'static decoy'); + + try { + $kernel = new HttpKernel(); + $response = $kernel->handle(new Request()); + + $this->assertSame(200, $response->statusCode); + $this->assertStringContainsString('Hello World!', $response->body); + $this->assertStringNotContainsString('static decoy', $response->body); + } finally { + Filesystem::deleteDirectory('_pages/9.x'); + Filesystem::unlink('_media/9.x'); + } + } + + public function testDocsSearchJsonRouteWinsOverMatchingAssetFile() + { + $this->mockCompilerRoute('docs/search.json'); + + Filesystem::put('_docs/index.md', '# Hello World!'); + Filesystem::ensureDirectoryExists('_media/docs'); + Filesystem::put('_media/docs/search.json', '"static decoy"'); + + try { + $kernel = new HttpKernel(); + $response = $kernel->handle(new Request()); + + $this->assertSame(200, $response->statusCode); + $this->assertNotSame('"static decoy"', $response->body); + $this->assertIsArray(json_decode($response->body, true)); + } finally { + Filesystem::unlink('_docs/index.md'); + Filesystem::deleteDirectory('_media/docs'); + } + } + + public function testProxiesRootLevelAssetWhenNoRouteMatchesThePath() + { + $this->mockCompilerRoute('data.json'); + + Filesystem::put('_media/data.json', '{"static": true}'); + + try { + $kernel = new HttpKernel(); + $response = $kernel->handle(new Request()); + + $this->assertSame(200, $response->statusCode); + $this->assertSame('{"static": true}', $response->body); + } finally { + Filesystem::unlink('_media/data.json'); + } + } + public function testTrailingSlashesAreNormalizedFromRoute() { $this->mockCompilerRoute('foo/');