diff --git a/EPIC_NON_HTML_PAGES.md b/EPIC_NON_HTML_PAGES.md
index a2459a54d7e..e8871a634f1 100644
--- a/EPIC_NON_HTML_PAGES.md
+++ b/EPIC_NON_HTML_PAGES.md
@@ -50,9 +50,10 @@ require every output format to have a matching filesystem-discovered page class.
- `BuildService::getPageTypes()` derives page classes from the live page collection,
and `StaticPageBuilder` writes whatever `getOutputPath()` returns — dynamic pages
need zero build-service changes.
-- `HydeCoreExtension::discoverPages()` is the proven registration point for
- feature-gated virtual pages (search index, redirects), and users/packages can do
- the same via `Hyde::kernel()->booting()` callbacks or a `HydeExtension`.
+- Extension page discovery is the proven registration mechanism for feature-gated
+ virtual pages. Normal pages use `discoverPages()`; framework fallbacks use the
+ later `discoverDefaultPages()` phase so user routes are present before defaults
+ fill any remaining gaps.
- The route-key-with-extension convention is already de facto established:
`DocumentationSearchIndex` uses route key `docs/search.json` equal to its output path.
@@ -176,13 +177,11 @@ a standalone feature in its own right.
### D4: Generators become container-resolved pages; generator actions stay
-Each generated file is registered as an `InMemoryPage` whose compiled contents
-resolve its generator **from the container at build time**, e.g. a `sitemap.xml`
-page whose compile step returns `app(SitemapGenerator::class)->generate()`. The XML
-generator actions (`SitemapGenerator`, `RssFeedGenerator`) are untouched and remain
-the default implementations — this is still the `DocumentationSearchIndex` →
-`GeneratesDocumentationSearchIndex` split, but the generator is *resolved*, not
-`new`'d.
+Each generated file is registered as an internal `GeneratedFilePage`, built on
+`InMemoryPage` exact-path semantics. Its compiled contents resolve a generator
+**from the container at build time**. All generated-file generators expose a small
+internal final-string adapter while retaining their existing public `generate()`
+behavior; the XML generators also retain `getXml()`.
Resolving through the container is the point: a user can rebind `SitemapGenerator`
(or the page's content closure) to swap the output without replacing the page — a
@@ -191,9 +190,9 @@ produced **lazily** (a `compile` macro / closure or a thin subclass `compile()`)
never eager string content, since generation must run at build time against the
final route set.
-Registration happens in `HydeCoreExtension::discoverPages()` behind the existing
-`Features::hasSitemap()` / `Features::hasRss()` conditions, replacing the
-registrations in `BuildTaskService::registerFrameworkTasks()`.
+Definitions for sitemap, RSS, robots, and llms output live in one internal registry.
+`HydeCoreExtension::discoverDefaultPages()` loops over enabled definitions after all
+normal page discovery has completed and registers only routes that are still absent.
**Plain page vs. thin subclass is deferred to PR 5.** D3 already defaults non-HTML
pages out of the sitemap, so a subclass is *not* needed merely to stop a generated
@@ -215,40 +214,29 @@ instantiate. Lean toward a plain `InMemoryPage` registered with a container-boun
> unaffected: `compile()` resolves `SitemapGenerator` from the container, verified
> by a rebind test. Part B should mirror this with `RssFeedPage`.
+> **Superseded by the post-implementation simplification:** command construction no
+> longer requires page class identity. Commands build the registered route, and the
+> shared registry supplies the fallback route information. One internal
+> `GeneratedFilePage` therefore replaces `SitemapPage`, `RssFeedPage`,
+> `RobotsTxtPage`, and `LlmsTxtPage` without changing generator rebinding or route
+> override behavior.
+
### D5: User-defined pages beat generators
If the page collection already contains a user-defined page with a route key such
-as `robots.txt`, the framework does not register its generated `RobotsTxtPage`.
-This follows the pattern of `discoverDocumentationRootRedirect()`, which skips when
-a user-defined route exists.
+as `robots.txt`, the framework does not register the matching generated default.
Users can register an `InMemoryPage` from a service provider or provide a custom page
class through an extension. Combined with D4's container-resolved generators, this
gives a smooth escalation path:
feature default → config tweaks → rebind the generator (or content closure) in the
container → fully custom page in code.
-> **Timing caveat — the skip check is ordering-sensitive.** "Is a `robots.txt` route
-> already registered" is evaluated at `discoverPages()` time, so whether a user's page
-> wins depends on it being visible at that moment. A page registered via a late
-> `booting()` callback may or may not be present depending on boot ordering. The
-> `discoverDocumentationRootRedirect()` precedent suggests this is fine, but "fine and
-> ordering-dependent" is exactly what passes in our tests and breaks for the one user
-> who registers late. PR 5/6 MUST include an end-to-end test asserting that a
-> user-registered `robots.txt` (via both a `HydeExtension` page class and a `booting()`
-> `addPage()` callback) suppresses the generated one — this is the D5 contract and the
-> most likely silent failure for the power-user audience.
-
-> **Verified for the sitemap (PR 5 part A):** both user paths win, through two
-> different mechanisms that the tests pin down end-to-end. `booting()` callbacks run
-> before the page collection boots (`BootsHydeKernel::boot()`), so a callback-registered
-> `sitemap.xml` page is visible to the core extension's `hasPageWithRouteKey()` skip
-> check. A user `HydeExtension` runs *after* the core extension (registration order),
-> so the skip check cannot see its pages; instead the user page replaces the generated
-> one under the same collection key (`addPage()` keys by source path). Both are
-> asserted through the real `build` command output. The robots.txt equivalent remains
-> mandatory for PR 6. *(Part B: both paths verified the same way for the feed page.)*
-> *(PR 6: both paths verified the same way for the robots.txt page.)*
-> *(PR 7: both paths verified the same way for the llms.txt page.)*
+> **Superseded ordering design:** page discovery now has two explicit phases. Every
+> extension completes `discoverPages()` before any extension runs
+> `discoverDefaultPages()`. Framework-generated files are registered in the default
+> phase only when their route is absent. User precedence is therefore independent of
+> page-collection source keys and normal extension registration order. Default
+> handlers still run in extension registration order relative to other defaults.
### D6: No built-in `TextPage` or `.txt` autodiscovery
@@ -270,6 +258,11 @@ an extension point.
## Work breakdown (planned PR sequence, in dependency order)
+> **Historical note:** the PR 5–7 implementation notes below record the intermediate
+> thin-page implementation that originally landed. The post-implementation
+> simplification recorded in D4 and D5 supersedes references to the four specialized
+> page subclasses and registration through `discoverPages()`.
+
### PR 1 — Foundation: explicit output paths and page-class extensions ✅ Implemented
Goal: any page class can emit a non-`.html` file without overriding `getOutputPath()`.
diff --git a/HYDEPHP_V3_PLANNING.md b/HYDEPHP_V3_PLANNING.md
index 651a4bc77c2..e4e32495766 100644
--- a/HYDEPHP_V3_PLANNING.md
+++ b/HYDEPHP_V3_PLANNING.md
@@ -38,6 +38,11 @@ Having this document in code lets us know the devlopment state at any given poin
### Minor Changes and Cleanup
+- Added `HydeExtension::discoverDefaultPages()` for fallback pages. All extensions
+ complete normal `discoverPages()` handling before any default-page handler runs,
+ allowing framework-generated routes to fill gaps without depending on normal
+ extension registration order. Default handlers retain extension registration order
+ relative to each other.
- Fixed documentation search index files leaking into the generated sitemap: `search.json` (and any other page compiled to a non-HTML output file) no longer appears in `sitemap.xml`. The sitemap generator now asks each page through `HydePage::showInSitemap()` instead of only filtering out redirect pages.
- 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.
diff --git a/TASK_NON_HTML_PAGE_SIMPLIFICATION.md b/TASK_NON_HTML_PAGE_SIMPLIFICATION.md
new file mode 100644
index 00000000000..92c093b6783
--- /dev/null
+++ b/TASK_NON_HTML_PAGE_SIMPLIFICATION.md
@@ -0,0 +1,119 @@
+# Non-HTML Page Simplification Task Specification
+
+## Objective
+
+Reduce the accidental complexity left by the first-class non-HTML page work while preserving its user-facing behavior:
+
+- generated files remain registered pages and routes;
+- `InMemoryPage::file()` remains the explicit arbitrary-file API;
+- registered routes continue to win over static assets in the realtime compiler;
+- sitemap inclusion remains a page policy through `showInSitemap()`;
+- generated-file generators remain replaceable through the service container;
+- user-defined pages continue to replace framework-generated files;
+- the existing `build:sitemap` and `build:rss` commands remain available.
+
+The implementation is split into isolated issues so each change can be reviewed, tested, and committed independently.
+
+## Issue 1: Derive route keys from resolved output paths
+
+### Problem in the current code
+
+`HydePage` constructs its stored route key with `RouteKey::fromPage()` before the instance output path is considered. `RouteKey::fromPage()` independently combines the page class output directory, identifier, and static `$outputExtension`. `InMemoryPage::file()` then overrides `getOutputPath()` at the instance level.
+
+This creates two representations of the same output semantics:
+
+- `packages/framework/src/Support/Models/RouteKey.php` derives a route from class-level declarations;
+- `packages/framework/src/Pages/InMemoryPage.php` can resolve a different exact instance output path.
+
+`DocumentationPage` also overrides both `getRouteKey()` and `getOutputPath()` to keep flattened documentation routes aligned, illustrating the same duplication.
+
+### Implementation
+
+1. Add `RouteKey::fromOutputPath(string $outputPath)`, with the single route rule: remove a trailing `.html`; retain every other path and extension verbatim.
+2. Change `HydePage` construction so the stored route key is derived from the instance's resolved `getOutputPath()`.
+3. Change `HydePage::outputPath()` to construct the output path directly from the normalized page identifier, output directory, and output extension instead of round-tripping through a route key.
+4. Keep `RouteKey::fromPage()` as a compatibility/helper API, but implement it by passing the page class's resolved static output path to `fromOutputPath()`.
+5. Remove `DocumentationPage::getRouteKey()` once its resolved `getOutputPath()` automatically determines the stored route key.
+
+### Acceptance criteria
+
+- HTML output `foo/bar.html` has route key `foo/bar`.
+- Non-HTML output `foo/bar.json` has route key `foo/bar.json`.
+- Extensionless exact output `feed` has route key `feed`.
+- An exact-path `InMemoryPage::file('robots.txt')` has output path and route key `robots.txt`.
+- A page subclass overriding only `getOutputPath()` receives a matching route key without also overriding `getRouteKey()`.
+- Documentation numerical-prefix stripping, post date-prefix stripping, output directories, non-HTML extension de-duplication, and flattened documentation output keep their existing behavior.
+- Existing page, route, build, manifest, and realtime compiler tests remain green.
+
+## Issue 2: Consolidate generated-file page wiring
+
+### Problem in the current code
+
+The following internal classes repeat the same page shell:
+
+- `Framework/Features/XmlGenerators/SitemapPage.php`
+- `Framework/Features/XmlGenerators/RssFeedPage.php`
+- `Framework/Features/TextGenerators/RobotsTxtPage.php`
+- `Framework/Features/TextGenerators/LlmsTxtPage.php`
+
+Each class supplies an exact path, hidden navigation, a container-resolved generator, and a small `compile()` adapter. `HydeCoreExtension` repeats a feature check and discovery method for each shell. XML generators return themselves from `generate()` and require `getXml()`, while text generators return their final strings directly.
+
+### Implementation
+
+1. Add an internal `GeneratedFileGenerator` contract with one final rendering method returning a string.
+2. Implement the contract in the text generators and `BaseXmlGenerator` through compatibility adapters, retaining the existing public `generate()` behavior for generator subclasses and callers.
+3. Add one internal `GeneratedFilePage` that stores an exact output path and generator class, hides itself from navigation, resolves the generator from the container at compile time, and returns the contract's final string.
+4. Add a generated-file definition/registry containing the four feature checks, output paths, and generator bindings. The configured RSS filename is resolved when definitions are created.
+5. Make `HydeCoreExtension` loop over the registry and add only missing routes.
+6. Update `build:sitemap`, `build:rss`, and the robots sitemap link to use registry paths instead of specialized page classes.
+7. Remove the four specialized generated page classes and consolidate their tests around `GeneratedFilePage` plus route behavior.
+
+### Acceptance criteria
+
+- The same four generated routes appear under the same feature conditions and configured RSS filename.
+- Generated pages are exact-path pages, hidden from navigation, and excluded from the sitemap by default.
+- All four files compile through the standard build and remain present in the build manifest and route list.
+- Rebinding any concrete generator in the service container changes the compiled route output.
+- Existing `generate()` and XML `getXml()` generator APIs continue to work.
+- `build:sitemap` and `build:rss` retain their current success, failure, configured-filename, and user-page override behavior.
+
+## Issue 3: Register framework defaults after user pages
+
+### Problem in the current code
+
+`PageCollection::runExtensionHandlers()` invokes extensions in registration order. `HydeCoreExtension` is registered first, so generated pages are added before user extensions. A user extension currently wins because adding an exact-path page happens to replace the generated page under the same page-collection source key, or later wins in the route collection.
+
+That makes “user pages beat framework defaults” dependent on collection keys and timing rather than a lifecycle guarantee.
+
+### Implementation
+
+1. Add a documented `discoverDefaultPages(PageCollection $collection)` extension hook for fallback pages.
+2. Run all normal `discoverPages()` hooks first, then all `discoverDefaultPages()` hooks.
+3. Move the generated-file registry loop from `HydeCoreExtension::discoverPages()` into `discoverDefaultPages()`.
+4. Keep each generated definition's route-existence check so defaults only fill gaps.
+5. Add a regression test where a user extension registers the same route under a different page-collection source key; assert the default is not added at all.
+
+### Acceptance criteria
+
+- Source-discovered pages, booting-callback pages, and normal extension pages exist before framework defaults are evaluated.
+- A user page with a generated route key suppresses that default regardless of its source path/collection key.
+- Only one page and one route exist for an overridden generated output.
+- Existing extension discovery order remains unchanged within each phase.
+- Other core-discovered pages (redirects and documentation search pages) retain their existing discovery behavior.
+
+## Verification
+
+For each issue:
+
+1. Run its focused unit and feature tests before committing.
+2. Run the complete framework test suite after the final issue.
+3. Run realtime compiler tests because route/output semantics cross package boundaries.
+4. Run the repository formatting/static checks available for changed PHP files.
+5. Confirm the worktree is clean after the final commit.
+
+## Deliberate non-goals
+
+- Do not introduce an `OutputPath` value object in this refactor. Existing framework APIs expose paths as strings; changing all of them would add migration surface without being necessary to establish one source of truth.
+- Do not add a public general-purpose `hyde build:route` command. Removing command-driven page subclasses solves the immediate coupling; a new CLI API should be evaluated separately.
+- Do not revisit the `$fileExtension` to `$sourceExtension` rename here. It is an independent v3 API cleanup already implemented on this branch, not a dependency of these simplifications.
+- Do not change route-first realtime compiler behavior, sitemap policy, exact-path validation, generated content formats, or feature configuration.
diff --git a/UPGRADE.md b/UPGRADE.md
index 011a83e1c51..26da82612dc 100644
--- a/UPGRADE.md
+++ b/UPGRADE.md
@@ -17,6 +17,21 @@ InMemoryPage::file('robots.txt', contents: $text);
// _site/robots.txt
```
+Route keys are always derived from the resolved output path by removing only its final `.html` extension. This also
+applies to exact-path files, so the page and file constructors intentionally differ for an identifier ending in HTML:
+
+```php
+InMemoryPage::file('download.html');
+// output: download.html, route key: download
+
+InMemoryPage::make('download.html');
+// output: download.html.html, route key: download.html
+```
+
+Custom pages that override `getOutputPath()` no longer need to override `getRouteKey()`. The route key is resolved
+during parent construction and remains the page's stable identity. If the output path depends on subclass instance
+state, initialize that state before calling `parent::__construct()`.
+
## Before You Begin
### Prerequisites
@@ -212,6 +227,10 @@ Hyde::kernel()->booting(function ($kernel): void {
});
```
+Framework fallback pages are now registered only after every extension has completed `discoverPages()`. If an extension
+previously inspected or modified a framework-generated page from that handler, move that work to
+`discoverDefaultPages()` or a later lifecycle hook. Default handlers still run in extension registration order.
+
The `build:sitemap` and `build:rss` commands still work and now compile the registered pages. When the output
cannot be generated (no base URL, disabled in the configuration, or — for the feed — no Markdown posts), they
fail with an error instead of generating an empty or unwanted file. `build:sitemap` reports this
diff --git a/packages/framework/src/Console/Commands/BuildRssFeedCommand.php b/packages/framework/src/Console/Commands/BuildRssFeedCommand.php
index 85a9b820f3a..77e56834af1 100644
--- a/packages/framework/src/Console/Commands/BuildRssFeedCommand.php
+++ b/packages/framework/src/Console/Commands/BuildRssFeedCommand.php
@@ -8,7 +8,8 @@
use Hyde\Console\Concerns\Command;
use Hyde\Foundation\Facades\Routes;
use Hyde\Framework\Actions\StaticPageBuilder;
-use Hyde\Framework\Features\XmlGenerators\RssFeedPage;
+use Hyde\Framework\Features\GeneratedFiles\GeneratedFileRegistry;
+use Hyde\Support\Models\RouteKey;
use function sprintf;
@@ -25,7 +26,8 @@ class BuildRssFeedCommand extends Command
public function handle(): int
{
- $page = Routes::find(RssFeedPage::routeKey())?->getPage();
+ $routeKey = RouteKey::fromOutputPath(GeneratedFileRegistry::rssOutputPath())->get();
+ $page = Routes::find($routeKey)?->getPage();
if ($page === null) {
$this->error('Cannot generate the RSS feed as the feature is not enabled');
diff --git a/packages/framework/src/Console/Commands/BuildSitemapCommand.php b/packages/framework/src/Console/Commands/BuildSitemapCommand.php
index 6f503d210a9..5e03a3de847 100644
--- a/packages/framework/src/Console/Commands/BuildSitemapCommand.php
+++ b/packages/framework/src/Console/Commands/BuildSitemapCommand.php
@@ -8,7 +8,7 @@
use Hyde\Console\Concerns\Command;
use Hyde\Foundation\Facades\Routes;
use Hyde\Framework\Actions\StaticPageBuilder;
-use Hyde\Framework\Features\XmlGenerators\SitemapPage;
+use Hyde\Framework\Features\GeneratedFiles\GeneratedFilePaths;
use function sprintf;
@@ -25,7 +25,7 @@ class BuildSitemapCommand extends Command
public function handle(): int
{
- $page = Routes::find(SitemapPage::routeKey())?->getPage();
+ $page = Routes::find(GeneratedFilePaths::SITEMAP)?->getPage();
if ($page === null) {
$this->error('Cannot generate the sitemap as the feature is not enabled');
diff --git a/packages/framework/src/Foundation/Concerns/HydeExtension.php b/packages/framework/src/Foundation/Concerns/HydeExtension.php
index 84e0af0fe60..5b93aca59fb 100644
--- a/packages/framework/src/Foundation/Concerns/HydeExtension.php
+++ b/packages/framework/src/Foundation/Concerns/HydeExtension.php
@@ -68,6 +68,17 @@ public function discoverPages(PageCollection $collection): void
//
}
+ /**
+ * If your extension provides fallback pages, register them here.
+ *
+ * Register fallback pages after every extension has completed normal page discovery.
+ * Default handlers themselves continue to run in extension registration order.
+ */
+ public function discoverDefaultPages(PageCollection $collection): void
+ {
+ //
+ }
+
/**
* If your extension needs to hook into the route discovery process,
* you can configure the following handler method. It will be called
diff --git a/packages/framework/src/Foundation/HydeCoreExtension.php b/packages/framework/src/Foundation/HydeCoreExtension.php
index 85ab57ee089..413c5083974 100644
--- a/packages/framework/src/Foundation/HydeCoreExtension.php
+++ b/packages/framework/src/Foundation/HydeCoreExtension.php
@@ -21,10 +21,7 @@
use Hyde\Facades\Config;
use Hyde\Framework\Features\Documentation\DocumentationSearchPage;
use Hyde\Framework\Features\Documentation\DocumentationSearchIndex;
-use Hyde\Framework\Features\TextGenerators\LlmsTxtPage;
-use Hyde\Framework\Features\TextGenerators\RobotsTxtPage;
-use Hyde\Framework\Features\XmlGenerators\RssFeedPage;
-use Hyde\Framework\Features\XmlGenerators\SitemapPage;
+use Hyde\Framework\Features\GeneratedFiles\GeneratedFileRegistry;
use Hyde\Framework\Features\Documentation\Versioning\DocumentationVersion;
use Hyde\Framework\Features\Documentation\Versioning\DocumentationVersions;
@@ -84,53 +81,14 @@ public function discoverPages(PageCollection $collection): void
}
}
}
-
- if (Features::hasSitemap()) {
- $this->discoverSitemapPage($collection);
- }
-
- if (Features::hasRss()) {
- $this->discoverRssFeedPage($collection);
- }
-
- if (Features::hasRobotsTxt()) {
- $this->discoverRobotsTxtPage($collection);
- }
-
- if (Features::hasLlmsTxt()) {
- $this->discoverLlmsTxtPage($collection);
- }
}
- /** Add the generated sitemap page unless the route is user-defined. */
- protected function discoverSitemapPage(PageCollection $collection): void
+ public function discoverDefaultPages(PageCollection $collection): void
{
- if (! $this->hasPageWithRouteKey($collection, SitemapPage::routeKey())) {
- $collection->addPage(new SitemapPage());
- }
- }
-
- /** Add the generated RSS feed page unless the route is user-defined. */
- protected function discoverRssFeedPage(PageCollection $collection): void
- {
- if (! $this->hasPageWithRouteKey($collection, RssFeedPage::routeKey())) {
- $collection->addPage(new RssFeedPage());
- }
- }
-
- /** Add the generated robots.txt page unless the route is user-defined. */
- protected function discoverRobotsTxtPage(PageCollection $collection): void
- {
- if (! $this->hasPageWithRouteKey($collection, RobotsTxtPage::routeKey())) {
- $collection->addPage(new RobotsTxtPage());
- }
- }
-
- /** Add the generated llms.txt page unless the route is user-defined. */
- protected function discoverLlmsTxtPage(PageCollection $collection): void
- {
- if (! $this->hasPageWithRouteKey($collection, LlmsTxtPage::routeKey())) {
- $collection->addPage(new LlmsTxtPage());
+ foreach (GeneratedFileRegistry::pages() as $page) {
+ if (! $this->hasPageWithRouteKey($collection, $page->getRouteKey())) {
+ $collection->addPage($page);
+ }
}
}
diff --git a/packages/framework/src/Foundation/Kernel/PageCollection.php b/packages/framework/src/Foundation/Kernel/PageCollection.php
index 4cc4d85eac2..57ab73fd729 100644
--- a/packages/framework/src/Foundation/Kernel/PageCollection.php
+++ b/packages/framework/src/Foundation/Kernel/PageCollection.php
@@ -46,6 +46,10 @@ protected function runExtensionHandlers(): void
foreach ($this->kernel->getExtensions() as $extension) {
$extension->discoverPages($this);
}
+
+ foreach ($this->kernel->getExtensions() as $extension) {
+ $extension->discoverDefaultPages($this);
+ }
}
/** @param class-string<\Hyde\Pages\Concerns\HydePage> $pageClass */
diff --git a/packages/framework/src/Framework/Actions/GeneratesDocumentationSearchIndex.php b/packages/framework/src/Framework/Actions/GeneratesDocumentationSearchIndex.php
index 6aa94d85e76..33d15d72fb3 100644
--- a/packages/framework/src/Framework/Actions/GeneratesDocumentationSearchIndex.php
+++ b/packages/framework/src/Framework/Actions/GeneratesDocumentationSearchIndex.php
@@ -70,10 +70,10 @@ protected function shouldIncludePage(DocumentationPage $page): bool
protected function generatePageEntry(DocumentationPage $page): array
{
return [
- 'slug' => basename($page->routeKey),
+ 'slug' => basename($page->getRouteKey()),
'title' => $page->title,
'content' => trim($this->getSearchContentForDocument($page)),
- 'destination' => $this->formatDestination(basename($page->routeKey)),
+ 'destination' => $this->formatDestination(basename($page->getRouteKey())),
];
}
diff --git a/packages/framework/src/Framework/Features/GeneratedFiles/GeneratedFileGenerator.php b/packages/framework/src/Framework/Features/GeneratedFiles/GeneratedFileGenerator.php
new file mode 100644
index 00000000000..679d25d0977
--- /dev/null
+++ b/packages/framework/src/Framework/Features/GeneratedFiles/GeneratedFileGenerator.php
@@ -0,0 +1,14 @@
+ */
+ protected readonly string $generator;
+
+ /** @param class-string<\Hyde\Framework\Features\GeneratedFiles\GeneratedFileGenerator> $generator */
+ public function __construct(string $outputPath, string $generator)
+ {
+ $this->generator = $generator;
+
+ parent::__construct($outputPath, [
+ 'navigation' => ['hidden' => true],
+ ], exactOutputPath: true);
+ }
+
+ public function compile(): string
+ {
+ return app($this->generator)->generateFile();
+ }
+}
diff --git a/packages/framework/src/Framework/Features/GeneratedFiles/GeneratedFilePaths.php b/packages/framework/src/Framework/Features/GeneratedFiles/GeneratedFilePaths.php
new file mode 100644
index 00000000000..df6c9bf6a62
--- /dev/null
+++ b/packages/framework/src/Framework/Features/GeneratedFiles/GeneratedFilePaths.php
@@ -0,0 +1,15 @@
+ */
+ public static function pages(): array
+ {
+ return array_values(array_filter([
+ Features::hasSitemap() ? new GeneratedFilePage(GeneratedFilePaths::SITEMAP, SitemapGenerator::class) : null,
+ Features::hasRss() ? new GeneratedFilePage(self::rssOutputPath(), RssFeedGenerator::class) : null,
+ Features::hasRobotsTxt() ? new GeneratedFilePage(GeneratedFilePaths::ROBOTS, RobotsTxtGenerator::class) : null,
+ Features::hasLlmsTxt() ? new GeneratedFilePage(GeneratedFilePaths::LLMS, LlmsTxtGenerator::class) : null,
+ ]));
+ }
+
+ public static function rssOutputPath(): string
+ {
+ return RssFeedGenerator::getFilename();
+ }
+}
diff --git a/packages/framework/src/Framework/Features/TextGenerators/LlmsTxtGenerator.php b/packages/framework/src/Framework/Features/TextGenerators/LlmsTxtGenerator.php
index 4a49ce4e9ca..63dcfdd2c50 100644
--- a/packages/framework/src/Framework/Features/TextGenerators/LlmsTxtGenerator.php
+++ b/packages/framework/src/Framework/Features/TextGenerators/LlmsTxtGenerator.php
@@ -14,6 +14,7 @@
use Hyde\Pages\Concerns\HydePage;
use Hyde\Support\Models\Route;
use Hyde\Foundation\Facades\Routes;
+use Hyde\Framework\Features\GeneratedFiles\GeneratedFileGenerator;
use function addcslashes;
use function array_fill_keys;
@@ -46,15 +47,20 @@
* of the generated file may change in future minor and patch releases to follow the spec.
*
* @see https://llmstxt.org/
- * @see \Hyde\Framework\Features\TextGenerators\LlmsTxtPage
+ * @see \Hyde\Framework\Features\GeneratedFiles\GeneratedFilePage
*/
-class LlmsTxtGenerator
+class LlmsTxtGenerator implements GeneratedFileGenerator
{
public function generate(): string
{
return implode("\n", $this->getLines())."\n";
}
+ public function generateFile(): string
+ {
+ return $this->generate();
+ }
+
/**
* The page types listed in the file, and the section heading each is listed under.
*
diff --git a/packages/framework/src/Framework/Features/TextGenerators/LlmsTxtPage.php b/packages/framework/src/Framework/Features/TextGenerators/LlmsTxtPage.php
deleted file mode 100644
index 782d0946bb9..00000000000
--- a/packages/framework/src/Framework/Features/TextGenerators/LlmsTxtPage.php
+++ /dev/null
@@ -1,39 +0,0 @@
- ['hidden' => true],
- ], exactOutputPath: true);
- }
-
- public function compile(): string
- {
- return app(LlmsTxtGenerator::class)->generate();
- }
-
- /**
- * Get the route key of the llms.txt file, which for this page is also its output path.
- */
- public static function routeKey(): string
- {
- return 'llms.txt';
- }
-}
diff --git a/packages/framework/src/Framework/Features/TextGenerators/RobotsTxtGenerator.php b/packages/framework/src/Framework/Features/TextGenerators/RobotsTxtGenerator.php
index fd73dcb512f..18198268626 100644
--- a/packages/framework/src/Framework/Features/TextGenerators/RobotsTxtGenerator.php
+++ b/packages/framework/src/Framework/Features/TextGenerators/RobotsTxtGenerator.php
@@ -8,7 +8,8 @@
use Hyde\Facades\Config;
use Hyde\Facades\Features;
use Hyde\Framework\Exceptions\InvalidConfigurationException;
-use Hyde\Framework\Features\XmlGenerators\SitemapPage;
+use Hyde\Framework\Features\GeneratedFiles\GeneratedFileGenerator;
+use Hyde\Framework\Features\GeneratedFiles\GeneratedFilePaths;
use function array_merge;
use function get_debug_type;
@@ -24,22 +25,27 @@
* rule are supported. A link to the sitemap is included when the sitemap
* feature is enabled.
*
- * @see \Hyde\Framework\Features\TextGenerators\RobotsTxtPage
+ * @see \Hyde\Framework\Features\GeneratedFiles\GeneratedFilePage
*/
-class RobotsTxtGenerator
+class RobotsTxtGenerator implements GeneratedFileGenerator
{
public function generate(): string
{
return implode("\n", $this->getLines())."\n";
}
+ public function generateFile(): string
+ {
+ return $this->generate();
+ }
+
/** @return array */
protected function getLines(): array
{
$lines = array_merge(['User-agent: *'], $this->getRuleLines());
if (Features::hasSitemap()) {
- $lines = array_merge($lines, ['', 'Sitemap: '.Hyde::url(SitemapPage::routeKey())]);
+ $lines = array_merge($lines, ['', 'Sitemap: '.Hyde::url(GeneratedFilePaths::SITEMAP)]);
}
return $lines;
diff --git a/packages/framework/src/Framework/Features/TextGenerators/RobotsTxtPage.php b/packages/framework/src/Framework/Features/TextGenerators/RobotsTxtPage.php
deleted file mode 100644
index 0e68f158378..00000000000
--- a/packages/framework/src/Framework/Features/TextGenerators/RobotsTxtPage.php
+++ /dev/null
@@ -1,39 +0,0 @@
- ['hidden' => true],
- ], exactOutputPath: true);
- }
-
- public function compile(): string
- {
- return app(RobotsTxtGenerator::class)->generate();
- }
-
- /**
- * Get the route key of the robots.txt file, which for this page is also its output path.
- */
- public static function routeKey(): string
- {
- return 'robots.txt';
- }
-}
diff --git a/packages/framework/src/Framework/Features/XmlGenerators/BaseXmlGenerator.php b/packages/framework/src/Framework/Features/XmlGenerators/BaseXmlGenerator.php
index 81e5fa2dcb8..9748165beee 100644
--- a/packages/framework/src/Framework/Features/XmlGenerators/BaseXmlGenerator.php
+++ b/packages/framework/src/Framework/Features/XmlGenerators/BaseXmlGenerator.php
@@ -8,6 +8,7 @@
use Exception;
use SimpleXMLElement;
+use Hyde\Framework\Features\GeneratedFiles\GeneratedFileGenerator;
use function extension_loaded;
use function htmlspecialchars;
@@ -19,7 +20,7 @@
* @see \Hyde\Framework\Features\XmlGenerators\RssFeedGenerator
* @see \Hyde\Framework\Features\XmlGenerators\SitemapGenerator
*/
-abstract class BaseXmlGenerator
+abstract class BaseXmlGenerator implements GeneratedFileGenerator
{
protected SimpleXMLElement $xmlElement;
@@ -60,6 +61,11 @@ public function getXml(): string
return (string) $this->xmlElement->asXML();
}
+ public function generateFile(): string
+ {
+ return $this->generate()->getXml();
+ }
+
/**
* Get the XML document as a SimpleXMLElement object.
*/
diff --git a/packages/framework/src/Framework/Features/XmlGenerators/RssFeedPage.php b/packages/framework/src/Framework/Features/XmlGenerators/RssFeedPage.php
deleted file mode 100644
index 658565a73a4..00000000000
--- a/packages/framework/src/Framework/Features/XmlGenerators/RssFeedPage.php
+++ /dev/null
@@ -1,39 +0,0 @@
- ['hidden' => true],
- ], exactOutputPath: true);
- }
-
- public function compile(): string
- {
- return app(RssFeedGenerator::class)->generate()->getXml();
- }
-
- /**
- * Get the route key of the RSS feed, which for this page is also its output path.
- */
- public static function routeKey(): string
- {
- return RssFeedGenerator::getFilename();
- }
-}
diff --git a/packages/framework/src/Framework/Features/XmlGenerators/SitemapPage.php b/packages/framework/src/Framework/Features/XmlGenerators/SitemapPage.php
deleted file mode 100644
index 87673073977..00000000000
--- a/packages/framework/src/Framework/Features/XmlGenerators/SitemapPage.php
+++ /dev/null
@@ -1,39 +0,0 @@
- ['hidden' => true],
- ], exactOutputPath: true);
- }
-
- public function compile(): string
- {
- return app(SitemapGenerator::class)->generate()->getXml();
- }
-
- /**
- * Get the route key of the sitemap, which for this page is also its output path.
- */
- public static function routeKey(): string
- {
- return 'sitemap.xml';
- }
-}
diff --git a/packages/framework/src/Pages/Concerns/HydePage.php b/packages/framework/src/Pages/Concerns/HydePage.php
index dbf08226941..f5a376e14c0 100644
--- a/packages/framework/src/Pages/Concerns/HydePage.php
+++ b/packages/framework/src/Pages/Concerns/HydePage.php
@@ -87,8 +87,8 @@ public static function make(string $identifier = '', FrontMatter|array $matter =
public function __construct(string $identifier = '', FrontMatter|array $matter = [])
{
$this->identifier = $identifier;
- $this->routeKey = RouteKey::fromPage(static::class, $identifier)->get();
$this->matter = $matter instanceof FrontMatter ? $matter : new FrontMatter($matter);
+ $this->routeKey = RouteKey::fromOutputPath($this->getOutputPath())->get();
$this->constructFactoryData();
$this->constructMetadata();
@@ -237,13 +237,25 @@ public static function sourcePath(string $identifier): string
*/
public static function outputPath(string $identifier): string
{
- $routeKey = RouteKey::fromPage(static::class, $identifier);
+ $outputPath = unslash(static::outputDirectory().'/'.static::normalizeOutputIdentifier($identifier));
+ $extension = static::outputExtension();
- if (static::outputExtension() === '.html') {
- return "$routeKey.html";
+ if ($extension !== '.html' && str_ends_with($outputPath, $extension)) {
+ return $outputPath;
}
- return (string) $routeKey;
+ return $outputPath.$extension;
+ }
+
+ /**
+ * Normalize the identifier segment used to construct the output path.
+ *
+ * Page types may override this when source naming conventions contain metadata
+ * that should not be present in the compiled output path.
+ */
+ protected static function normalizeOutputIdentifier(string $identifier): string
+ {
+ return $identifier;
}
/**
diff --git a/packages/framework/src/Pages/DocumentationPage.php b/packages/framework/src/Pages/DocumentationPage.php
index 519696581ed..2c93b954018 100644
--- a/packages/framework/src/Pages/DocumentationPage.php
+++ b/packages/framework/src/Pages/DocumentationPage.php
@@ -8,12 +8,12 @@
use Hyde\Foundation\Facades\Routes;
use Hyde\Pages\Concerns\BaseMarkdownPage;
use Hyde\Support\Models\Route;
+use Hyde\Framework\Features\Navigation\NumericalPageOrderingHelper;
use Hyde\Framework\Features\Documentation\Versioning\DocumentationVersion;
use Hyde\Framework\Features\Documentation\Versioning\DocumentationVersions;
use function trim;
use function sprintf;
-use function Hyde\unslash;
use function basename;
/**
@@ -77,19 +77,6 @@ public function getOnlineSourcePath(): string|false
return sprintf('%s/%s.md', trim(Config::getString('docs.source_file_location_base'), '/'), $this->identifier);
}
- /**
- * Get the route key for the page.
- *
- * If flattened outputs are enabled, this will use the identifier basename so nested pages are flattened.
- * Pages belonging to a documentation version keep the version prefix, so only the structure within the version is flattened.
- */
- public function getRouteKey(): string
- {
- return Config::getBool('docs.flattened_output_paths', true)
- ? unslash(static::outputDirectory().'/'.$this->versionedBasename(basename(parent::getRouteKey())))
- : parent::getRouteKey();
- }
-
/**
* Get the path where the compiled page will be saved.
*
@@ -103,6 +90,13 @@ public function getOutputPath(): string
: parent::getOutputPath();
}
+ protected static function normalizeOutputIdentifier(string $identifier): string
+ {
+ return NumericalPageOrderingHelper::hasNumericalPrefix($identifier)
+ ? NumericalPageOrderingHelper::splitNumericPrefix($identifier)[1]
+ : $identifier;
+ }
+
/**
* Prefix a flattened page basename with the page's version name, if the page belongs to a version.
*/
diff --git a/packages/framework/src/Pages/MarkdownPost.php b/packages/framework/src/Pages/MarkdownPost.php
index 9e96fa1a4d2..40a5e8cecc7 100644
--- a/packages/framework/src/Pages/MarkdownPost.php
+++ b/packages/framework/src/Pages/MarkdownPost.php
@@ -7,6 +7,7 @@
use Hyde\Foundation\Kernel\PageCollection;
use Hyde\Framework\Features\Blogging\Models\FeaturedImage;
use Hyde\Framework\Features\Blogging\Models\PostAuthor;
+use Hyde\Framework\Features\Blogging\BlogPostDatePrefixHelper;
use Hyde\Markdown\Contracts\FrontMatter\BlogPostSchema;
use Hyde\Pages\Concerns\BaseMarkdownPage;
use Hyde\Support\Models\DateString;
@@ -51,4 +52,11 @@ public function toArray(): array
'image' => $this->image,
]);
}
+
+ protected static function normalizeOutputIdentifier(string $identifier): string
+ {
+ return BlogPostDatePrefixHelper::hasDatePrefix($identifier)
+ ? BlogPostDatePrefixHelper::stripDatePrefix($identifier)
+ : $identifier;
+ }
}
diff --git a/packages/framework/src/Support/Models/RouteKey.php b/packages/framework/src/Support/Models/RouteKey.php
index a371b327af9..c8283716d5f 100644
--- a/packages/framework/src/Support/Models/RouteKey.php
+++ b/packages/framework/src/Support/Models/RouteKey.php
@@ -5,12 +5,9 @@
namespace Hyde\Support\Models;
use Stringable;
-use Hyde\Pages\DocumentationPage;
-use Hyde\Pages\MarkdownPost;
-use Hyde\Framework\Features\Navigation\NumericalPageOrderingHelper;
-use Hyde\Framework\Features\Blogging\BlogPostDatePrefixHelper;
use function Hyde\unslash;
+use function substr;
use function str_ends_with;
/**
@@ -55,34 +52,23 @@ public function get(): string
/** @param class-string<\Hyde\Pages\Concerns\HydePage> $pageClass */
public static function fromPage(string $pageClass, string $identifier): self
{
- $identifier = self::stripPrefixIfNeeded($pageClass, $identifier);
- $key = unslash("{$pageClass::baseRouteKey()}/$identifier");
- $extension = $pageClass::outputExtension();
-
- if ($extension !== '.html' && ! str_ends_with($key, $extension)) {
- $key .= $extension;
- }
-
- return new self($key);
+ return self::fromOutputPath($pageClass::outputPath($identifier));
}
/**
- * @param class-string<\Hyde\Pages\Concerns\HydePage> $pageClass
- * */
- protected static function stripPrefixIfNeeded(string $pageClass, string $identifier): string
+ * Create a route key from the resolved path of a compiled page.
+ *
+ * Only the HTML extension is implicit in Hyde routes. Every other extension,
+ * and extensionless output paths, remain part of the route key verbatim.
+ */
+ public static function fromOutputPath(string $outputPath): self
{
- if (is_a($pageClass, DocumentationPage::class, true)) {
- if (NumericalPageOrderingHelper::hasNumericalPrefix($identifier)) {
- return NumericalPageOrderingHelper::splitNumericPrefix($identifier)[1];
- }
- }
+ $key = unslash($outputPath);
- if (is_a($pageClass, MarkdownPost::class, true)) {
- if (BlogPostDatePrefixHelper::hasDatePrefix($identifier)) {
- return BlogPostDatePrefixHelper::stripDatePrefix($identifier);
- }
+ if (str_ends_with($key, '.html')) {
+ $key = substr($key, 0, -5);
}
- return $identifier;
+ return new self($key);
}
}
diff --git a/packages/framework/tests/Feature/Commands/BuildRssFeedCommandTest.php b/packages/framework/tests/Feature/Commands/BuildRssFeedCommandTest.php
index 5d44aa1884e..65fa2908cfe 100644
--- a/packages/framework/tests/Feature/Commands/BuildRssFeedCommandTest.php
+++ b/packages/framework/tests/Feature/Commands/BuildRssFeedCommandTest.php
@@ -11,7 +11,7 @@
use Hyde\Testing\TestCase;
#[\PHPUnit\Framework\Attributes\CoversClass(\Hyde\Console\Commands\BuildRssFeedCommand::class)]
-#[\PHPUnit\Framework\Attributes\CoversClass(\Hyde\Framework\Features\XmlGenerators\RssFeedPage::class)]
+#[\PHPUnit\Framework\Attributes\CoversClass(\Hyde\Framework\Features\GeneratedFiles\GeneratedFilePage::class)]
class BuildRssFeedCommandTest extends TestCase
{
public function testRssFeedIsGeneratedWhenConditionsAreMet()
@@ -44,6 +44,18 @@ public function testRssFilenameCanBeChanged()
Filesystem::unlink('_site/blog.xml');
}
+ public function testRssFilenameCanUseAnImplicitHtmlRouteKey()
+ {
+ $this->withSiteUrl();
+ config(['hyde.rss.filename' => 'blog.html']);
+ $this->file('_posts/foo.md');
+
+ $this->artisan('build:rss')->assertExitCode(0);
+
+ $this->assertFileExists(Hyde::path('_site/blog.html'));
+ Filesystem::unlink('_site/blog.html');
+ }
+
public function testRssFeedIsNotGeneratedWithoutSiteUrl()
{
config(['hyde.url' => '']);
diff --git a/packages/framework/tests/Feature/Commands/BuildSitemapCommandTest.php b/packages/framework/tests/Feature/Commands/BuildSitemapCommandTest.php
index 3822aa4e38e..4ce6f454013 100644
--- a/packages/framework/tests/Feature/Commands/BuildSitemapCommandTest.php
+++ b/packages/framework/tests/Feature/Commands/BuildSitemapCommandTest.php
@@ -10,7 +10,7 @@
use Hyde\Testing\TestCase;
#[\PHPUnit\Framework\Attributes\CoversClass(\Hyde\Console\Commands\BuildSitemapCommand::class)]
-#[\PHPUnit\Framework\Attributes\CoversClass(\Hyde\Framework\Features\XmlGenerators\SitemapPage::class)]
+#[\PHPUnit\Framework\Attributes\CoversClass(\Hyde\Framework\Features\GeneratedFiles\GeneratedFilePage::class)]
class BuildSitemapCommandTest extends TestCase
{
public function testSitemapIsGeneratedWhenConditionsAreMet()
diff --git a/packages/framework/tests/Feature/HydeExtensionFeatureTest.php b/packages/framework/tests/Feature/HydeExtensionFeatureTest.php
index f074acc165e..19d593bb14a 100644
--- a/packages/framework/tests/Feature/HydeExtensionFeatureTest.php
+++ b/packages/framework/tests/Feature/HydeExtensionFeatureTest.php
@@ -62,7 +62,7 @@ public function testHandlerMethodsAreCalledByDiscovery()
$this->kernel->boot();
- $this->assertSame(['booting', 'files', 'pages', 'routes', 'booted'], HydeTestExtension::$callCache);
+ $this->assertSame(['booting', 'files', 'pages', 'default-pages', 'routes', 'booted'], HydeTestExtension::$callCache);
HydeTestExtension::$callCache = [];
}
@@ -83,6 +83,30 @@ public function testPageHandlerDependencyInjection()
$this->assertInstanceOf(PageCollection::class, ...InspectableTestExtension::getCalled('pages'));
}
+ public function testDefaultPageHandlerDependencyInjection()
+ {
+ $this->kernel->registerExtension(InspectableTestExtension::class);
+ $this->kernel->boot();
+
+ $this->assertInstanceOf(PageCollection::class, ...InspectableTestExtension::getCalled('default-pages'));
+ }
+
+ public function testEveryNormalPageHandlerRunsBeforeAnyDefaultPageHandler()
+ {
+ DiscoveryPhaseOrder::$calls = [];
+
+ $this->kernel->registerExtension(FirstDiscoveryPhaseExtension::class);
+ $this->kernel->registerExtension(SecondDiscoveryPhaseExtension::class);
+ $this->kernel->boot();
+
+ $this->assertSame([
+ 'extension-a pages',
+ 'extension-b pages',
+ 'extension-a defaults',
+ 'extension-b defaults',
+ ], DiscoveryPhaseOrder::$calls);
+ }
+
public function testRouteHandlerDependencyInjection()
{
$this->kernel->registerExtension(InspectableTestExtension::class);
@@ -190,6 +214,11 @@ public function discoverPages(PageCollection $collection): void
static::$callCache[] = 'pages';
}
+ public function discoverDefaultPages(PageCollection $collection): void
+ {
+ static::$callCache[] = 'default-pages';
+ }
+
public function discoverRoutes(RouteCollection $collection): void
{
static::$callCache[] = 'routes';
@@ -276,6 +305,11 @@ public function discoverPages(PageCollection $collection): void
self::$callCache['pages'] = func_get_args();
}
+ public function discoverDefaultPages(PageCollection $collection): void
+ {
+ self::$callCache['default-pages'] = func_get_args();
+ }
+
public function discoverRoutes(RouteCollection $collection): void
{
self::$callCache['routes'] = func_get_args();
@@ -301,3 +335,34 @@ public function booted(HydeKernel $kernel): void
static::$callCache['booted'] = $kernel;
}
}
+
+class DiscoveryPhaseOrder
+{
+ public static array $calls = [];
+}
+
+class FirstDiscoveryPhaseExtension extends HydeExtension
+{
+ public function discoverPages(PageCollection $collection): void
+ {
+ DiscoveryPhaseOrder::$calls[] = 'extension-a pages';
+ }
+
+ public function discoverDefaultPages(PageCollection $collection): void
+ {
+ DiscoveryPhaseOrder::$calls[] = 'extension-a defaults';
+ }
+}
+
+class SecondDiscoveryPhaseExtension extends HydeExtension
+{
+ public function discoverPages(PageCollection $collection): void
+ {
+ DiscoveryPhaseOrder::$calls[] = 'extension-b pages';
+ }
+
+ public function discoverDefaultPages(PageCollection $collection): void
+ {
+ DiscoveryPhaseOrder::$calls[] = 'extension-b defaults';
+ }
+}
diff --git a/packages/framework/tests/Feature/HydePageTest.php b/packages/framework/tests/Feature/HydePageTest.php
index a752e0e7417..b7321546c51 100644
--- a/packages/framework/tests/Feature/HydePageTest.php
+++ b/packages/framework/tests/Feature/HydePageTest.php
@@ -176,6 +176,14 @@ public function testBaseRouteKey()
$this->assertSame(TestPage::outputDirectory(), TestPage::baseRouteKey());
}
+ public function testOutputDirectoryRemainsAuthoritativeWhenBaseRouteKeyIsOverridden()
+ {
+ $page = new CustomBaseRouteKeyTestPage('hello-world');
+
+ $this->assertSame('ignored/hello-world.html', $page->getOutputPath());
+ $this->assertSame('ignored/hello-world', $page->getRouteKey());
+ }
+
public function testIsDiscoverable()
{
$this->assertTrue(TestPage::isDiscoverable());
@@ -1336,6 +1344,18 @@ class NonHtmlOutputTestPage extends HydePage
public static string $template = 'template';
}
+class CustomBaseRouteKeyTestPage extends HydePage
+{
+ use VoidCompiler;
+
+ public static string $outputDirectory = 'ignored';
+
+ public static function baseRouteKey(): string
+ {
+ return 'custom';
+ }
+}
+
class MissingDotOutputExtensionTestPage extends HydePage
{
use VoidCompiler;
diff --git a/packages/framework/tests/Feature/LlmsTxtPageTest.php b/packages/framework/tests/Feature/LlmsTxtPageTest.php
index 1b3b65ebc80..9324e942588 100644
--- a/packages/framework/tests/Feature/LlmsTxtPageTest.php
+++ b/packages/framework/tests/Feature/LlmsTxtPageTest.php
@@ -12,7 +12,8 @@
use Hyde\Foundation\Concerns\HydeExtension;
use Hyde\Foundation\Kernel\PageCollection;
use Hyde\Framework\Features\TextGenerators\LlmsTxtGenerator;
-use Hyde\Framework\Features\TextGenerators\LlmsTxtPage;
+use Hyde\Framework\Features\GeneratedFiles\GeneratedFilePage;
+use Hyde\Framework\Features\GeneratedFiles\GeneratedFilePaths;
use Illuminate\Support\Facades\File;
/**
@@ -22,7 +23,8 @@
*
* @see \Hyde\Framework\Testing\Feature\LlmsTxtGeneratorTest
*/
-#[\PHPUnit\Framework\Attributes\CoversClass(\Hyde\Framework\Features\TextGenerators\LlmsTxtPage::class)]
+#[\PHPUnit\Framework\Attributes\CoversClass(\Hyde\Framework\Features\GeneratedFiles\GeneratedFilePage::class)]
+#[\PHPUnit\Framework\Attributes\CoversClass(\Hyde\Framework\Features\GeneratedFiles\GeneratedFileRegistry::class)]
#[\PHPUnit\Framework\Attributes\CoversClass(\Hyde\Foundation\HydeCoreExtension::class)]
class LlmsTxtPageTest extends TestCase
{
@@ -46,7 +48,7 @@ public function testLlmsTxtPageIsRegisteredAsRouteByDefault()
$page = Routes::get('llms.txt')->getPage();
- $this->assertInstanceOf(LlmsTxtPage::class, $page);
+ $this->assertInstanceOf(GeneratedFilePage::class, $page);
$this->assertSame('llms.txt', $page->getOutputPath());
$this->assertSame('llms.txt', $page->getRouteKey());
}
@@ -67,7 +69,7 @@ public function testLlmsTxtPageIsNotRegisteredWhenDisabledInConfig()
public function testLlmsTxtPageIsHiddenFromNavigationAndExcludedFromTheSitemap()
{
- $page = new LlmsTxtPage();
+ $page = new GeneratedFilePage(GeneratedFilePaths::LLMS, LlmsTxtGenerator::class);
$this->assertFalse($page->showInNavigation());
$this->assertFalse($page->showInSitemap());
@@ -75,7 +77,7 @@ public function testLlmsTxtPageIsHiddenFromNavigationAndExcludedFromTheSitemap()
public function testLlmsTxtPageCompilesUsingTheLlmsTxtGenerator()
{
- $this->assertSame((new LlmsTxtGenerator())->generate(), (new LlmsTxtPage())->compile());
+ $this->assertSame((new LlmsTxtGenerator())->generate(), (new GeneratedFilePage(GeneratedFilePaths::LLMS, LlmsTxtGenerator::class))->compile());
}
public function testLlmsTxtGeneratorCanBeSwappedThroughTheServiceContainer()
@@ -133,7 +135,7 @@ public function testUserPageRegisteredInBootingCallbackSuppressesTheGeneratedLlm
$page = Routes::get('llms.txt')->getPage();
- $this->assertNotInstanceOf(LlmsTxtPage::class, $page);
+ $this->assertNotInstanceOf(GeneratedFilePage::class, $page);
$this->assertSame(1, Hyde::pages()->filter(fn ($page) => $page->getRouteKey() === 'llms.txt')->count());
$this->artisan('build')->assertExitCode(0);
@@ -147,7 +149,7 @@ public function testUserPageRegisteredThroughExtensionSuppressesTheGeneratedLlms
$page = Routes::get('llms.txt')->getPage();
- $this->assertNotInstanceOf(LlmsTxtPage::class, $page);
+ $this->assertNotInstanceOf(GeneratedFilePage::class, $page);
$this->assertSame(1, Hyde::pages()->filter(fn ($page) => $page->getRouteKey() === 'llms.txt')->count());
$this->artisan('build')->assertExitCode(0);
diff --git a/packages/framework/tests/Feature/RobotsTxtPageTest.php b/packages/framework/tests/Feature/RobotsTxtPageTest.php
index 00a66ce3f6b..588ca9f0d09 100644
--- a/packages/framework/tests/Feature/RobotsTxtPageTest.php
+++ b/packages/framework/tests/Feature/RobotsTxtPageTest.php
@@ -12,7 +12,8 @@
use Hyde\Foundation\Concerns\HydeExtension;
use Hyde\Foundation\Kernel\PageCollection;
use Hyde\Framework\Features\TextGenerators\RobotsTxtGenerator;
-use Hyde\Framework\Features\TextGenerators\RobotsTxtPage;
+use Hyde\Framework\Features\GeneratedFiles\GeneratedFilePage;
+use Hyde\Framework\Features\GeneratedFiles\GeneratedFilePaths;
use Illuminate\Support\Facades\File;
/**
@@ -22,7 +23,8 @@
*
* @see \Hyde\Framework\Testing\Feature\RobotsTxtGeneratorTest
*/
-#[\PHPUnit\Framework\Attributes\CoversClass(\Hyde\Framework\Features\TextGenerators\RobotsTxtPage::class)]
+#[\PHPUnit\Framework\Attributes\CoversClass(\Hyde\Framework\Features\GeneratedFiles\GeneratedFilePage::class)]
+#[\PHPUnit\Framework\Attributes\CoversClass(\Hyde\Framework\Features\GeneratedFiles\GeneratedFileRegistry::class)]
#[\PHPUnit\Framework\Attributes\CoversClass(\Hyde\Foundation\HydeCoreExtension::class)]
class RobotsTxtPageTest extends TestCase
{
@@ -39,7 +41,7 @@ public function testRobotsTxtPageIsRegisteredAsRouteByDefault()
$page = Routes::get('robots.txt')->getPage();
- $this->assertInstanceOf(RobotsTxtPage::class, $page);
+ $this->assertInstanceOf(GeneratedFilePage::class, $page);
$this->assertSame('robots.txt', $page->getOutputPath());
$this->assertSame('robots.txt', $page->getRouteKey());
}
@@ -60,7 +62,7 @@ public function testRobotsTxtPageIsNotRegisteredWhenDisabledInConfig()
public function testRobotsTxtPageIsHiddenFromNavigationAndExcludedFromTheSitemap()
{
- $page = new RobotsTxtPage();
+ $page = new GeneratedFilePage(GeneratedFilePaths::ROBOTS, RobotsTxtGenerator::class);
$this->assertFalse($page->showInNavigation());
$this->assertFalse($page->showInSitemap());
@@ -70,7 +72,20 @@ public function testRobotsTxtPageCompilesUsingTheRobotsTxtGenerator()
{
$this->withoutSiteUrl();
- $this->assertSame("User-agent: *\nAllow: /\n", (new RobotsTxtPage())->compile());
+ $this->assertSame("User-agent: *\nAllow: /\n", (new GeneratedFilePage(GeneratedFilePaths::ROBOTS, RobotsTxtGenerator::class))->compile());
+ }
+
+ public function testGeneratedPageRetainsItsGeneratorAcrossNativeSerialization()
+ {
+ $this->withoutSiteUrl();
+
+ $page = new GeneratedFilePage(GeneratedFilePaths::ROBOTS, RobotsTxtGenerator::class);
+
+ $restored = unserialize(serialize($page), ['allowed_classes' => true]);
+
+ $this->assertInstanceOf(GeneratedFilePage::class, $restored);
+ $this->assertSame(GeneratedFilePaths::ROBOTS, $restored->getOutputPath());
+ $this->assertSame("User-agent: *\nAllow: /\n", $restored->compile());
}
public function testRobotsTxtGeneratorCanBeSwappedThroughTheServiceContainer()
@@ -132,7 +147,7 @@ public function testUserPageRegisteredInBootingCallbackSuppressesTheGeneratedRob
$page = Routes::get('robots.txt')->getPage();
- $this->assertNotInstanceOf(RobotsTxtPage::class, $page);
+ $this->assertNotInstanceOf(GeneratedFilePage::class, $page);
$this->assertSame(1, Hyde::pages()->filter(fn ($page) => $page->getRouteKey() === 'robots.txt')->count());
$this->artisan('build')->assertExitCode(0);
@@ -146,7 +161,7 @@ public function testUserPageRegisteredThroughExtensionSuppressesTheGeneratedRobo
$page = Routes::get('robots.txt')->getPage();
- $this->assertNotInstanceOf(RobotsTxtPage::class, $page);
+ $this->assertNotInstanceOf(GeneratedFilePage::class, $page);
$this->assertSame(1, Hyde::pages()->filter(fn ($page) => $page->getRouteKey() === 'robots.txt')->count());
$this->artisan('build')->assertExitCode(0);
diff --git a/packages/framework/tests/Feature/RouteCollectionTest.php b/packages/framework/tests/Feature/RouteCollectionTest.php
index 0e75ad34344..575488f1c7f 100644
--- a/packages/framework/tests/Feature/RouteCollectionTest.php
+++ b/packages/framework/tests/Feature/RouteCollectionTest.php
@@ -11,6 +11,7 @@
use Hyde\Pages\BladePage;
use Hyde\Pages\DocumentationPage;
use Hyde\Pages\HtmlPage;
+use Hyde\Pages\InMemoryPage;
use Hyde\Pages\MarkdownPage;
use Hyde\Pages\MarkdownPost;
use Hyde\Support\Models\Route;
@@ -114,6 +115,28 @@ public function testAddRouteAddsNewRoute()
$this->assertEquals(new Route(new BladePage('new')), $collection->last());
}
+ public function testRegisteredRouteIdentityRemainsStableWhenOutputResolutionChanges()
+ {
+ $page = new class extends InMemoryPage
+ {
+ public string $resolvedOutputPath = 'first.html';
+
+ public function getOutputPath(): string
+ {
+ return $this->resolvedOutputPath;
+ }
+ };
+
+ $collection = Hyde::routes();
+ $collection->addRoute(new Route($page));
+
+ $page->resolvedOutputPath = 'second.json';
+
+ $this->assertTrue($collection->has('first'));
+ $this->assertFalse($collection->has('second.json'));
+ $this->assertSame('first', $collection->get('first')->getRouteKey());
+ }
+
public function testGetRoute()
{
$this->assertEquals(new Route(new BladePage('index')), Routes::getRoute('index'));
diff --git a/packages/framework/tests/Feature/RssFeedPageTest.php b/packages/framework/tests/Feature/RssFeedPageTest.php
index a7d7853896f..95dae56f1cf 100644
--- a/packages/framework/tests/Feature/RssFeedPageTest.php
+++ b/packages/framework/tests/Feature/RssFeedPageTest.php
@@ -13,7 +13,8 @@
use Hyde\Foundation\Concerns\HydeExtension;
use Hyde\Foundation\Kernel\PageCollection;
use Hyde\Framework\Features\XmlGenerators\RssFeedGenerator;
-use Hyde\Framework\Features\XmlGenerators\RssFeedPage;
+use Hyde\Framework\Features\GeneratedFiles\GeneratedFilePage;
+use Hyde\Framework\Features\GeneratedFiles\GeneratedFileRegistry;
use Illuminate\Support\Facades\File;
/**
@@ -24,7 +25,8 @@
* @see \Hyde\Framework\Testing\Feature\Services\RssFeedServiceTest
* @see \Hyde\Framework\Testing\Feature\Commands\BuildRssFeedCommandTest
*/
-#[\PHPUnit\Framework\Attributes\CoversClass(\Hyde\Framework\Features\XmlGenerators\RssFeedPage::class)]
+#[\PHPUnit\Framework\Attributes\CoversClass(\Hyde\Framework\Features\GeneratedFiles\GeneratedFilePage::class)]
+#[\PHPUnit\Framework\Attributes\CoversClass(\Hyde\Framework\Features\GeneratedFiles\GeneratedFileRegistry::class)]
#[\PHPUnit\Framework\Attributes\CoversClass(\Hyde\Foundation\HydeCoreExtension::class)]
class RssFeedPageTest extends TestCase
{
@@ -49,7 +51,7 @@ public function testFeedPageIsRegisteredAsRouteWhenRssFeatureIsEnabled()
$page = Routes::get('feed.xml')->getPage();
- $this->assertInstanceOf(RssFeedPage::class, $page);
+ $this->assertInstanceOf(GeneratedFilePage::class, $page);
$this->assertSame('feed.xml', $page->getOutputPath());
$this->assertSame('feed.xml', $page->getRouteKey());
}
@@ -92,9 +94,18 @@ public function testFeedPageUsesConfiguredFilenameVerbatimForAnyExtension()
$this->assertSame('feed.rss', Routes::get('feed.rss')->getPage()->getOutputPath());
}
+ public function testConfiguredHtmlFilenameUsesAnImplicitHtmlRouteKey()
+ {
+ config(['hyde.rss.filename' => 'feed.html']);
+
+ $this->assertFalse(Routes::exists('feed.html'));
+ $this->assertTrue(Routes::exists('feed'));
+ $this->assertSame('feed.html', Routes::get('feed')->getPage()->getOutputPath());
+ }
+
public function testFeedPageIsHiddenFromNavigationAndExcludesItselfFromTheSitemap()
{
- $page = new RssFeedPage();
+ $page = new GeneratedFilePage(GeneratedFileRegistry::rssOutputPath(), RssFeedGenerator::class);
$this->assertFalse($page->showInNavigation());
$this->assertFalse($page->showInSitemap());
@@ -102,7 +113,7 @@ public function testFeedPageIsHiddenFromNavigationAndExcludesItselfFromTheSitema
public function testFeedPageCompilesUsingTheRssFeedGenerator()
{
- $contents = (new RssFeedPage())->compile();
+ $contents = (new GeneratedFilePage(GeneratedFileRegistry::rssOutputPath(), RssFeedGenerator::class))->compile();
$this->assertStringStartsWith('', $contents);
$this->assertStringContainsString('getPage();
- $this->assertNotInstanceOf(RssFeedPage::class, $page);
+ $this->assertNotInstanceOf(GeneratedFilePage::class, $page);
$this->assertSame(1, Hyde::pages()->filter(fn ($page) => $page->getRouteKey() === 'feed.xml')->count());
$this->artisan('build')->assertExitCode(0);
@@ -182,7 +193,7 @@ public function testUserPageRegisteredThroughExtensionSuppressesTheGeneratedFeed
$page = Routes::get('feed.xml')->getPage();
- $this->assertNotInstanceOf(RssFeedPage::class, $page);
+ $this->assertNotInstanceOf(GeneratedFilePage::class, $page);
$this->assertSame(1, Hyde::pages()->filter(fn ($page) => $page->getRouteKey() === 'feed.xml')->count());
$this->artisan('build')->assertExitCode(0);
diff --git a/packages/framework/tests/Feature/SitemapFeatureTest.php b/packages/framework/tests/Feature/SitemapFeatureTest.php
index cf7a493bab9..9a41ed68216 100644
--- a/packages/framework/tests/Feature/SitemapFeatureTest.php
+++ b/packages/framework/tests/Feature/SitemapFeatureTest.php
@@ -20,7 +20,7 @@
* @see \Hyde\Framework\Testing\Feature\Commands\BuildSitemapCommandTest
*/
#[\PHPUnit\Framework\Attributes\CoversClass(\Hyde\Framework\Features\XmlGenerators\SitemapGenerator::class)]
-#[\PHPUnit\Framework\Attributes\CoversClass(\Hyde\Framework\Features\XmlGenerators\SitemapPage::class)]
+#[\PHPUnit\Framework\Attributes\CoversClass(\Hyde\Framework\Features\GeneratedFiles\GeneratedFilePage::class)]
#[\PHPUnit\Framework\Attributes\CoversClass(\Hyde\Console\Commands\BuildSitemapCommand::class)]
class SitemapFeatureTest extends TestCase
{
diff --git a/packages/framework/tests/Feature/SitemapPageTest.php b/packages/framework/tests/Feature/SitemapPageTest.php
index 873146887b1..919c83e62b0 100644
--- a/packages/framework/tests/Feature/SitemapPageTest.php
+++ b/packages/framework/tests/Feature/SitemapPageTest.php
@@ -12,7 +12,8 @@
use Hyde\Foundation\Concerns\HydeExtension;
use Hyde\Foundation\Kernel\PageCollection;
use Hyde\Framework\Features\XmlGenerators\SitemapGenerator;
-use Hyde\Framework\Features\XmlGenerators\SitemapPage;
+use Hyde\Framework\Features\GeneratedFiles\GeneratedFilePage;
+use Hyde\Framework\Features\GeneratedFiles\GeneratedFilePaths;
use Illuminate\Support\Facades\File;
/**
@@ -23,7 +24,8 @@
* @see \Hyde\Framework\Testing\Feature\SitemapFeatureTest
* @see \Hyde\Framework\Testing\Feature\Services\SitemapServiceTest
*/
-#[\PHPUnit\Framework\Attributes\CoversClass(\Hyde\Framework\Features\XmlGenerators\SitemapPage::class)]
+#[\PHPUnit\Framework\Attributes\CoversClass(\Hyde\Framework\Features\GeneratedFiles\GeneratedFilePage::class)]
+#[\PHPUnit\Framework\Attributes\CoversClass(\Hyde\Framework\Features\GeneratedFiles\GeneratedFileRegistry::class)]
#[\PHPUnit\Framework\Attributes\CoversClass(\Hyde\Foundation\HydeCoreExtension::class)]
class SitemapPageTest extends TestCase
{
@@ -42,7 +44,7 @@ public function testSitemapPageIsRegisteredAsRouteWhenSitemapFeatureIsEnabled()
$page = Routes::get('sitemap.xml')->getPage();
- $this->assertInstanceOf(SitemapPage::class, $page);
+ $this->assertInstanceOf(GeneratedFilePage::class, $page);
$this->assertSame('sitemap.xml', $page->getOutputPath());
$this->assertSame('sitemap.xml', $page->getRouteKey());
}
@@ -64,7 +66,7 @@ public function testSitemapPageIsNotRegisteredWhenSitemapIsDisabledInConfig()
public function testSitemapPageIsHiddenFromNavigationAndExcludesItselfFromTheSitemap()
{
- $page = new SitemapPage();
+ $page = new GeneratedFilePage(GeneratedFilePaths::SITEMAP, SitemapGenerator::class);
$this->assertFalse($page->showInNavigation());
$this->assertFalse($page->showInSitemap());
@@ -74,7 +76,7 @@ public function testSitemapPageCompilesUsingTheSitemapGenerator()
{
$this->withSiteUrl();
- $contents = (new SitemapPage())->compile();
+ $contents = (new GeneratedFilePage(GeneratedFilePaths::SITEMAP, SitemapGenerator::class))->compile();
$this->assertStringStartsWith('', $contents);
$this->assertStringContainsString('getPage();
- $this->assertNotInstanceOf(SitemapPage::class, $page);
+ $this->assertNotInstanceOf(GeneratedFilePage::class, $page);
$this->assertSame(1, Hyde::pages()->filter(fn ($page) => $page->getRouteKey() === 'sitemap.xml')->count());
$this->artisan('build')->assertExitCode(0);
@@ -154,7 +156,7 @@ public function testUserPageRegisteredInBootingCallbackSuppressesTheGeneratedSit
$this->assertSame('user defined sitemap', file_get_contents(Hyde::path('_site/sitemap.xml')));
}
- public function testUserPageRegisteredThroughExtensionSuppressesTheGeneratedSitemapPage()
+ public function testUserPageRegisteredThroughExtensionSuppressesTheGeneratedSitemapPageRegardlessOfCollectionKey()
{
$this->withSiteUrl();
@@ -162,7 +164,7 @@ public function testUserPageRegisteredThroughExtensionSuppressesTheGeneratedSite
$page = Routes::get('sitemap.xml')->getPage();
- $this->assertNotInstanceOf(SitemapPage::class, $page);
+ $this->assertNotInstanceOf(GeneratedFilePage::class, $page);
$this->assertSame(1, Hyde::pages()->filter(fn ($page) => $page->getRouteKey() === 'sitemap.xml')->count());
$this->artisan('build')->assertExitCode(0);
@@ -175,6 +177,12 @@ class SitemapPageTestExtension extends HydeExtension
{
public function discoverPages(PageCollection $collection): void
{
- $collection->addPage(InMemoryPage::file('sitemap.xml', contents: 'extension defined sitemap'));
+ $collection->addPage(new class('sitemap.xml', contents: 'extension defined sitemap', exactOutputPath: true) extends InMemoryPage
+ {
+ public function getSourcePath(): string
+ {
+ return '_custom/sitemap.xml';
+ }
+ });
}
}
diff --git a/packages/framework/tests/Unit/Pages/InMemoryPageTest.php b/packages/framework/tests/Unit/Pages/InMemoryPageTest.php
index f20206ab5b9..342b7a5aafd 100644
--- a/packages/framework/tests/Unit/Pages/InMemoryPageTest.php
+++ b/packages/framework/tests/Unit/Pages/InMemoryPageTest.php
@@ -174,6 +174,15 @@ public function testFileUsesAnyOutputPathExactly()
$this->assertSame('docs/1.x/search.json', InMemoryPage::file('docs/1.x/search.json')->getOutputPath());
}
+ public function testExactHtmlFileUsesTheUniversalImplicitHtmlRouteRule()
+ {
+ $this->assertSame('download.html', InMemoryPage::file('download.html')->getOutputPath());
+ $this->assertSame('download', InMemoryPage::file('download.html')->getRouteKey());
+
+ $this->assertSame('download.html.html', InMemoryPage::make('download.html')->getOutputPath());
+ $this->assertSame('download.html', InMemoryPage::make('download.html')->getRouteKey());
+ }
+
#[DataProvider('invalidExactOutputPaths')]
public function testFileRejectsInvalidOutputPaths(string $path): void
{
@@ -205,6 +214,90 @@ public function testGetRouteKeyForFile()
$this->assertSame('feed', InMemoryPage::file('feed')->getRouteKey());
}
+ public function testRouteKeyIsDerivedFromOverriddenOutputPath()
+ {
+ $page = new class('ignored') extends InMemoryPage
+ {
+ public function getOutputPath(): string
+ {
+ return 'custom/data.json';
+ }
+ };
+
+ $this->assertSame('custom/data.json', $page->getRouteKey());
+ }
+
+ public function testOverriddenOutputPathCanUseStateInitializedBeforeParentConstructor()
+ {
+ $this->withSiteUrl();
+ config([
+ 'hyde.navigation.labels' => ['custom/data.json' => 'Custom Data'],
+ 'hyde.navigation.exclude' => ['custom/data.json'],
+ ]);
+
+ $page = new class extends InMemoryPage
+ {
+ private string $resolvedOutputPath;
+
+ public function __construct()
+ {
+ $this->resolvedOutputPath = 'custom/data.json';
+
+ parent::__construct('custom');
+ }
+
+ public function getOutputPath(): string
+ {
+ return $this->resolvedOutputPath;
+ }
+ };
+
+ $this->assertSame('custom/data.json', $page->getRouteKey());
+ $this->assertSame('Custom', $page->title);
+ $this->assertSame('Custom Data', $page->navigation->label);
+ $this->assertTrue($page->navigation->hidden);
+ $this->assertSame('custom/data.json', $page->getLink());
+ $this->assertSame('https://example.com/custom/data.json', $page->getCanonicalUrl());
+ $this->assertStringContainsString('custom/data.json', $page->metadata()->render());
+
+ $factoryData = $page->toCoreDataObject();
+
+ $this->assertSame('custom/data.json', $factoryData->outputPath);
+ $this->assertSame('custom/data.json', $factoryData->routeKey);
+ }
+
+ public function testRouteKeyIsResolvedDuringConstructionAndRemainsStable()
+ {
+ $page = new class extends InMemoryPage
+ {
+ public string $resolvedOutputPath = 'first.html';
+
+ public function getOutputPath(): string
+ {
+ return $this->resolvedOutputPath;
+ }
+ };
+
+ $this->assertSame('first', $page->getRouteKey());
+
+ $page->resolvedOutputPath = 'second.json';
+
+ $this->assertSame('first', $page->getRouteKey());
+ $this->assertSame('first', $page->routeKey);
+ }
+
+ public function testReadonlyRouteKeyPropertyCannotBeAssignedExternally()
+ {
+ $page = new InMemoryPage('first');
+
+ $this->assertTrue(property_exists($page, 'routeKey'));
+ $this->assertSame('first', $page->routeKey);
+
+ $this->expectException(\Error::class);
+
+ $page->routeKey = 'incorrect';
+ }
+
public function testGetLinkForFile()
{
$this->assertSame('robots.txt', InMemoryPage::file('robots.txt')->getLink());
diff --git a/packages/framework/tests/Unit/RouteKeyTest.php b/packages/framework/tests/Unit/RouteKeyTest.php
index d5d7a2252ae..195d9bb4f4d 100644
--- a/packages/framework/tests/Unit/RouteKeyTest.php
+++ b/packages/framework/tests/Unit/RouteKeyTest.php
@@ -67,6 +67,14 @@ public function testFromPage()
$this->assertEquals(new RouteKey('docs/foo'), RouteKey::fromPage(DocumentationPage::class, 'foo'));
}
+ public function testFromOutputPathRemovesOnlyTheHtmlExtension()
+ {
+ $this->assertSame('foo/bar', RouteKey::fromOutputPath('foo/bar.html')->get());
+ $this->assertSame('foo/bar.html', RouteKey::fromOutputPath('foo/bar.html.html')->get());
+ $this->assertSame('foo/bar.json', RouteKey::fromOutputPath('foo/bar.json')->get());
+ $this->assertSame('feed', RouteKey::fromOutputPath('feed')->get());
+ }
+
public function testFromPageWithNestedIdentifier()
{
$this->assertEquals(new RouteKey('foo/bar'), RouteKey::fromPage(HtmlPage::class, 'foo/bar'));