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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 21 additions & 1 deletion EPIC_NON_HTML_PAGES.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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.
Expand Down
1 change: 1 addition & 0 deletions HYDEPHP_V3_PLANNING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down
52 changes: 8 additions & 44 deletions packages/realtime-compiler/src/Routing/Router.php
Original file line number Diff line number Diff line change
Expand Up @@ -19,17 +19,16 @@ class Router

protected Request $request;

protected bool $assetPathResolved = false;
protected ?string $resolvedAssetPath = null;

public function __construct(Request $request)
{
$this->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();
}

Expand All @@ -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?
*
Expand All @@ -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
Expand Down Expand Up @@ -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();
Expand Down
59 changes: 59 additions & 0 deletions packages/realtime-compiler/tests/RealtimeCompilerTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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/');
Expand Down