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