From d4e39a387c194d33cb9ded743ca5a371162d533a Mon Sep 17 00:00:00 2001 From: Emma De Silva Date: Tue, 14 Jul 2026 14:04:51 +0200 Subject: [PATCH 01/17] Document non-HTML page simplification tasks --- TASK_NON_HTML_PAGE_SIMPLIFICATION.md | 119 +++++++++++++++++++++++++++ 1 file changed, 119 insertions(+) create mode 100644 TASK_NON_HTML_PAGE_SIMPLIFICATION.md 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. From 891772d1e7003be256ba94dfe8e85a55d531c4c1 Mon Sep 17 00:00:00 2001 From: Emma De Silva Date: Tue, 14 Jul 2026 14:07:41 +0200 Subject: [PATCH 02/17] Derive route keys from resolved output paths --- .../framework/src/Pages/Concerns/HydePage.php | 22 ++++++++--- .../framework/src/Pages/DocumentationPage.php | 22 ++++------- packages/framework/src/Pages/MarkdownPost.php | 8 ++++ .../framework/src/Support/Models/RouteKey.php | 39 ++++++------------- .../tests/Unit/Pages/InMemoryPageTest.php | 13 +++++++ .../framework/tests/Unit/RouteKeyTest.php | 8 ++++ 6 files changed, 66 insertions(+), 46 deletions(-) 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..9971bea6132 100644 --- a/packages/framework/src/Support/Models/RouteKey.php +++ b/packages/framework/src/Support/Models/RouteKey.php @@ -5,12 +5,8 @@ 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 +51,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/Unit/Pages/InMemoryPageTest.php b/packages/framework/tests/Unit/Pages/InMemoryPageTest.php index f20206ab5b9..14402da12db 100644 --- a/packages/framework/tests/Unit/Pages/InMemoryPageTest.php +++ b/packages/framework/tests/Unit/Pages/InMemoryPageTest.php @@ -205,6 +205,19 @@ 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 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')); From b7cee803fd249400afa341315231971a1e2dc521 Mon Sep 17 00:00:00 2001 From: Emma De Silva Date: Tue, 14 Jul 2026 14:10:42 +0200 Subject: [PATCH 03/17] Consolidate generated file page wiring --- .../Console/Commands/BuildRssFeedCommand.php | 4 +- .../Console/Commands/BuildSitemapCommand.php | 4 +- .../src/Foundation/HydeCoreExtension.php | 55 ++----------------- .../GeneratedFiles/GeneratedFileGenerator.php | 14 +++++ .../GeneratedFiles/GeneratedFilePage.php | 33 +++++++++++ .../GeneratedFiles/GeneratedFileRegistry.php | 40 ++++++++++++++ .../TextGenerators/LlmsTxtGenerator.php | 10 +++- .../Features/TextGenerators/LlmsTxtPage.php | 39 ------------- .../TextGenerators/RobotsTxtGenerator.php | 14 +++-- .../Features/TextGenerators/RobotsTxtPage.php | 39 ------------- .../XmlGenerators/BaseXmlGenerator.php | 8 ++- .../Features/XmlGenerators/RssFeedPage.php | 39 ------------- .../Features/XmlGenerators/SitemapPage.php | 39 ------------- .../Commands/BuildRssFeedCommandTest.php | 2 +- .../Commands/BuildSitemapCommandTest.php | 2 +- .../tests/Feature/LlmsTxtPageTest.php | 16 +++--- .../tests/Feature/RobotsTxtPageTest.php | 16 +++--- .../tests/Feature/RssFeedPageTest.php | 16 +++--- .../tests/Feature/SitemapFeatureTest.php | 2 +- .../tests/Feature/SitemapPageTest.php | 16 +++--- 20 files changed, 160 insertions(+), 248 deletions(-) create mode 100644 packages/framework/src/Framework/Features/GeneratedFiles/GeneratedFileGenerator.php create mode 100644 packages/framework/src/Framework/Features/GeneratedFiles/GeneratedFilePage.php create mode 100644 packages/framework/src/Framework/Features/GeneratedFiles/GeneratedFileRegistry.php delete mode 100644 packages/framework/src/Framework/Features/TextGenerators/LlmsTxtPage.php delete mode 100644 packages/framework/src/Framework/Features/TextGenerators/RobotsTxtPage.php delete mode 100644 packages/framework/src/Framework/Features/XmlGenerators/RssFeedPage.php delete mode 100644 packages/framework/src/Framework/Features/XmlGenerators/SitemapPage.php diff --git a/packages/framework/src/Console/Commands/BuildRssFeedCommand.php b/packages/framework/src/Console/Commands/BuildRssFeedCommand.php index 85a9b820f3a..12d0589aa7f 100644 --- a/packages/framework/src/Console/Commands/BuildRssFeedCommand.php +++ b/packages/framework/src/Console/Commands/BuildRssFeedCommand.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\RssFeedPage; +use Hyde\Framework\Features\GeneratedFiles\GeneratedFileRegistry; use function sprintf; @@ -25,7 +25,7 @@ class BuildRssFeedCommand extends Command public function handle(): int { - $page = Routes::find(RssFeedPage::routeKey())?->getPage(); + $page = Routes::find(GeneratedFileRegistry::rssOutputPath())?->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..db6d560da8b 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\GeneratedFileRegistry; use function sprintf; @@ -25,7 +25,7 @@ class BuildSitemapCommand extends Command public function handle(): int { - $page = Routes::find(SitemapPage::routeKey())?->getPage(); + $page = Routes::find(GeneratedFileRegistry::SITEMAP)?->getPage(); if ($page === null) { $this->error('Cannot generate the sitemap as the feature is not enabled'); diff --git a/packages/framework/src/Foundation/HydeCoreExtension.php b/packages/framework/src/Foundation/HydeCoreExtension.php index 85ab57ee089..b0be867fafc 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; @@ -85,52 +82,10 @@ 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 - { - 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/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/GeneratedFileRegistry.php b/packages/framework/src/Framework/Features/GeneratedFiles/GeneratedFileRegistry.php new file mode 100644 index 00000000000..2776f5ebeca --- /dev/null +++ b/packages/framework/src/Framework/Features/GeneratedFiles/GeneratedFileRegistry.php @@ -0,0 +1,40 @@ + */ + public static function pages(): array + { + return array_values(array_filter([ + Features::hasSitemap() ? new GeneratedFilePage(self::SITEMAP, SitemapGenerator::class) : null, + Features::hasRss() ? new GeneratedFilePage(self::rssOutputPath(), RssFeedGenerator::class) : null, + Features::hasRobotsTxt() ? new GeneratedFilePage(self::ROBOTS, RobotsTxtGenerator::class) : null, + Features::hasLlmsTxt() ? new GeneratedFilePage(self::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..22211bcd9a2 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\GeneratedFileRegistry; 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(GeneratedFileRegistry::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/tests/Feature/Commands/BuildRssFeedCommandTest.php b/packages/framework/tests/Feature/Commands/BuildRssFeedCommandTest.php index 5d44aa1884e..c53a5fa35e7 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() 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/LlmsTxtPageTest.php b/packages/framework/tests/Feature/LlmsTxtPageTest.php index 1b3b65ebc80..69691674a49 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\GeneratedFileRegistry; 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(GeneratedFileRegistry::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(GeneratedFileRegistry::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..232689c4978 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\GeneratedFileRegistry; 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(GeneratedFileRegistry::ROBOTS, RobotsTxtGenerator::class); $this->assertFalse($page->showInNavigation()); $this->assertFalse($page->showInSitemap()); @@ -70,7 +72,7 @@ public function testRobotsTxtPageCompilesUsingTheRobotsTxtGenerator() { $this->withoutSiteUrl(); - $this->assertSame("User-agent: *\nAllow: /\n", (new RobotsTxtPage())->compile()); + $this->assertSame("User-agent: *\nAllow: /\n", (new GeneratedFilePage(GeneratedFileRegistry::ROBOTS, RobotsTxtGenerator::class))->compile()); } public function testRobotsTxtGeneratorCanBeSwappedThroughTheServiceContainer() @@ -132,7 +134,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 +148,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/RssFeedPageTest.php b/packages/framework/tests/Feature/RssFeedPageTest.php index a7d7853896f..362676ed946 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()); } @@ -94,7 +96,7 @@ public function testFeedPageUsesConfiguredFilenameVerbatimForAnyExtension() public function testFeedPageIsHiddenFromNavigationAndExcludesItselfFromTheSitemap() { - $page = new RssFeedPage(); + $page = new GeneratedFilePage(GeneratedFileRegistry::rssOutputPath(), RssFeedGenerator::class); $this->assertFalse($page->showInNavigation()); $this->assertFalse($page->showInSitemap()); @@ -102,7 +104,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 +184,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..e025a92c342 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\GeneratedFileRegistry; 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(GeneratedFileRegistry::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(GeneratedFileRegistry::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); @@ -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); From 89630e92dc9df47e8407a63eb32ae76e0a447c50 Mon Sep 17 00:00:00 2001 From: Emma De Silva Date: Tue, 14 Jul 2026 14:11:57 +0200 Subject: [PATCH 04/17] Register framework page defaults after extensions --- .../src/Foundation/Concerns/HydeExtension.php | 11 ++++++++++ .../src/Foundation/HydeCoreExtension.php | 3 +++ .../src/Foundation/Kernel/PageCollection.php | 4 ++++ .../Feature/HydeExtensionFeatureTest.php | 20 ++++++++++++++++++- .../tests/Feature/SitemapPageTest.php | 10 ++++++++-- 5 files changed, 45 insertions(+), 3 deletions(-) diff --git a/packages/framework/src/Foundation/Concerns/HydeExtension.php b/packages/framework/src/Foundation/Concerns/HydeExtension.php index 84e0af0fe60..61f77a1a740 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. + * + * This handler runs after every extension's normal page discovery handler, + * so defaults can fill route gaps without depending on extension 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 b0be867fafc..413c5083974 100644 --- a/packages/framework/src/Foundation/HydeCoreExtension.php +++ b/packages/framework/src/Foundation/HydeCoreExtension.php @@ -81,7 +81,10 @@ public function discoverPages(PageCollection $collection): void } } } + } + public function discoverDefaultPages(PageCollection $collection): void + { 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/tests/Feature/HydeExtensionFeatureTest.php b/packages/framework/tests/Feature/HydeExtensionFeatureTest.php index f074acc165e..87b08f719bd 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,14 @@ 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 testRouteHandlerDependencyInjection() { $this->kernel->registerExtension(InspectableTestExtension::class); @@ -190,6 +198,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 +289,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(); diff --git a/packages/framework/tests/Feature/SitemapPageTest.php b/packages/framework/tests/Feature/SitemapPageTest.php index e025a92c342..980a25034c8 100644 --- a/packages/framework/tests/Feature/SitemapPageTest.php +++ b/packages/framework/tests/Feature/SitemapPageTest.php @@ -156,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(); @@ -177,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'; + } + }); } } From c75360086805cab7bb2e58b360231ac2dbc21dbe Mon Sep 17 00:00:00 2001 From: StyleCI Bot Date: Tue, 14 Jul 2026 12:29:14 +0000 Subject: [PATCH 05/17] Apply fixes from StyleCI --- packages/framework/src/Support/Models/RouteKey.php | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/framework/src/Support/Models/RouteKey.php b/packages/framework/src/Support/Models/RouteKey.php index 9971bea6132..c8283716d5f 100644 --- a/packages/framework/src/Support/Models/RouteKey.php +++ b/packages/framework/src/Support/Models/RouteKey.php @@ -5,6 +5,7 @@ namespace Hyde\Support\Models; use Stringable; + use function Hyde\unslash; use function substr; use function str_ends_with; From 3bbc48fb52b1bf6d0320c5c1c3a03ad1fa3436ea Mon Sep 17 00:00:00 2001 From: Emma De Silva Date: Tue, 14 Jul 2026 14:52:44 +0200 Subject: [PATCH 06/17] Resolve page routes lazily from output paths --- UPGRADE.md | 14 +++++ .../Console/Commands/BuildRssFeedCommand.php | 4 +- .../GeneratesDocumentationSearchIndex.php | 4 +- .../Factories/Concerns/HasFactory.php | 33 +++++++++-- .../Features/Metadata/PageMetadataBag.php | 52 +++++++++++++++--- .../framework/src/Pages/Concerns/HydePage.php | 30 ++++++++-- .../Commands/BuildRssFeedCommandTest.php | 12 ++++ .../framework/tests/Feature/HydePageTest.php | 20 +++++++ .../tests/Feature/RssFeedPageTest.php | 9 +++ .../tests/Unit/Pages/InMemoryPageTest.php | 55 +++++++++++++++++++ 10 files changed, 213 insertions(+), 20 deletions(-) diff --git a/UPGRADE.md b/UPGRADE.md index 011a83e1c51..3f97f01ecc8 100644 --- a/UPGRADE.md +++ b/UPGRADE.md @@ -17,6 +17,20 @@ 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 +lazily, so output paths backed by instance state are safe to initialize after calling the parent constructor. + ## Before You Begin ### Prerequisites diff --git a/packages/framework/src/Console/Commands/BuildRssFeedCommand.php b/packages/framework/src/Console/Commands/BuildRssFeedCommand.php index 12d0589aa7f..77e56834af1 100644 --- a/packages/framework/src/Console/Commands/BuildRssFeedCommand.php +++ b/packages/framework/src/Console/Commands/BuildRssFeedCommand.php @@ -9,6 +9,7 @@ use Hyde\Foundation\Facades\Routes; use Hyde\Framework\Actions\StaticPageBuilder; 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(GeneratedFileRegistry::rssOutputPath())?->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/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/Factories/Concerns/HasFactory.php b/packages/framework/src/Framework/Factories/Concerns/HasFactory.php index d78ebf9da96..e8dee47870e 100644 --- a/packages/framework/src/Framework/Factories/Concerns/HasFactory.php +++ b/packages/framework/src/Framework/Factories/Concerns/HasFactory.php @@ -7,28 +7,51 @@ use Hyde\Framework\Factories\BlogPostDataFactory; use Hyde\Framework\Factories\HydePageDataFactory; use Hyde\Pages\MarkdownPost; +use Hyde\Support\Models\RouteKey; trait HasFactory { public function toCoreDataObject(): CoreDataObject + { + return $this->makeCoreDataObject( + $this->getSourcePath(), + $this->getOutputPath(), + $this->getRouteKey(), + ); + } + + protected function toConstructionCoreDataObject(): CoreDataObject + { + $outputPath = static::outputPath($this->identifier); + + return $this->makeCoreDataObject( + static::sourcePath($this->identifier), + $outputPath, + RouteKey::fromOutputPath($outputPath)->get(), + ); + } + + private function makeCoreDataObject(string $sourcePath, string $outputPath, string $routeKey): CoreDataObject { return new CoreDataObject( $this->matter, $this->markdown ?? false, static::class, $this->identifier, - $this->getSourcePath(), - $this->getOutputPath(), - $this->getRouteKey(), + $sourcePath, + $outputPath, + $routeKey, ); } protected function constructFactoryData(): void { - $this->assignFactoryData(new HydePageDataFactory($this->toCoreDataObject())); + $pageData = $this->toConstructionCoreDataObject(); + + $this->assignFactoryData(new HydePageDataFactory($pageData)); if ($this instanceof MarkdownPost) { - $this->assignFactoryData(new BlogPostDataFactory($this->toCoreDataObject())); + $this->assignFactoryData(new BlogPostDataFactory($pageData)); } } diff --git a/packages/framework/src/Framework/Features/Metadata/PageMetadataBag.php b/packages/framework/src/Framework/Features/Metadata/PageMetadataBag.php index 23a764b3d7f..a830ae715d4 100644 --- a/packages/framework/src/Framework/Features/Metadata/PageMetadataBag.php +++ b/packages/framework/src/Framework/Features/Metadata/PageMetadataBag.php @@ -8,24 +8,51 @@ use Hyde\Pages\Concerns\HydePage; use Hyde\Pages\MarkdownPost; use Hyde\Foundation\Kernel\Hyperlinks; +use Hyde\Support\Facades\Render; use function substr_count; use function str_repeat; +use function str_starts_with; class PageMetadataBag extends MetadataBag { protected HydePage $page; + protected bool $generated = false; + protected bool $generating = false; public function __construct(HydePage $page) { $this->page = $page; + } + + public function get(): array + { + $this->generateIfNeeded(); - $this->generate(); + return parent::get(); } - protected function generate(): void + public function add(MetadataElementContract|string $element): static { - $this->addDynamicPageMetadata($this->page); + $this->generateIfNeeded(); + + return parent::add($element); + } + + protected function generateIfNeeded(): void + { + if ($this->generated || $this->generating) { + return; + } + + $this->generating = true; + + try { + $this->addDynamicPageMetadata($this->page); + $this->generated = true; + } finally { + $this->generating = false; + } } protected function addDynamicPageMetadata(HydePage $page): void @@ -79,13 +106,24 @@ protected function addPostMetadataIfExists(MarkdownPost $page, string $property, protected function resolveImageLink(string $image): string { - // Since this is run before the page is rendered, we don't have the currentPage property. - // So we need to run some of the same calculations here to resolve the image path link. - return Hyperlinks::isRemote($image) ? $image : $this->calculatePathTraversal().$image; + if (Hyperlinks::isRemote($image) || str_starts_with($image, '../')) { + return $image; + } + + return $this->calculatePathTraversal().$image; } private function calculatePathTraversal(): string { - return str_repeat('../', substr_count(MarkdownPost::outputDirectory().'/'.$this->page->identifier, '/')); + $routeKey = Render::getPage() === $this->page ? Render::getRouteKey() : null; + + if ($routeKey !== null) { + return str_repeat('../', substr_count($routeKey, '/')); + } + + $depth = substr_count($this->page->getOutputPath(), '/'); + + // Empty post identifiers historically resolve relative to the post output directory. + return str_repeat('../', $depth + (int) ($this->page->identifier === '')); } } diff --git a/packages/framework/src/Pages/Concerns/HydePage.php b/packages/framework/src/Pages/Concerns/HydePage.php index f5a376e14c0..9bf880a03c0 100644 --- a/packages/framework/src/Pages/Concerns/HydePage.php +++ b/packages/framework/src/Pages/Concerns/HydePage.php @@ -52,6 +52,8 @@ * In Blade views, you can always access the current page instance being rendered using the $page variable. * * @see \Hyde\Pages\Concerns\BaseMarkdownPage + * + * @property-read string $routeKey The lazily resolved route key. Prefer getRouteKey(). */ abstract class HydePage implements PageSchema, SerializableContract { @@ -66,7 +68,6 @@ abstract class HydePage implements PageSchema, SerializableContract public static string $template; public readonly string $identifier; - public readonly string $routeKey; public readonly string $title; public FrontMatter $matter; @@ -88,7 +89,6 @@ public function __construct(string $identifier = '', FrontMatter|array $matter = { $this->identifier = $identifier; $this->matter = $matter instanceof FrontMatter ? $matter : new FrontMatter($matter); - $this->routeKey = RouteKey::fromOutputPath($this->getOutputPath())->get(); $this->constructFactoryData(); $this->constructMetadata(); @@ -237,7 +237,7 @@ public static function sourcePath(string $identifier): string */ public static function outputPath(string $identifier): string { - $outputPath = unslash(static::outputDirectory().'/'.static::normalizeOutputIdentifier($identifier)); + $outputPath = unslash(static::baseRouteKey().'/'.static::normalizeOutputIdentifier($identifier)); $extension = static::outputExtension(); if ($extension !== '.html' && str_ends_with($outputPath, $extension)) { @@ -306,7 +306,7 @@ public function toArray(): array return [ 'class' => static::class, 'identifier' => $this->identifier, - 'routeKey' => $this->routeKey, + 'routeKey' => $this->getRouteKey(), 'matter' => $this->matter, 'metadata' => $this->metadata, 'navigation' => $this->navigation, @@ -349,7 +349,27 @@ public function getOutputPath(): string */ public function getRouteKey(): string { - return $this->routeKey; + return RouteKey::fromOutputPath($this->getOutputPath())->get(); + } + + /** + * Preserve read access to the historical public routeKey property while + * resolving it lazily from the authoritative output path. + */ + public function __get(string $property): mixed + { + if ($property === 'routeKey') { + return $this->getRouteKey(); + } + + trigger_error(sprintf('Undefined property: %s::$%s', static::class, $property), E_USER_WARNING); + + return null; + } + + public function __isset(string $property): bool + { + return $property === 'routeKey'; } /** diff --git a/packages/framework/tests/Feature/Commands/BuildRssFeedCommandTest.php b/packages/framework/tests/Feature/Commands/BuildRssFeedCommandTest.php index c53a5fa35e7..65fa2908cfe 100644 --- a/packages/framework/tests/Feature/Commands/BuildRssFeedCommandTest.php +++ b/packages/framework/tests/Feature/Commands/BuildRssFeedCommandTest.php @@ -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/HydePageTest.php b/packages/framework/tests/Feature/HydePageTest.php index a752e0e7417..8f2bba36506 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 testCustomBaseRouteKeyRemainsPartOfTheOutputPath() + { + $page = new CustomBaseRouteKeyTestPage('hello-world'); + + $this->assertSame('custom/hello-world.html', $page->getOutputPath()); + $this->assertSame('custom/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/RssFeedPageTest.php b/packages/framework/tests/Feature/RssFeedPageTest.php index 362676ed946..95dae56f1cf 100644 --- a/packages/framework/tests/Feature/RssFeedPageTest.php +++ b/packages/framework/tests/Feature/RssFeedPageTest.php @@ -94,6 +94,15 @@ 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 GeneratedFilePage(GeneratedFileRegistry::rssOutputPath(), RssFeedGenerator::class); diff --git a/packages/framework/tests/Unit/Pages/InMemoryPageTest.php b/packages/framework/tests/Unit/Pages/InMemoryPageTest.php index 14402da12db..9854f08be4c 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 { @@ -218,6 +227,52 @@ public function getOutputPath(): string $this->assertSame('custom/data.json', $page->getRouteKey()); } + public function testOverriddenOutputPathCanUseStateInitializedAfterParentConstructor() + { + $this->withSiteUrl(); + + $page = new class extends InMemoryPage + { + private string $resolvedOutputPath; + + public function __construct() + { + parent::__construct('custom'); + + $this->resolvedOutputPath = 'custom/data.json'; + } + + public function getOutputPath(): string + { + return $this->resolvedOutputPath; + } + }; + + $this->assertSame('custom/data.json', $page->getRouteKey()); + $this->assertSame('https://example.com/custom/data.json', $page->getCanonicalUrl()); + $this->assertStringContainsString('custom/data.json', $page->metadata()->render()); + } + + public function testRouteKeyTracksMutableOutputPathResolution() + { + $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('second.json', $page->getRouteKey()); + $this->assertSame('second.json', $page->routeKey); + } + public function testGetLinkForFile() { $this->assertSame('robots.txt', InMemoryPage::file('robots.txt')->getLink()); From 52340582732907320ca3db4b2118d9a75f2a1248 Mon Sep 17 00:00:00 2001 From: Emma De Silva Date: Tue, 14 Jul 2026 14:53:44 +0200 Subject: [PATCH 07/17] Decouple generated file paths from registry --- .../GeneratedFiles/GeneratedFilePaths.php | 15 +++++++++++++++ .../GeneratedFiles/GeneratedFileRegistry.php | 12 ++++++------ .../TextGenerators/RobotsTxtGenerator.php | 4 ++-- .../framework/tests/Feature/RobotsTxtPageTest.php | 14 ++++++++++++++ 4 files changed, 37 insertions(+), 8 deletions(-) create mode 100644 packages/framework/src/Framework/Features/GeneratedFiles/GeneratedFilePaths.php 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(self::SITEMAP, SitemapGenerator::class) : null, + Features::hasSitemap() ? new GeneratedFilePage(GeneratedFilePaths::SITEMAP, SitemapGenerator::class) : null, Features::hasRss() ? new GeneratedFilePage(self::rssOutputPath(), RssFeedGenerator::class) : null, - Features::hasRobotsTxt() ? new GeneratedFilePage(self::ROBOTS, RobotsTxtGenerator::class) : null, - Features::hasLlmsTxt() ? new GeneratedFilePage(self::LLMS, LlmsTxtGenerator::class) : null, + Features::hasRobotsTxt() ? new GeneratedFilePage(GeneratedFilePaths::ROBOTS, RobotsTxtGenerator::class) : null, + Features::hasLlmsTxt() ? new GeneratedFilePage(GeneratedFilePaths::LLMS, LlmsTxtGenerator::class) : null, ])); } diff --git a/packages/framework/src/Framework/Features/TextGenerators/RobotsTxtGenerator.php b/packages/framework/src/Framework/Features/TextGenerators/RobotsTxtGenerator.php index 22211bcd9a2..18198268626 100644 --- a/packages/framework/src/Framework/Features/TextGenerators/RobotsTxtGenerator.php +++ b/packages/framework/src/Framework/Features/TextGenerators/RobotsTxtGenerator.php @@ -9,7 +9,7 @@ use Hyde\Facades\Features; use Hyde\Framework\Exceptions\InvalidConfigurationException; use Hyde\Framework\Features\GeneratedFiles\GeneratedFileGenerator; -use Hyde\Framework\Features\GeneratedFiles\GeneratedFileRegistry; +use Hyde\Framework\Features\GeneratedFiles\GeneratedFilePaths; use function array_merge; use function get_debug_type; @@ -45,7 +45,7 @@ protected function getLines(): array $lines = array_merge(['User-agent: *'], $this->getRuleLines()); if (Features::hasSitemap()) { - $lines = array_merge($lines, ['', 'Sitemap: '.Hyde::url(GeneratedFileRegistry::SITEMAP)]); + $lines = array_merge($lines, ['', 'Sitemap: '.Hyde::url(GeneratedFilePaths::SITEMAP)]); } return $lines; diff --git a/packages/framework/tests/Feature/RobotsTxtPageTest.php b/packages/framework/tests/Feature/RobotsTxtPageTest.php index 232689c4978..ec674a93b38 100644 --- a/packages/framework/tests/Feature/RobotsTxtPageTest.php +++ b/packages/framework/tests/Feature/RobotsTxtPageTest.php @@ -13,6 +13,7 @@ use Hyde\Foundation\Kernel\PageCollection; use Hyde\Framework\Features\TextGenerators\RobotsTxtGenerator; use Hyde\Framework\Features\GeneratedFiles\GeneratedFilePage; +use Hyde\Framework\Features\GeneratedFiles\GeneratedFilePaths; use Hyde\Framework\Features\GeneratedFiles\GeneratedFileRegistry; use Illuminate\Support\Facades\File; @@ -75,6 +76,19 @@ public function testRobotsTxtPageCompilesUsingTheRobotsTxtGenerator() $this->assertSame("User-agent: *\nAllow: /\n", (new GeneratedFilePage(GeneratedFileRegistry::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() { app()->bind(RobotsTxtGenerator::class, fn (): RobotsTxtGenerator => new class extends RobotsTxtGenerator From 835953eb8ef8f5b09e1c2c2941145b342b359366 Mon Sep 17 00:00:00 2001 From: Emma De Silva Date: Tue, 14 Jul 2026 14:54:37 +0200 Subject: [PATCH 08/17] Clarify generated page discovery phases --- UPGRADE.md | 4 ++ .../src/Foundation/Concerns/HydeExtension.php | 4 +- .../Feature/HydeExtensionFeatureTest.php | 47 +++++++++++++++++++ 3 files changed, 53 insertions(+), 2 deletions(-) diff --git a/UPGRADE.md b/UPGRADE.md index 3f97f01ecc8..a4265c92918 100644 --- a/UPGRADE.md +++ b/UPGRADE.md @@ -226,6 +226,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/Foundation/Concerns/HydeExtension.php b/packages/framework/src/Foundation/Concerns/HydeExtension.php index 61f77a1a740..5b93aca59fb 100644 --- a/packages/framework/src/Foundation/Concerns/HydeExtension.php +++ b/packages/framework/src/Foundation/Concerns/HydeExtension.php @@ -71,8 +71,8 @@ public function discoverPages(PageCollection $collection): void /** * If your extension provides fallback pages, register them here. * - * This handler runs after every extension's normal page discovery handler, - * so defaults can fill route gaps without depending on extension order. + * 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 { diff --git a/packages/framework/tests/Feature/HydeExtensionFeatureTest.php b/packages/framework/tests/Feature/HydeExtensionFeatureTest.php index 87b08f719bd..19d593bb14a 100644 --- a/packages/framework/tests/Feature/HydeExtensionFeatureTest.php +++ b/packages/framework/tests/Feature/HydeExtensionFeatureTest.php @@ -91,6 +91,22 @@ public function testDefaultPageHandlerDependencyInjection() $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); @@ -319,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'; + } +} From 3d171559d0a8ce400155e03d5db89d617629a3c0 Mon Sep 17 00:00:00 2001 From: Emma De Silva Date: Tue, 14 Jul 2026 15:06:19 +0200 Subject: [PATCH 09/17] Stabilize lazily resolved route identities --- .../framework/src/Pages/Concerns/HydePage.php | 4 +++- .../framework/src/Support/Models/Route.php | 3 +++ .../tests/Feature/RouteCollectionTest.php | 23 +++++++++++++++++++ .../tests/Unit/Pages/InMemoryPageTest.php | 18 ++++++++++++--- 4 files changed, 44 insertions(+), 4 deletions(-) diff --git a/packages/framework/src/Pages/Concerns/HydePage.php b/packages/framework/src/Pages/Concerns/HydePage.php index 9bf880a03c0..e917f2daa48 100644 --- a/packages/framework/src/Pages/Concerns/HydePage.php +++ b/packages/framework/src/Pages/Concerns/HydePage.php @@ -70,6 +70,8 @@ abstract class HydePage implements PageSchema, SerializableContract public readonly string $identifier; public readonly string $title; + protected readonly string $routeKey; + public FrontMatter $matter; public PageMetadataBag $metadata; public NavigationData $navigation; @@ -349,7 +351,7 @@ public function getOutputPath(): string */ public function getRouteKey(): string { - return RouteKey::fromOutputPath($this->getOutputPath())->get(); + return $this->routeKey ??= RouteKey::fromOutputPath($this->getOutputPath())->get(); } /** diff --git a/packages/framework/src/Support/Models/Route.php b/packages/framework/src/Support/Models/Route.php index 755eb264f76..2679db2b6bd 100644 --- a/packages/framework/src/Support/Models/Route.php +++ b/packages/framework/src/Support/Models/Route.php @@ -27,6 +27,9 @@ class Route implements Stringable, SerializableContract public function __construct(HydePage $page) { $this->page = $page; + + // Entering the route lifecycle finalizes the page's lazily resolved identity. + $this->page->getRouteKey(); } /** 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/Unit/Pages/InMemoryPageTest.php b/packages/framework/tests/Unit/Pages/InMemoryPageTest.php index 9854f08be4c..0fd78da3d08 100644 --- a/packages/framework/tests/Unit/Pages/InMemoryPageTest.php +++ b/packages/framework/tests/Unit/Pages/InMemoryPageTest.php @@ -253,7 +253,7 @@ public function getOutputPath(): string $this->assertStringContainsString('custom/data.json', $page->metadata()->render()); } - public function testRouteKeyTracksMutableOutputPathResolution() + public function testRouteKeyIsResolvedLazilyButRemainsStable() { $page = new class extends InMemoryPage { @@ -269,8 +269,20 @@ public function getOutputPath(): string $page->resolvedOutputPath = 'second.json'; - $this->assertSame('second.json', $page->getRouteKey()); - $this->assertSame('second.json', $page->routeKey); + $this->assertSame('first', $page->getRouteKey()); + $this->assertSame('first', $page->routeKey); + } + + public function testCompatibilityRouteKeyPropertyCannotBeAssignedExternally() + { + $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() From 1c2a52ca8a358beaabf9ef7ef1bda71124fa4b1b Mon Sep 17 00:00:00 2001 From: Emma De Silva Date: Tue, 14 Jul 2026 15:08:15 +0200 Subject: [PATCH 10/17] Synchronize factory data when routes finalize --- .../Factories/Concerns/HasFactory.php | 21 +++++++++++++++++++ .../framework/src/Pages/Concerns/HydePage.php | 9 +++++++- .../tests/Unit/Pages/InMemoryPageTest.php | 13 ++++++++++++ 3 files changed, 42 insertions(+), 1 deletion(-) diff --git a/packages/framework/src/Framework/Factories/Concerns/HasFactory.php b/packages/framework/src/Framework/Factories/Concerns/HasFactory.php index e8dee47870e..a292141163a 100644 --- a/packages/framework/src/Framework/Factories/Concerns/HasFactory.php +++ b/packages/framework/src/Framework/Factories/Concerns/HasFactory.php @@ -11,6 +11,8 @@ trait HasFactory { + protected string $constructionRouteKey; + public function toCoreDataObject(): CoreDataObject { return $this->makeCoreDataObject( @@ -22,6 +24,8 @@ public function toCoreDataObject(): CoreDataObject protected function toConstructionCoreDataObject(): CoreDataObject { + // Base construction cannot safely dispatch to instance path overrides. This + // provisional route is replaced in navigation data when the route is finalized. $outputPath = static::outputPath($this->identifier); return $this->makeCoreDataObject( @@ -47,6 +51,7 @@ private function makeCoreDataObject(string $sourcePath, string $outputPath, stri protected function constructFactoryData(): void { $pageData = $this->toConstructionCoreDataObject(); + $this->constructionRouteKey = $pageData->routeKey; $this->assignFactoryData(new HydePageDataFactory($pageData)); @@ -55,6 +60,22 @@ protected function constructFactoryData(): void } } + protected function synchronizeFactoryDataForResolvedRoute(string $routeKey): void + { + if ($routeKey === $this->constructionRouteKey) { + return; + } + + $pageData = $this->makeCoreDataObject( + $this->getSourcePath(), + $this->getOutputPath(), + $routeKey, + ); + + $this->navigation = (new HydePageDataFactory($pageData))->toArray()['navigation']; + $this->constructionRouteKey = $routeKey; + } + protected function assignFactoryData(PageDataFactory $factory): void { foreach ($factory->toArray() as $key => $value) { diff --git a/packages/framework/src/Pages/Concerns/HydePage.php b/packages/framework/src/Pages/Concerns/HydePage.php index e917f2daa48..2464776074c 100644 --- a/packages/framework/src/Pages/Concerns/HydePage.php +++ b/packages/framework/src/Pages/Concerns/HydePage.php @@ -351,7 +351,14 @@ public function getOutputPath(): string */ public function getRouteKey(): string { - return $this->routeKey ??= RouteKey::fromOutputPath($this->getOutputPath())->get(); + if (! isset($this->routeKey)) { + $routeKey = RouteKey::fromOutputPath($this->getOutputPath())->get(); + + $this->synchronizeFactoryDataForResolvedRoute($routeKey); + $this->routeKey = $routeKey; + } + + return $this->routeKey; } /** diff --git a/packages/framework/tests/Unit/Pages/InMemoryPageTest.php b/packages/framework/tests/Unit/Pages/InMemoryPageTest.php index 0fd78da3d08..b49f8c7f767 100644 --- a/packages/framework/tests/Unit/Pages/InMemoryPageTest.php +++ b/packages/framework/tests/Unit/Pages/InMemoryPageTest.php @@ -230,6 +230,10 @@ public function getOutputPath(): string public function testOverriddenOutputPathCanUseStateInitializedAfterParentConstructor() { $this->withSiteUrl(); + config([ + 'hyde.navigation.labels' => ['custom/data.json' => 'Custom Data'], + 'hyde.navigation.exclude' => ['custom/data.json'], + ]); $page = new class extends InMemoryPage { @@ -249,8 +253,17 @@ public function getOutputPath(): string }; $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 testRouteKeyIsResolvedLazilyButRemainsStable() From aef1ed47f6121693de2216263036c703b8077945 Mon Sep 17 00:00:00 2001 From: Emma De Silva Date: Tue, 14 Jul 2026 15:09:47 +0200 Subject: [PATCH 11/17] Cover lazy metadata path resolution --- .../Features/Metadata/PageMetadataBag.php | 2 + .../framework/tests/Feature/MetadataTest.php | 73 +++++++++++++++++++ 2 files changed, 75 insertions(+) diff --git a/packages/framework/src/Framework/Features/Metadata/PageMetadataBag.php b/packages/framework/src/Framework/Features/Metadata/PageMetadataBag.php index a830ae715d4..0794cab2ee1 100644 --- a/packages/framework/src/Framework/Features/Metadata/PageMetadataBag.php +++ b/packages/framework/src/Framework/Features/Metadata/PageMetadataBag.php @@ -106,6 +106,8 @@ protected function addPostMetadataIfExists(MarkdownPost $page, string $property, protected function resolveImageLink(string $image): string { + // FeaturedImage resolves local assets against the active render route. Preserve + // an already-relative result instead of applying page traversal a second time. if (Hyperlinks::isRemote($image) || str_starts_with($image, '../')) { return $image; } diff --git a/packages/framework/tests/Feature/MetadataTest.php b/packages/framework/tests/Feature/MetadataTest.php index 3cd140d4bb0..260bd83694c 100644 --- a/packages/framework/tests/Feature/MetadataTest.php +++ b/packages/framework/tests/Feature/MetadataTest.php @@ -11,11 +11,13 @@ use Hyde\Framework\Features\Metadata\Elements\MetadataElement; use Hyde\Framework\Features\Metadata\Elements\OpenGraphElement; use Hyde\Framework\Features\Metadata\MetadataBag; +use Hyde\Framework\Features\Metadata\PageMetadataBag; use Hyde\Hyde; use Hyde\Pages\Concerns\HydePage; use Hyde\Pages\MarkdownPage; use Hyde\Pages\MarkdownPost; use Hyde\Testing\TestCase; +use Hyde\Support\Facades\Render; #[\PHPUnit\Framework\Attributes\CoversClass(\Hyde\Framework\Features\Metadata\MetadataBag::class)] #[\PHPUnit\Framework\Attributes\CoversClass(\Hyde\Framework\Features\Metadata\PageMetadataBag::class)] @@ -230,6 +232,46 @@ public function testAddsCanonicalLinkWhenBaseUrlAndIdentifierIsSet() $this->assertStringContainsString('', $page->metadata->render()); } + public function testLazyMetadataUsesFlattenedVersionedDocumentationOutputPath() + { + $this->withSiteUrl(); + config([ + 'docs.versions' => ['1.x', '2.x'], + 'docs.flattened_output_paths' => true, + ]); + + $page = new DocumentationPage('2.x/guides/01-installation'); + + $this->assertStringContainsString( + '', + $page->metadata->render(), + ); + } + + public function testEveryPublicMetadataReadPathTriggersLazyGeneration() + { + $this->withSiteUrl(); + + foreach (['get', 'render', 'toHtml'] as $method) { + $metadata = (new MarkdownPage($method))->metadata(); + $rendered = $method === 'get' ? implode("\n", $metadata->get()) : $metadata->{$method}(); + + $this->assertStringContainsString("https://example.com/$method.html", $rendered); + } + } + + public function testLazyMetadataSurvivesNativePageSerialization() + { + $this->withSiteUrl(); + + $page = unserialize(serialize(new MarkdownPage('serialized')), ['allowed_classes' => true]); + + $this->assertStringContainsString( + '', + $page->metadata->render(), + ); + } + public function testCanonicalLinkUsesCleanUrlSetting() { config(['hyde.url' => 'foo']); @@ -470,6 +512,29 @@ public function testDynamicPostMetaPropertiesContainsImageLinkThatIsAlwaysRelati $this->assertPageHasMetadata($page, ''); } + public function testNestedPostImageIsNotPrefixedTwiceDuringActivePageRender() + { + config(['hyde.cache_busting' => false]); + $this->file('_media/foo.jpg'); + + $page = new MarkdownPost('foo/bar', matter: ['image' => 'foo.jpg']); + Render::setPage($page); + + try { + $this->assertPageHasMetadata($page, ''); + } finally { + Render::clearData(); + } + } + + public function testAlreadyRelativeAndRemoteImageLinksAreNotPrefixed() + { + $metadata = new InspectablePageMetadataBag(new MarkdownPost('foo/bar')); + + $this->assertSame('../image.png', $metadata->resolveImageLink('../image.png')); + $this->assertSame('https://example.com/image.png', $metadata->resolveImageLink('https://example.com/image.png')); + } + public function testDynamicPostMetaPropertiesContainsImageLinkThatIsAlwaysRelativeForNestedOutputDirectories() { MarkdownPost::setOutputDirectory('_posts/foo'); @@ -577,3 +642,11 @@ public function testNoAuthorIsSetWhenAuthorSetToArrayWithoutNameOrUsername() $this->assertPageDoesNotHaveMetadata($page, ' Date: Tue, 14 Jul 2026 15:10:08 +0200 Subject: [PATCH 12/17] Document lazy route property compatibility --- UPGRADE.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/UPGRADE.md b/UPGRADE.md index a4265c92918..b893cb7e026 100644 --- a/UPGRADE.md +++ b/UPGRADE.md @@ -30,6 +30,12 @@ InMemoryPage::make('download.html'); Custom pages that override `getOutputPath()` no longer need to override `getRouteKey()`. The route key is resolved lazily, so output paths backed by instance state are safe to initialize after calling the parent constructor. +The first route read fixes that identity for the rest of the page lifecycle, including when a `Route` is constructed. + +The historical public readonly `routeKey` property is now a protected readonly lazy cache. Direct reads such as +`$page->routeKey` remain supported for compatibility through the magic accessor, but new code should call +`$page->getRouteKey()`. Code that reflects public properties, uses `get_object_vars()`, inspects property visibility, +or depends on the native serialized property shape must account for that runtime compatibility change. ## Before You Begin From 45b1da3b174b933a864d65b420a05720dcca1d18 Mon Sep 17 00:00:00 2001 From: Emma De Silva Date: Tue, 14 Jul 2026 15:11:31 +0200 Subject: [PATCH 13/17] Keep resolved routes out of page value state --- UPGRADE.md | 6 +++--- .../framework/src/Pages/Concerns/HydePage.php | 17 ++++++++++++----- .../tests/Unit/Pages/InMemoryPageTest.php | 10 ++++++++++ 3 files changed, 25 insertions(+), 8 deletions(-) diff --git a/UPGRADE.md b/UPGRADE.md index b893cb7e026..2532e79ce96 100644 --- a/UPGRADE.md +++ b/UPGRADE.md @@ -32,9 +32,9 @@ Custom pages that override `getOutputPath()` no longer need to override `getRout lazily, so output paths backed by instance state are safe to initialize after calling the parent constructor. The first route read fixes that identity for the rest of the page lifecycle, including when a `Route` is constructed. -The historical public readonly `routeKey` property is now a protected readonly lazy cache. Direct reads such as -`$page->routeKey` remain supported for compatibility through the magic accessor, but new code should call -`$page->getRouteKey()`. Code that reflects public properties, uses `get_object_vars()`, inspects property visibility, +The historical public readonly `routeKey` property is now a protected readonly compatibility declaration backed by +an internal lazy cache. Direct reads such as `$page->routeKey` remain supported through the magic accessor, but new +code should call `$page->getRouteKey()`. Code that reflects public properties, uses `get_object_vars()`, inspects property visibility, or depends on the native serialized property shape must account for that runtime compatibility change. ## Before You Begin diff --git a/packages/framework/src/Pages/Concerns/HydePage.php b/packages/framework/src/Pages/Concerns/HydePage.php index 2464776074c..6cc2c6af6bc 100644 --- a/packages/framework/src/Pages/Concerns/HydePage.php +++ b/packages/framework/src/Pages/Concerns/HydePage.php @@ -25,6 +25,7 @@ use Hyde\Support\Models\RouteKey; use Illuminate\Support\Str; use InvalidArgumentException; +use WeakMap; use function Hyde\unslash; use function filled; @@ -72,6 +73,9 @@ abstract class HydePage implements PageSchema, SerializableContract protected readonly string $routeKey; + /** @var \WeakMap|null */ + private static ?WeakMap $resolvedRouteKeys = null; + public FrontMatter $matter; public PageMetadataBag $metadata; public NavigationData $navigation; @@ -351,14 +355,17 @@ public function getOutputPath(): string */ public function getRouteKey(): string { - if (! isset($this->routeKey)) { - $routeKey = RouteKey::fromOutputPath($this->getOutputPath())->get(); + $routeKeys = self::$resolvedRouteKeys ??= new WeakMap(); - $this->synchronizeFactoryDataForResolvedRoute($routeKey); - $this->routeKey = $routeKey; + if (isset($routeKeys[$this])) { + return $routeKeys[$this]; } - return $this->routeKey; + $routeKey = RouteKey::fromOutputPath($this->getOutputPath())->get(); + + $this->synchronizeFactoryDataForResolvedRoute($routeKey); + + return $routeKeys[$this] = $routeKey; } /** diff --git a/packages/framework/tests/Unit/Pages/InMemoryPageTest.php b/packages/framework/tests/Unit/Pages/InMemoryPageTest.php index b49f8c7f767..62532f8e578 100644 --- a/packages/framework/tests/Unit/Pages/InMemoryPageTest.php +++ b/packages/framework/tests/Unit/Pages/InMemoryPageTest.php @@ -298,6 +298,16 @@ public function testCompatibilityRouteKeyPropertyCannotBeAssignedExternally() $page->routeKey = 'incorrect'; } + public function testResolvingRouteKeyDoesNotChangePageValueEquality() + { + $resolved = new InMemoryPage('page'); + $unresolved = new InMemoryPage('page'); + + $resolved->getRouteKey(); + + $this->assertEquals($unresolved, $resolved); + } + public function testGetLinkForFile() { $this->assertSame('robots.txt', InMemoryPage::file('robots.txt')->getLink()); From d14106125615e1c7aea38a3afabe3deaf5692837 Mon Sep 17 00:00:00 2001 From: Emma De Silva Date: Tue, 14 Jul 2026 15:12:36 +0200 Subject: [PATCH 14/17] Keep route property compatibility analyzable --- UPGRADE.md | 2 +- packages/framework/src/Pages/Concerns/HydePage.php | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/UPGRADE.md b/UPGRADE.md index 2532e79ce96..8bb1c711c0d 100644 --- a/UPGRADE.md +++ b/UPGRADE.md @@ -32,7 +32,7 @@ Custom pages that override `getOutputPath()` no longer need to override `getRout lazily, so output paths backed by instance state are safe to initialize after calling the parent constructor. The first route read fixes that identity for the rest of the page lifecycle, including when a `Route` is constructed. -The historical public readonly `routeKey` property is now a protected readonly compatibility declaration backed by +The historical public readonly `routeKey` property is now a protected compatibility declaration backed by an internal lazy cache. Direct reads such as `$page->routeKey` remain supported through the magic accessor, but new code should call `$page->getRouteKey()`. Code that reflects public properties, uses `get_object_vars()`, inspects property visibility, or depends on the native serialized property shape must account for that runtime compatibility change. diff --git a/packages/framework/src/Pages/Concerns/HydePage.php b/packages/framework/src/Pages/Concerns/HydePage.php index 6cc2c6af6bc..476a3d6e3a0 100644 --- a/packages/framework/src/Pages/Concerns/HydePage.php +++ b/packages/framework/src/Pages/Concerns/HydePage.php @@ -71,7 +71,7 @@ abstract class HydePage implements PageSchema, SerializableContract public readonly string $identifier; public readonly string $title; - protected readonly string $routeKey; + protected ?string $routeKey = null; /** @var \WeakMap|null */ private static ?WeakMap $resolvedRouteKeys = null; From 88c21e34a27a0361c7e4404df7d777a1de788f8b Mon Sep 17 00:00:00 2001 From: Emma De Silva Date: Tue, 14 Jul 2026 15:47:57 +0200 Subject: [PATCH 15/17] Resolve page route identity during construction --- UPGRADE.md | 9 +-- .../Factories/Concerns/HasFactory.php | 54 ++------------ .../Features/Metadata/PageMetadataBag.php | 54 ++------------ .../framework/src/Pages/Concerns/HydePage.php | 46 ++---------- .../framework/src/Support/Models/Route.php | 3 - .../framework/tests/Feature/HydePageTest.php | 6 +- .../framework/tests/Feature/MetadataTest.php | 73 ------------------- .../tests/Unit/Pages/InMemoryPageTest.php | 20 ++--- 8 files changed, 27 insertions(+), 238 deletions(-) diff --git a/UPGRADE.md b/UPGRADE.md index 8bb1c711c0d..26da82612dc 100644 --- a/UPGRADE.md +++ b/UPGRADE.md @@ -29,13 +29,8 @@ InMemoryPage::make('download.html'); ``` Custom pages that override `getOutputPath()` no longer need to override `getRouteKey()`. The route key is resolved -lazily, so output paths backed by instance state are safe to initialize after calling the parent constructor. -The first route read fixes that identity for the rest of the page lifecycle, including when a `Route` is constructed. - -The historical public readonly `routeKey` property is now a protected compatibility declaration backed by -an internal lazy cache. Direct reads such as `$page->routeKey` remain supported through the magic accessor, but new -code should call `$page->getRouteKey()`. Code that reflects public properties, uses `get_object_vars()`, inspects property visibility, -or depends on the native serialized property shape must account for that runtime compatibility change. +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 diff --git a/packages/framework/src/Framework/Factories/Concerns/HasFactory.php b/packages/framework/src/Framework/Factories/Concerns/HasFactory.php index a292141163a..d78ebf9da96 100644 --- a/packages/framework/src/Framework/Factories/Concerns/HasFactory.php +++ b/packages/framework/src/Framework/Factories/Concerns/HasFactory.php @@ -7,75 +7,31 @@ use Hyde\Framework\Factories\BlogPostDataFactory; use Hyde\Framework\Factories\HydePageDataFactory; use Hyde\Pages\MarkdownPost; -use Hyde\Support\Models\RouteKey; trait HasFactory { - protected string $constructionRouteKey; - public function toCoreDataObject(): CoreDataObject - { - return $this->makeCoreDataObject( - $this->getSourcePath(), - $this->getOutputPath(), - $this->getRouteKey(), - ); - } - - protected function toConstructionCoreDataObject(): CoreDataObject - { - // Base construction cannot safely dispatch to instance path overrides. This - // provisional route is replaced in navigation data when the route is finalized. - $outputPath = static::outputPath($this->identifier); - - return $this->makeCoreDataObject( - static::sourcePath($this->identifier), - $outputPath, - RouteKey::fromOutputPath($outputPath)->get(), - ); - } - - private function makeCoreDataObject(string $sourcePath, string $outputPath, string $routeKey): CoreDataObject { return new CoreDataObject( $this->matter, $this->markdown ?? false, static::class, $this->identifier, - $sourcePath, - $outputPath, - $routeKey, + $this->getSourcePath(), + $this->getOutputPath(), + $this->getRouteKey(), ); } protected function constructFactoryData(): void { - $pageData = $this->toConstructionCoreDataObject(); - $this->constructionRouteKey = $pageData->routeKey; - - $this->assignFactoryData(new HydePageDataFactory($pageData)); + $this->assignFactoryData(new HydePageDataFactory($this->toCoreDataObject())); if ($this instanceof MarkdownPost) { - $this->assignFactoryData(new BlogPostDataFactory($pageData)); + $this->assignFactoryData(new BlogPostDataFactory($this->toCoreDataObject())); } } - protected function synchronizeFactoryDataForResolvedRoute(string $routeKey): void - { - if ($routeKey === $this->constructionRouteKey) { - return; - } - - $pageData = $this->makeCoreDataObject( - $this->getSourcePath(), - $this->getOutputPath(), - $routeKey, - ); - - $this->navigation = (new HydePageDataFactory($pageData))->toArray()['navigation']; - $this->constructionRouteKey = $routeKey; - } - protected function assignFactoryData(PageDataFactory $factory): void { foreach ($factory->toArray() as $key => $value) { diff --git a/packages/framework/src/Framework/Features/Metadata/PageMetadataBag.php b/packages/framework/src/Framework/Features/Metadata/PageMetadataBag.php index 0794cab2ee1..23a764b3d7f 100644 --- a/packages/framework/src/Framework/Features/Metadata/PageMetadataBag.php +++ b/packages/framework/src/Framework/Features/Metadata/PageMetadataBag.php @@ -8,51 +8,24 @@ use Hyde\Pages\Concerns\HydePage; use Hyde\Pages\MarkdownPost; use Hyde\Foundation\Kernel\Hyperlinks; -use Hyde\Support\Facades\Render; use function substr_count; use function str_repeat; -use function str_starts_with; class PageMetadataBag extends MetadataBag { protected HydePage $page; - protected bool $generated = false; - protected bool $generating = false; public function __construct(HydePage $page) { $this->page = $page; - } - - public function get(): array - { - $this->generateIfNeeded(); - return parent::get(); + $this->generate(); } - public function add(MetadataElementContract|string $element): static + protected function generate(): void { - $this->generateIfNeeded(); - - return parent::add($element); - } - - protected function generateIfNeeded(): void - { - if ($this->generated || $this->generating) { - return; - } - - $this->generating = true; - - try { - $this->addDynamicPageMetadata($this->page); - $this->generated = true; - } finally { - $this->generating = false; - } + $this->addDynamicPageMetadata($this->page); } protected function addDynamicPageMetadata(HydePage $page): void @@ -106,26 +79,13 @@ protected function addPostMetadataIfExists(MarkdownPost $page, string $property, protected function resolveImageLink(string $image): string { - // FeaturedImage resolves local assets against the active render route. Preserve - // an already-relative result instead of applying page traversal a second time. - if (Hyperlinks::isRemote($image) || str_starts_with($image, '../')) { - return $image; - } - - return $this->calculatePathTraversal().$image; + // Since this is run before the page is rendered, we don't have the currentPage property. + // So we need to run some of the same calculations here to resolve the image path link. + return Hyperlinks::isRemote($image) ? $image : $this->calculatePathTraversal().$image; } private function calculatePathTraversal(): string { - $routeKey = Render::getPage() === $this->page ? Render::getRouteKey() : null; - - if ($routeKey !== null) { - return str_repeat('../', substr_count($routeKey, '/')); - } - - $depth = substr_count($this->page->getOutputPath(), '/'); - - // Empty post identifiers historically resolve relative to the post output directory. - return str_repeat('../', $depth + (int) ($this->page->identifier === '')); + return str_repeat('../', substr_count(MarkdownPost::outputDirectory().'/'.$this->page->identifier, '/')); } } diff --git a/packages/framework/src/Pages/Concerns/HydePage.php b/packages/framework/src/Pages/Concerns/HydePage.php index 476a3d6e3a0..f5a376e14c0 100644 --- a/packages/framework/src/Pages/Concerns/HydePage.php +++ b/packages/framework/src/Pages/Concerns/HydePage.php @@ -25,7 +25,6 @@ use Hyde\Support\Models\RouteKey; use Illuminate\Support\Str; use InvalidArgumentException; -use WeakMap; use function Hyde\unslash; use function filled; @@ -53,8 +52,6 @@ * In Blade views, you can always access the current page instance being rendered using the $page variable. * * @see \Hyde\Pages\Concerns\BaseMarkdownPage - * - * @property-read string $routeKey The lazily resolved route key. Prefer getRouteKey(). */ abstract class HydePage implements PageSchema, SerializableContract { @@ -69,13 +66,9 @@ abstract class HydePage implements PageSchema, SerializableContract public static string $template; public readonly string $identifier; + public readonly string $routeKey; public readonly string $title; - protected ?string $routeKey = null; - - /** @var \WeakMap|null */ - private static ?WeakMap $resolvedRouteKeys = null; - public FrontMatter $matter; public PageMetadataBag $metadata; public NavigationData $navigation; @@ -95,6 +88,7 @@ public function __construct(string $identifier = '', FrontMatter|array $matter = { $this->identifier = $identifier; $this->matter = $matter instanceof FrontMatter ? $matter : new FrontMatter($matter); + $this->routeKey = RouteKey::fromOutputPath($this->getOutputPath())->get(); $this->constructFactoryData(); $this->constructMetadata(); @@ -243,7 +237,7 @@ public static function sourcePath(string $identifier): string */ public static function outputPath(string $identifier): string { - $outputPath = unslash(static::baseRouteKey().'/'.static::normalizeOutputIdentifier($identifier)); + $outputPath = unslash(static::outputDirectory().'/'.static::normalizeOutputIdentifier($identifier)); $extension = static::outputExtension(); if ($extension !== '.html' && str_ends_with($outputPath, $extension)) { @@ -312,7 +306,7 @@ public function toArray(): array return [ 'class' => static::class, 'identifier' => $this->identifier, - 'routeKey' => $this->getRouteKey(), + 'routeKey' => $this->routeKey, 'matter' => $this->matter, 'metadata' => $this->metadata, 'navigation' => $this->navigation, @@ -355,37 +349,7 @@ public function getOutputPath(): string */ public function getRouteKey(): string { - $routeKeys = self::$resolvedRouteKeys ??= new WeakMap(); - - if (isset($routeKeys[$this])) { - return $routeKeys[$this]; - } - - $routeKey = RouteKey::fromOutputPath($this->getOutputPath())->get(); - - $this->synchronizeFactoryDataForResolvedRoute($routeKey); - - return $routeKeys[$this] = $routeKey; - } - - /** - * Preserve read access to the historical public routeKey property while - * resolving it lazily from the authoritative output path. - */ - public function __get(string $property): mixed - { - if ($property === 'routeKey') { - return $this->getRouteKey(); - } - - trigger_error(sprintf('Undefined property: %s::$%s', static::class, $property), E_USER_WARNING); - - return null; - } - - public function __isset(string $property): bool - { - return $property === 'routeKey'; + return $this->routeKey; } /** diff --git a/packages/framework/src/Support/Models/Route.php b/packages/framework/src/Support/Models/Route.php index 2679db2b6bd..755eb264f76 100644 --- a/packages/framework/src/Support/Models/Route.php +++ b/packages/framework/src/Support/Models/Route.php @@ -27,9 +27,6 @@ class Route implements Stringable, SerializableContract public function __construct(HydePage $page) { $this->page = $page; - - // Entering the route lifecycle finalizes the page's lazily resolved identity. - $this->page->getRouteKey(); } /** diff --git a/packages/framework/tests/Feature/HydePageTest.php b/packages/framework/tests/Feature/HydePageTest.php index 8f2bba36506..b7321546c51 100644 --- a/packages/framework/tests/Feature/HydePageTest.php +++ b/packages/framework/tests/Feature/HydePageTest.php @@ -176,12 +176,12 @@ public function testBaseRouteKey() $this->assertSame(TestPage::outputDirectory(), TestPage::baseRouteKey()); } - public function testCustomBaseRouteKeyRemainsPartOfTheOutputPath() + public function testOutputDirectoryRemainsAuthoritativeWhenBaseRouteKeyIsOverridden() { $page = new CustomBaseRouteKeyTestPage('hello-world'); - $this->assertSame('custom/hello-world.html', $page->getOutputPath()); - $this->assertSame('custom/hello-world', $page->getRouteKey()); + $this->assertSame('ignored/hello-world.html', $page->getOutputPath()); + $this->assertSame('ignored/hello-world', $page->getRouteKey()); } public function testIsDiscoverable() diff --git a/packages/framework/tests/Feature/MetadataTest.php b/packages/framework/tests/Feature/MetadataTest.php index 260bd83694c..3cd140d4bb0 100644 --- a/packages/framework/tests/Feature/MetadataTest.php +++ b/packages/framework/tests/Feature/MetadataTest.php @@ -11,13 +11,11 @@ use Hyde\Framework\Features\Metadata\Elements\MetadataElement; use Hyde\Framework\Features\Metadata\Elements\OpenGraphElement; use Hyde\Framework\Features\Metadata\MetadataBag; -use Hyde\Framework\Features\Metadata\PageMetadataBag; use Hyde\Hyde; use Hyde\Pages\Concerns\HydePage; use Hyde\Pages\MarkdownPage; use Hyde\Pages\MarkdownPost; use Hyde\Testing\TestCase; -use Hyde\Support\Facades\Render; #[\PHPUnit\Framework\Attributes\CoversClass(\Hyde\Framework\Features\Metadata\MetadataBag::class)] #[\PHPUnit\Framework\Attributes\CoversClass(\Hyde\Framework\Features\Metadata\PageMetadataBag::class)] @@ -232,46 +230,6 @@ public function testAddsCanonicalLinkWhenBaseUrlAndIdentifierIsSet() $this->assertStringContainsString('', $page->metadata->render()); } - public function testLazyMetadataUsesFlattenedVersionedDocumentationOutputPath() - { - $this->withSiteUrl(); - config([ - 'docs.versions' => ['1.x', '2.x'], - 'docs.flattened_output_paths' => true, - ]); - - $page = new DocumentationPage('2.x/guides/01-installation'); - - $this->assertStringContainsString( - '', - $page->metadata->render(), - ); - } - - public function testEveryPublicMetadataReadPathTriggersLazyGeneration() - { - $this->withSiteUrl(); - - foreach (['get', 'render', 'toHtml'] as $method) { - $metadata = (new MarkdownPage($method))->metadata(); - $rendered = $method === 'get' ? implode("\n", $metadata->get()) : $metadata->{$method}(); - - $this->assertStringContainsString("https://example.com/$method.html", $rendered); - } - } - - public function testLazyMetadataSurvivesNativePageSerialization() - { - $this->withSiteUrl(); - - $page = unserialize(serialize(new MarkdownPage('serialized')), ['allowed_classes' => true]); - - $this->assertStringContainsString( - '', - $page->metadata->render(), - ); - } - public function testCanonicalLinkUsesCleanUrlSetting() { config(['hyde.url' => 'foo']); @@ -512,29 +470,6 @@ public function testDynamicPostMetaPropertiesContainsImageLinkThatIsAlwaysRelati $this->assertPageHasMetadata($page, ''); } - public function testNestedPostImageIsNotPrefixedTwiceDuringActivePageRender() - { - config(['hyde.cache_busting' => false]); - $this->file('_media/foo.jpg'); - - $page = new MarkdownPost('foo/bar', matter: ['image' => 'foo.jpg']); - Render::setPage($page); - - try { - $this->assertPageHasMetadata($page, ''); - } finally { - Render::clearData(); - } - } - - public function testAlreadyRelativeAndRemoteImageLinksAreNotPrefixed() - { - $metadata = new InspectablePageMetadataBag(new MarkdownPost('foo/bar')); - - $this->assertSame('../image.png', $metadata->resolveImageLink('../image.png')); - $this->assertSame('https://example.com/image.png', $metadata->resolveImageLink('https://example.com/image.png')); - } - public function testDynamicPostMetaPropertiesContainsImageLinkThatIsAlwaysRelativeForNestedOutputDirectories() { MarkdownPost::setOutputDirectory('_posts/foo'); @@ -642,11 +577,3 @@ public function testNoAuthorIsSetWhenAuthorSetToArrayWithoutNameOrUsername() $this->assertPageDoesNotHaveMetadata($page, 'assertSame('custom/data.json', $page->getRouteKey()); } - public function testOverriddenOutputPathCanUseStateInitializedAfterParentConstructor() + public function testOverriddenOutputPathCanUseStateInitializedBeforeParentConstructor() { $this->withSiteUrl(); config([ @@ -241,9 +241,9 @@ public function testOverriddenOutputPathCanUseStateInitializedAfterParentConstru public function __construct() { - parent::__construct('custom'); - $this->resolvedOutputPath = 'custom/data.json'; + + parent::__construct('custom'); } public function getOutputPath(): string @@ -266,7 +266,7 @@ public function getOutputPath(): string $this->assertSame('custom/data.json', $factoryData->routeKey); } - public function testRouteKeyIsResolvedLazilyButRemainsStable() + public function testRouteKeyIsResolvedDuringConstructionAndRemainsStable() { $page = new class extends InMemoryPage { @@ -286,7 +286,7 @@ public function getOutputPath(): string $this->assertSame('first', $page->routeKey); } - public function testCompatibilityRouteKeyPropertyCannotBeAssignedExternally() + public function testReadonlyRouteKeyPropertyCannotBeAssignedExternally() { $page = new InMemoryPage('first'); @@ -298,16 +298,6 @@ public function testCompatibilityRouteKeyPropertyCannotBeAssignedExternally() $page->routeKey = 'incorrect'; } - public function testResolvingRouteKeyDoesNotChangePageValueEquality() - { - $resolved = new InMemoryPage('page'); - $unresolved = new InMemoryPage('page'); - - $resolved->getRouteKey(); - - $this->assertEquals($unresolved, $resolved); - } - public function testGetLinkForFile() { $this->assertSame('robots.txt', InMemoryPage::file('robots.txt')->getLink()); From dbf2457aa2e4095c305e1d4926066713ed2b941b Mon Sep 17 00:00:00 2001 From: Emma De Silva Date: Tue, 14 Jul 2026 15:48:56 +0200 Subject: [PATCH 16/17] Reconcile non-HTML page design records --- EPIC_NON_HTML_PAGES.md | 69 +++++++++++++++++++----------------------- HYDEPHP_V3_PLANNING.md | 5 +++ 2 files changed, 36 insertions(+), 38 deletions(-) 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. From 495d1d12c0b04b428b8bfdfbc1c41654cd135032 Mon Sep 17 00:00:00 2001 From: Emma De Silva Date: Tue, 14 Jul 2026 15:49:55 +0200 Subject: [PATCH 17/17] Use one source for generated file paths --- .../framework/src/Console/Commands/BuildSitemapCommand.php | 4 ++-- .../Features/GeneratedFiles/GeneratedFileRegistry.php | 4 ---- packages/framework/tests/Feature/LlmsTxtPageTest.php | 6 +++--- packages/framework/tests/Feature/RobotsTxtPageTest.php | 5 ++--- packages/framework/tests/Feature/SitemapPageTest.php | 6 +++--- 5 files changed, 10 insertions(+), 15 deletions(-) diff --git a/packages/framework/src/Console/Commands/BuildSitemapCommand.php b/packages/framework/src/Console/Commands/BuildSitemapCommand.php index db6d560da8b..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\GeneratedFiles\GeneratedFileRegistry; +use Hyde\Framework\Features\GeneratedFiles\GeneratedFilePaths; use function sprintf; @@ -25,7 +25,7 @@ class BuildSitemapCommand extends Command public function handle(): int { - $page = Routes::find(GeneratedFileRegistry::SITEMAP)?->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/Framework/Features/GeneratedFiles/GeneratedFileRegistry.php b/packages/framework/src/Framework/Features/GeneratedFiles/GeneratedFileRegistry.php index 8a99ee06cd1..07b1cc78512 100644 --- a/packages/framework/src/Framework/Features/GeneratedFiles/GeneratedFileRegistry.php +++ b/packages/framework/src/Framework/Features/GeneratedFiles/GeneratedFileRegistry.php @@ -18,10 +18,6 @@ */ final class GeneratedFileRegistry { - public const SITEMAP = GeneratedFilePaths::SITEMAP; - public const ROBOTS = GeneratedFilePaths::ROBOTS; - public const LLMS = GeneratedFilePaths::LLMS; - /** @return array<\Hyde\Framework\Features\GeneratedFiles\GeneratedFilePage> */ public static function pages(): array { diff --git a/packages/framework/tests/Feature/LlmsTxtPageTest.php b/packages/framework/tests/Feature/LlmsTxtPageTest.php index 69691674a49..9324e942588 100644 --- a/packages/framework/tests/Feature/LlmsTxtPageTest.php +++ b/packages/framework/tests/Feature/LlmsTxtPageTest.php @@ -13,7 +13,7 @@ use Hyde\Foundation\Kernel\PageCollection; use Hyde\Framework\Features\TextGenerators\LlmsTxtGenerator; use Hyde\Framework\Features\GeneratedFiles\GeneratedFilePage; -use Hyde\Framework\Features\GeneratedFiles\GeneratedFileRegistry; +use Hyde\Framework\Features\GeneratedFiles\GeneratedFilePaths; use Illuminate\Support\Facades\File; /** @@ -69,7 +69,7 @@ public function testLlmsTxtPageIsNotRegisteredWhenDisabledInConfig() public function testLlmsTxtPageIsHiddenFromNavigationAndExcludedFromTheSitemap() { - $page = new GeneratedFilePage(GeneratedFileRegistry::LLMS, LlmsTxtGenerator::class); + $page = new GeneratedFilePage(GeneratedFilePaths::LLMS, LlmsTxtGenerator::class); $this->assertFalse($page->showInNavigation()); $this->assertFalse($page->showInSitemap()); @@ -77,7 +77,7 @@ public function testLlmsTxtPageIsHiddenFromNavigationAndExcludedFromTheSitemap() public function testLlmsTxtPageCompilesUsingTheLlmsTxtGenerator() { - $this->assertSame((new LlmsTxtGenerator())->generate(), (new GeneratedFilePage(GeneratedFileRegistry::LLMS, LlmsTxtGenerator::class))->compile()); + $this->assertSame((new LlmsTxtGenerator())->generate(), (new GeneratedFilePage(GeneratedFilePaths::LLMS, LlmsTxtGenerator::class))->compile()); } public function testLlmsTxtGeneratorCanBeSwappedThroughTheServiceContainer() diff --git a/packages/framework/tests/Feature/RobotsTxtPageTest.php b/packages/framework/tests/Feature/RobotsTxtPageTest.php index ec674a93b38..588ca9f0d09 100644 --- a/packages/framework/tests/Feature/RobotsTxtPageTest.php +++ b/packages/framework/tests/Feature/RobotsTxtPageTest.php @@ -14,7 +14,6 @@ use Hyde\Framework\Features\TextGenerators\RobotsTxtGenerator; use Hyde\Framework\Features\GeneratedFiles\GeneratedFilePage; use Hyde\Framework\Features\GeneratedFiles\GeneratedFilePaths; -use Hyde\Framework\Features\GeneratedFiles\GeneratedFileRegistry; use Illuminate\Support\Facades\File; /** @@ -63,7 +62,7 @@ public function testRobotsTxtPageIsNotRegisteredWhenDisabledInConfig() public function testRobotsTxtPageIsHiddenFromNavigationAndExcludedFromTheSitemap() { - $page = new GeneratedFilePage(GeneratedFileRegistry::ROBOTS, RobotsTxtGenerator::class); + $page = new GeneratedFilePage(GeneratedFilePaths::ROBOTS, RobotsTxtGenerator::class); $this->assertFalse($page->showInNavigation()); $this->assertFalse($page->showInSitemap()); @@ -73,7 +72,7 @@ public function testRobotsTxtPageCompilesUsingTheRobotsTxtGenerator() { $this->withoutSiteUrl(); - $this->assertSame("User-agent: *\nAllow: /\n", (new GeneratedFilePage(GeneratedFileRegistry::ROBOTS, RobotsTxtGenerator::class))->compile()); + $this->assertSame("User-agent: *\nAllow: /\n", (new GeneratedFilePage(GeneratedFilePaths::ROBOTS, RobotsTxtGenerator::class))->compile()); } public function testGeneratedPageRetainsItsGeneratorAcrossNativeSerialization() diff --git a/packages/framework/tests/Feature/SitemapPageTest.php b/packages/framework/tests/Feature/SitemapPageTest.php index 980a25034c8..919c83e62b0 100644 --- a/packages/framework/tests/Feature/SitemapPageTest.php +++ b/packages/framework/tests/Feature/SitemapPageTest.php @@ -13,7 +13,7 @@ use Hyde\Foundation\Kernel\PageCollection; use Hyde\Framework\Features\XmlGenerators\SitemapGenerator; use Hyde\Framework\Features\GeneratedFiles\GeneratedFilePage; -use Hyde\Framework\Features\GeneratedFiles\GeneratedFileRegistry; +use Hyde\Framework\Features\GeneratedFiles\GeneratedFilePaths; use Illuminate\Support\Facades\File; /** @@ -66,7 +66,7 @@ public function testSitemapPageIsNotRegisteredWhenSitemapIsDisabledInConfig() public function testSitemapPageIsHiddenFromNavigationAndExcludesItselfFromTheSitemap() { - $page = new GeneratedFilePage(GeneratedFileRegistry::SITEMAP, SitemapGenerator::class); + $page = new GeneratedFilePage(GeneratedFilePaths::SITEMAP, SitemapGenerator::class); $this->assertFalse($page->showInNavigation()); $this->assertFalse($page->showInSitemap()); @@ -76,7 +76,7 @@ public function testSitemapPageCompilesUsingTheSitemapGenerator() { $this->withSiteUrl(); - $contents = (new GeneratedFilePage(GeneratedFileRegistry::SITEMAP, SitemapGenerator::class))->compile(); + $contents = (new GeneratedFilePage(GeneratedFilePaths::SITEMAP, SitemapGenerator::class))->compile(); $this->assertStringStartsWith('', $contents); $this->assertStringContainsString('