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