From d56c5c7b6f40319231a1c6ee3aa754cd4a1726d5 Mon Sep 17 00:00:00 2001 From: Emma De Silva Date: Sat, 5 Sep 2026 01:34:14 +0200 Subject: [PATCH 01/14] Support replacing discovered page classes --- .../Foundation/Concerns/ManagesExtensions.php | 62 +++++++- .../framework/src/Foundation/HydeKernel.php | 3 + .../src/Foundation/Kernel/FileCollection.php | 3 +- .../Framework/Actions/SourceFileParser.php | 15 +- .../XmlGenerators/SitemapGenerator.php | 23 ++- .../Markdown/Processing/HeadingRenderer.php | 9 +- .../tests/Feature/FileCollectionTest.php | 14 ++ .../Feature/PageClassReplacementTest.php | 122 ++++++++++++++++ .../tests/Feature/SourceFileParserTest.php | 16 +++ .../tests/Unit/ExtensionsUnitTest.php | 136 ++++++++++++++++++ .../tests/Unit/HeadingRendererUnitTest.php | 15 ++ .../framework/tests/Unit/RouteKeyTest.php | 14 ++ 12 files changed, 418 insertions(+), 14 deletions(-) create mode 100644 packages/framework/tests/Feature/PageClassReplacementTest.php diff --git a/packages/framework/src/Foundation/Concerns/ManagesExtensions.php b/packages/framework/src/Foundation/Concerns/ManagesExtensions.php index 52de766e3cf..db618239e5a 100644 --- a/packages/framework/src/Foundation/Concerns/ManagesExtensions.php +++ b/packages/framework/src/Foundation/Concerns/ManagesExtensions.php @@ -5,13 +5,15 @@ namespace Hyde\Foundation\Concerns; use BadMethodCallException; +use Hyde\Pages\Concerns\HydePage; use InvalidArgumentException; -use function array_keys; use function array_map; +use function array_keys; use function array_merge; use function array_unique; use function in_array; +use function is_a; use function is_subclass_of; /** @@ -103,9 +105,63 @@ public function getRegisteredExtensions(): array /** @return array> */ public function getRegisteredPageClasses(): array { - return array_unique(array_merge(...array_map(function (string $extension): array { + $classes = array_merge(...array_map(function (string $extension): array { /** @var > $extension */ return $extension::getPageClasses(); - }, $this->getRegisteredExtensions()))); + }, $this->getRegisteredExtensions())); + + return array_unique(array_map($this->resolvePageClass(...), $classes)); + } + + /** + * Replace a registered page class with an application subclass. + * + * Register replacements in a service provider's register method before the Hyde Kernel boots. + * Changing filesystem or routing behavior on the replacement is not supported. + * + * @param class-string $original + * @param class-string $replacement + * + * @throws \BadMethodCallException If Kernel booting has already started + * @throws \InvalidArgumentException If the classes are incompatible or the replacement conflicts with an existing mapping + */ + public function replacePageClass(string $original, string $replacement): void + { + if ($this->booting || $this->booted) { + throw new BadMethodCallException('Cannot replace a page class after Kernel booting has started.'); + } + + if (! is_a($original, HydePage::class, true)) { + throw new InvalidArgumentException("Original page class [$original] must extend the HydePage class."); + } + + if (! is_subclass_of($replacement, $original)) { + throw new InvalidArgumentException("Replacement page class [$replacement] must extend [$original]."); + } + + if (($this->pageClassReplacements[$original] ?? null) === $replacement) { + return; + } + + if (isset($this->pageClassReplacements[$original])) { + throw new InvalidArgumentException("Page class [$original] has already been replaced by [{$this->pageClassReplacements[$original]}]."); + } + + if (isset($this->pageClassReplacements[$replacement]) || in_array($original, $this->pageClassReplacements, true)) { + throw new InvalidArgumentException('Page class replacements cannot be chained.'); + } + + $this->pageClassReplacements[$original] = $replacement; + } + + /** + * @internal Resolve a canonical page class to the class used at runtime. + * + * @param class-string $pageClass + * @return class-string + */ + public function resolvePageClass(string $pageClass): string + { + return $this->pageClassReplacements[$pageClass] ?? $pageClass; } } diff --git a/packages/framework/src/Foundation/HydeKernel.php b/packages/framework/src/Foundation/HydeKernel.php index 5d82740a814..ceacfdcf8ad 100644 --- a/packages/framework/src/Foundation/HydeKernel.php +++ b/packages/framework/src/Foundation/HydeKernel.php @@ -72,6 +72,9 @@ class HydeKernel implements SerializableContract /** @var array, \Hyde\Foundation\Concerns\HydeExtension> */ protected array $extensions = []; + /** @var array, class-string<\Hyde\Pages\Concerns\HydePage>> */ + protected array $pageClassReplacements = []; + public function __construct(?string $basePath = null) { $this->setBasePath($basePath ?? getcwd()); diff --git a/packages/framework/src/Foundation/Kernel/FileCollection.php b/packages/framework/src/Foundation/Kernel/FileCollection.php index 7659cbbd9df..b83583fcd7e 100644 --- a/packages/framework/src/Foundation/Kernel/FileCollection.php +++ b/packages/framework/src/Foundation/Kernel/FileCollection.php @@ -11,6 +11,7 @@ use Hyde\Support\Filesystem\SourceFile; use function basename; +use function is_a; use function str_starts_with; /** @@ -75,7 +76,7 @@ public function getFile(string $path): SourceFile public function getFiles(?string $pageClass = null): FileCollection { return $pageClass ? $this->filter(function (SourceFile $file) use ($pageClass): bool { - return $file->pageClass === $pageClass; + return is_a($file->pageClass, $pageClass, true); }) : $this; } } diff --git a/packages/framework/src/Framework/Actions/SourceFileParser.php b/packages/framework/src/Framework/Actions/SourceFileParser.php index 0a6358f7284..d856276a296 100644 --- a/packages/framework/src/Framework/Actions/SourceFileParser.php +++ b/packages/framework/src/Framework/Actions/SourceFileParser.php @@ -4,11 +4,13 @@ namespace Hyde\Framework\Actions; +use Hyde\Hyde; use Hyde\Pages\BladePage; use Hyde\Pages\Concerns\HydePage; use Hyde\Pages\Concerns\BaseMarkdownPage; use Hyde\Framework\Concerns\ValidatesExistence; +use function is_a; use function is_subclass_of; /** @@ -31,6 +33,8 @@ class SourceFileParser */ public function __construct(string $pageClass, string $identifier) { + $pageClass = Hyde::resolvePageClass($pageClass); + $this->validateExistence($pageClass, $identifier); $this->identifier = $identifier; @@ -39,8 +43,8 @@ public function __construct(string $pageClass, string $identifier) protected function constructPage(string $pageClass): HydePage|BladePage|BaseMarkdownPage { - if ($pageClass === BladePage::class) { - return $this->parseBladePage(); + if (is_a($pageClass, BladePage::class, true)) { + return $this->parseBladePage($pageClass); } if (is_subclass_of($pageClass, BaseMarkdownPage::class)) { @@ -50,11 +54,12 @@ protected function constructPage(string $pageClass): HydePage|BladePage|BaseMark return new $pageClass($this->identifier); } - protected function parseBladePage(): BladePage + /** @param class-string $pageClass */ + protected function parseBladePage(string $pageClass): BladePage { - return new BladePage( + return new $pageClass( identifier: $this->identifier, - matter: BladeMatterParser::parseFile(BladePage::sourcePath($this->identifier)) + matter: BladeMatterParser::parseFile($pageClass::sourcePath($this->identifier)) ); } diff --git a/packages/framework/src/Framework/Features/XmlGenerators/SitemapGenerator.php b/packages/framework/src/Framework/Features/XmlGenerators/SitemapGenerator.php index abb862fb216..8a13303510b 100644 --- a/packages/framework/src/Framework/Features/XmlGenerators/SitemapGenerator.php +++ b/packages/framework/src/Framework/Features/XmlGenerators/SitemapGenerator.php @@ -19,7 +19,7 @@ use Hyde\Pages\DocumentationPage; use Hyde\Foundation\Facades\Routes; -use function in_array; +use function is_a; use function date; /** @@ -72,7 +72,7 @@ protected function generatePriority(string $pageClass, string $identifier): stri { $priority = 0.5; - if (in_array($pageClass, [BladePage::class, MarkdownPage::class, DocumentationPage::class])) { + if ($this->isPageType($pageClass, [BladePage::class, MarkdownPage::class, DocumentationPage::class])) { $priority = 0.9; if ($identifier === 'index') { @@ -80,7 +80,7 @@ protected function generatePriority(string $pageClass, string $identifier): stri } } - if (in_array($pageClass, [MarkdownPost::class, InMemoryPage::class, HtmlPage::class])) { + if ($this->isPageType($pageClass, [MarkdownPost::class, HtmlPage::class]) || $pageClass === InMemoryPage::class) { $priority = 0.75; } @@ -99,7 +99,7 @@ protected function generateChangeFrequency(string $pageClass, string $identifier { $frequency = 'weekly'; - if (in_array($pageClass, [BladePage::class, MarkdownPage::class, DocumentationPage::class])) { + if ($this->isPageType($pageClass, [BladePage::class, MarkdownPage::class, DocumentationPage::class])) { $frequency = 'daily'; } @@ -115,4 +115,19 @@ protected function getRouteInformation(Route $route): array { return [$route->getPageClass(), $route->getPage()->getIdentifier()]; } + + /** + * @param class-string<\Hyde\Pages\Concerns\HydePage> $pageClass + * @param array> $pageTypes + */ + private function isPageType(string $pageClass, array $pageTypes): bool + { + foreach ($pageTypes as $pageType) { + if (is_a($pageClass, $pageType, true)) { + return true; + } + } + + return false; + } } diff --git a/packages/framework/src/Markdown/Processing/HeadingRenderer.php b/packages/framework/src/Markdown/Processing/HeadingRenderer.php index d4eedced24f..82eb8513d49 100644 --- a/packages/framework/src/Markdown/Processing/HeadingRenderer.php +++ b/packages/framework/src/Markdown/Processing/HeadingRenderer.php @@ -11,6 +11,9 @@ use League\CommonMark\Renderer\ChildNodeRendererInterface; use League\CommonMark\Renderer\NodeRendererInterface; +use function array_filter; +use function is_a; + /** * Renders a heading node, and supports built-in permalink generation. * @@ -59,7 +62,11 @@ public function canAddPermalink(string $content, int $level): bool && $level >= config('markdown.permalinks.min_level', 2) && $level <= config('markdown.permalinks.max_level', 6) && ! str_contains($content, 'class="heading-permalink"') - && in_array($this->pageClass, config('markdown.permalinks.pages', [DocumentationPage::class])); + && $this->pageClass !== null + && array_filter( + config('markdown.permalinks.pages', [DocumentationPage::class]), + fn (string $pageClass): bool => is_a($this->pageClass, $pageClass, true) + ) !== []; } /** @internal */ diff --git a/packages/framework/tests/Feature/FileCollectionTest.php b/packages/framework/tests/Feature/FileCollectionTest.php index 9a31862e0f1..06a0386e7be 100644 --- a/packages/framework/tests/Feature/FileCollectionTest.php +++ b/packages/framework/tests/Feature/FileCollectionTest.php @@ -69,6 +69,16 @@ public function testGetSourceFilesDoesNotIncludeNonPageSourceFiles() $this->restoreDefaultPages(); } + public function testGetFilesIncludesSubclassesOfTheRequestedPageClass() + { + $collection = FileCollection::init(Hyde::getInstance()); + $collection->addFile(new SourceFile('_posts/post.md', MarkdownPost::class)); + $collection->addFile(new SourceFile('_posts/special.md', SpecialMarkdownPost::class)); + + $this->assertCount(2, $collection->getFiles(MarkdownPost::class)); + $this->assertCount(1, $collection->getFiles(SpecialMarkdownPost::class)); + } + public function testBladePagesAreDiscovered() { $this->file('_pages/foo.blade.php'); @@ -121,3 +131,7 @@ public function testDiscoverFilesForRecursivelyDiscoversFilesInSubdirectories() $this->assertEquals(new SourceFile('_pages/foo/bar/baz.md', MarkdownPage::class), $collection->get('_pages/foo/bar/baz.md')); } } + +class SpecialMarkdownPost extends MarkdownPost +{ +} diff --git a/packages/framework/tests/Feature/PageClassReplacementTest.php b/packages/framework/tests/Feature/PageClassReplacementTest.php new file mode 100644 index 00000000000..decd9957f7e --- /dev/null +++ b/packages/framework/tests/Feature/PageClassReplacementTest.php @@ -0,0 +1,122 @@ + false, 'hyde.rss.enabled' => false]); + } + + public function testServiceProviderCanReplaceADiscoveredPageClassBeforeKernelBoot() + { + (new PageClassReplacementServiceProvider(app()))->register(); + $this->markdown('_posts/2024-01-02-hello-world.md', 'Hello world', ['title' => 'Hello World']); + + Hyde::boot(); + + $this->assertSame([ + \Hyde\Pages\HtmlPage::class, + \Hyde\Pages\BladePage::class, + \Hyde\Pages\MarkdownPage::class, + TestMarkdownPost::class, + \Hyde\Pages\DocumentationPage::class, + ], Hyde::getRegisteredPageClasses()); + + $this->assertNotContains(MarkdownPost::class, Hyde::getRegisteredPageClasses()); + $this->assertCount(1, Hyde::files()->getFiles(MarkdownPost::class)); + $this->assertCount(1, Hyde::files()->getFiles(TestMarkdownPost::class)); + + $sourceFile = Hyde::files()->getFile('_posts/2024-01-02-hello-world.md'); + $page = Hyde::pages()->getPage('_posts/2024-01-02-hello-world.md'); + + $this->assertSame(TestMarkdownPost::class, $sourceFile->pageClass); + $this->assertSame(TestMarkdownPost::class, $page::class); + $this->assertInstanceOf(TestMarkdownPost::class, $page); + $this->assertInstanceOf(MarkdownPost::class, $page); + $this->assertSame(2, $page->readingTime()); + $this->assertSame('Custom: Hello World', $page->title()); + $this->assertSame('posts/hello-world', $page->getRouteKey()); + $this->assertSame('_posts', TestMarkdownPost::sourceDirectory()); + $this->assertSame('posts', TestMarkdownPost::outputDirectory()); + + $sitemap = (new SitemapGenerator())->generate()->getXmlElement(); + $postEntry = collect($sitemap->url)->first( + fn ($url): bool => (string) $url->loc === 'posts/hello-world.html' + ); + + $this->assertNotNull($postEntry); + $this->assertSame('0.75', (string) $postEntry->priority); + } + + public function testCanonicalAndReplacementStaticQueriesUseTheDiscoveredSubclass() + { + Hyde::replacePageClass(MarkdownPost::class, TestMarkdownPost::class); + $this->markdown('_posts/query-test.md', 'Hello world'); + + Hyde::boot(); + + $this->assertSame(['query-test'], MarkdownPost::files()); + $this->assertSame(['query-test'], TestMarkdownPost::files()); + $this->assertContainsOnlyInstancesOf(TestMarkdownPost::class, MarkdownPost::all()); + $this->assertContainsOnlyInstancesOf(TestMarkdownPost::class, TestMarkdownPost::all()); + $this->assertInstanceOf(TestMarkdownPost::class, MarkdownPost::get('query-test')); + $this->assertInstanceOf(TestMarkdownPost::class, TestMarkdownPost::get('query-test')); + $this->assertInstanceOf(TestMarkdownPost::class, MarkdownPost::parse('query-test')); + $this->assertInstanceOf(TestMarkdownPost::class, TestMarkdownPost::parse('query-test')); + } + + public function testCanonicalDirectoryConfigurationStillAppliesToReplacementClass() + { + config(['hyde.source_directories' => [MarkdownPost::class => '.replacement/posts']]); + config(['hyde.output_directories' => [MarkdownPost::class => 'articles']]); + + (new HydeServiceProvider(app()))->register(); + (new PageClassReplacementServiceProvider(app()))->register(); + $this->markdown('.replacement/posts/configured.md', 'Configured post'); + + Hyde::boot(); + + $page = MarkdownPost::get('configured'); + + $this->assertInstanceOf(TestMarkdownPost::class, $page); + $this->assertSame('.replacement/posts/configured.md', $page->getSourcePath()); + $this->assertSame('articles/configured.html', $page->getOutputPath()); + } +} + +class PageClassReplacementServiceProvider extends ServiceProvider +{ + public function register(): void + { + Hyde::replacePageClass(MarkdownPost::class, TestMarkdownPost::class); + } +} + +class TestMarkdownPost extends MarkdownPost +{ + public function readingTime(): int + { + return str_word_count($this->markdown->body()); + } + + public function title(): string + { + return 'Custom: '.$this->title; + } +} diff --git a/packages/framework/tests/Feature/SourceFileParserTest.php b/packages/framework/tests/Feature/SourceFileParserTest.php index e63d1e86b83..d03120a1f9e 100644 --- a/packages/framework/tests/Feature/SourceFileParserTest.php +++ b/packages/framework/tests/Feature/SourceFileParserTest.php @@ -4,6 +4,7 @@ namespace Hyde\Framework\Testing\Feature; +use Hyde\Hyde; use Hyde\Framework\Actions\SourceFileParser; use Hyde\Pages\BladePage; use Hyde\Pages\DocumentationPage; @@ -103,4 +104,19 @@ public function testBladePageMatterIsUsedForThePageTitle() $this->assertSame('Foo Bar', $page->data('title')); } + + public function testBladePageReplacementRetainsBladeMatterParsing() + { + Hyde::replacePageClass(BladePage::class, ReplacementBladePage::class); + $this->file('_pages/foo.blade.php', "@php(\$title = 'Foo Bar')\n"); + + $page = BladePage::parse('foo'); + + $this->assertInstanceOf(ReplacementBladePage::class, $page); + $this->assertSame('Foo Bar', $page->data('title')); + } +} + +class ReplacementBladePage extends BladePage +{ } diff --git a/packages/framework/tests/Unit/ExtensionsUnitTest.php b/packages/framework/tests/Unit/ExtensionsUnitTest.php index 966612fd4d1..124bde61579 100644 --- a/packages/framework/tests/Unit/ExtensionsUnitTest.php +++ b/packages/framework/tests/Unit/ExtensionsUnitTest.php @@ -203,6 +203,98 @@ public function testGetRegisteredPageClassesMergesAllExtensionClasses() ); } + public function testCanReplaceARegisteredPageClassWithASubclass() + { + $this->kernel->replacePageClass(MarkdownPost::class, ReplacementMarkdownPost::class); + + $this->assertSame(ReplacementMarkdownPost::class, $this->kernel->resolvePageClass(MarkdownPost::class)); + $this->assertContains(ReplacementMarkdownPost::class, $this->kernel->getRegisteredPageClasses()); + $this->assertNotContains(MarkdownPost::class, $this->kernel->getRegisteredPageClasses()); + } + + public function testCanRegisterAReplacementBeforeItsOriginalPageClassExtension() + { + $this->kernel->replacePageClass(ReplaceableExtensionPage::class, ReplacementExtensionPage::class); + $this->kernel->registerExtension(ReplaceablePageExtension::class); + + $this->assertContains(ReplacementExtensionPage::class, $this->kernel->getRegisteredPageClasses()); + $this->assertNotContains(ReplaceableExtensionPage::class, $this->kernel->getRegisteredPageClasses()); + } + + public function testEffectivePageClassRegistryIsDeduplicated() + { + $this->kernel->replacePageClass(MarkdownPost::class, ReplacementMarkdownPost::class); + $this->kernel->registerExtension(ReplacementPageExtension::class); + + $this->assertSame(1, array_count_values($this->kernel->getRegisteredPageClasses())[ReplacementMarkdownPost::class]); + } + + public function testRegisteringTheSameReplacementTwiceIsIdempotent() + { + $this->kernel->replacePageClass(MarkdownPost::class, ReplacementMarkdownPost::class); + $this->kernel->replacePageClass(MarkdownPost::class, ReplacementMarkdownPost::class); + + $this->assertSame(ReplacementMarkdownPost::class, $this->kernel->resolvePageClass(MarkdownPost::class)); + } + + public function testCannotReplaceAPageClassAfterTheKernelHasBooted() + { + $this->kernel->boot(); + + $this->expectException(BadMethodCallException::class); + $this->expectExceptionMessage('Cannot replace a page class after Kernel booting has started.'); + + $this->kernel->replacePageClass(MarkdownPost::class, ReplacementMarkdownPost::class); + } + + public function testCannotReplaceAPageClassAfterKernelBootingHasStarted() + { + $this->expectException(BadMethodCallException::class); + $this->expectExceptionMessage('Cannot replace a page class after Kernel booting has started.'); + + $this->kernel->booting(function (HydeKernel $kernel): void { + $kernel->replacePageClass(MarkdownPost::class, ReplacementMarkdownPost::class); + }); + + $this->kernel->boot(); + } + + public function testOriginalReplacementClassMustBeAHydePage() + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Original page class [stdClass] must extend the HydePage class.'); + + $this->kernel->replacePageClass(stdClass::class, ReplacementMarkdownPost::class); + } + + public function testReplacementClassMustExtendTheOriginalPageClass() + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Replacement page class ['.MarkdownPage::class.'] must extend ['.MarkdownPost::class.'].'); + + $this->kernel->replacePageClass(MarkdownPost::class, MarkdownPage::class); + } + + public function testCannotRegisterCompetingReplacements() + { + $this->kernel->replacePageClass(MarkdownPost::class, ReplacementMarkdownPost::class); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Page class ['.MarkdownPost::class.'] has already been replaced by ['.ReplacementMarkdownPost::class.'].'); + + $this->kernel->replacePageClass(MarkdownPost::class, OtherReplacementMarkdownPost::class); + } + + public function testCannotChainPageClassReplacements() + { + $this->kernel->replacePageClass(MarkdownPost::class, ReplacementMarkdownPost::class); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Page class replacements cannot be chained.'); + + $this->kernel->replacePageClass(ReplacementMarkdownPost::class, NestedReplacementMarkdownPost::class); + } + public function testMergedRegisteredPageClassesArrayContents() { $this->assertSame([ @@ -290,3 +382,47 @@ public function example(): string return 'foo'; } } + +class ReplacementMarkdownPost extends MarkdownPost +{ +} + +class OtherReplacementMarkdownPost extends MarkdownPost +{ +} + +class NestedReplacementMarkdownPost extends ReplacementMarkdownPost +{ +} + +class ReplaceableExtensionPage extends HydePage +{ + public static string $sourceDirectory = 'replaceable'; + public static string $outputDirectory = 'replaceable'; + public static string $sourceExtension = '.txt'; + + public function compile(): string + { + return ''; + } +} + +class ReplacementExtensionPage extends ReplaceableExtensionPage +{ +} + +class ReplaceablePageExtension extends HydeExtension +{ + public static function getPageClasses(): array + { + return [ReplaceableExtensionPage::class]; + } +} + +class ReplacementPageExtension extends HydeExtension +{ + public static function getPageClasses(): array + { + return [ReplacementMarkdownPost::class]; + } +} diff --git a/packages/framework/tests/Unit/HeadingRendererUnitTest.php b/packages/framework/tests/Unit/HeadingRendererUnitTest.php index e6e271e0298..eba8b101dfc 100644 --- a/packages/framework/tests/Unit/HeadingRendererUnitTest.php +++ b/packages/framework/tests/Unit/HeadingRendererUnitTest.php @@ -175,6 +175,17 @@ public function testCanAddPermalinkWithCustomPageClasses(): void $this->assertTrue($renderer->canAddPermalink('Test Content', 2)); } + public function testCanonicalPermalinkConfigurationAppliesToPageSubclasses(): void + { + self::mockConfig([ + 'markdown.permalinks.pages' => [DocumentationPage::class], + ]); + + $renderer = new HeadingRenderer(ReplacementDocumentationPage::class); + + $this->assertTrue($renderer->canAddPermalink('Test Content', 2)); + } + public function testPostProcessMethodNormalizesInputToMatchCommonMark() { // Actual HTML output returned from Blade @@ -303,3 +314,7 @@ public static function headingIdentifierProvider(): \Iterator yield ['1234567890', '1234567890']; } } + +class ReplacementDocumentationPage extends DocumentationPage +{ +} diff --git a/packages/framework/tests/Unit/RouteKeyTest.php b/packages/framework/tests/Unit/RouteKeyTest.php index fbd5f3e2b42..f423ce6e3a2 100644 --- a/packages/framework/tests/Unit/RouteKeyTest.php +++ b/packages/framework/tests/Unit/RouteKeyTest.php @@ -131,6 +131,12 @@ public function testItExtractsCoreIdentifierPartFromNumericalFilenamePrefix() $this->assertSame('docs/test', RouteKey::fromPage(DocumentationPage::class, '01-test')->get()); } + public function testCoreIdentifierPrefixesAreExtractedForPageSubclasses() + { + $this->assertSame('docs/test', RouteKey::fromPage(RouteKeyDocumentationPage::class, '01-test')->get()); + $this->assertSame('posts/test', RouteKey::fromPage(RouteKeyMarkdownPost::class, '2024-01-02-test')->get()); + } + public function testItExtractsCoreIdentifierPartFromNumericalFilenamePrefixWithKebabCaseSyntax() { $this->assertSame('docs/foo', RouteKey::fromPage(DocumentationPage::class, '01-foo')->get()); @@ -194,3 +200,11 @@ class InMemoryPageWithCustomOutputConfiguration extends InMemoryPage public static string $outputDirectory = 'api'; public static string $outputExtension = '.json'; } + +class RouteKeyDocumentationPage extends DocumentationPage +{ +} + +class RouteKeyMarkdownPost extends MarkdownPost +{ +} From a1edd377dceed76414589b4e9b91a87c7464cffe Mon Sep 17 00:00:00 2001 From: Emma De Silva Date: Sat, 5 Sep 2026 01:34:24 +0200 Subject: [PATCH 02/14] Document page class replacements --- HYDEPHP_V3_PLANNING.md | 3 ++ UPGRADE.md | 17 +++++++ .../hyde-kernel-extensions-methods.md | 23 +++++++++- docs/architecture-concepts/page-models.md | 45 +++++++++++++++++++ docs/architecture-concepts/the-hydekernel.md | 23 +++++++++- 5 files changed, 109 insertions(+), 2 deletions(-) diff --git a/HYDEPHP_V3_PLANNING.md b/HYDEPHP_V3_PLANNING.md index eed8207afc1..7f917a1e2d6 100644 --- a/HYDEPHP_V3_PLANNING.md +++ b/HYDEPHP_V3_PLANNING.md @@ -21,6 +21,7 @@ Having this document in code lets us know the devlopment state at any given poin ### New Features +- Discoverable page models can now be replaced with behavioral application subclasses by calling `Hyde::replacePageClass()` from a service provider's `register()` method. Hyde uses the replacement throughout discovery and parsing while preserving canonical page queries, configuration, routing, and parent-class type checks. Replacements are registration-time only, must extend the original page class, and conflicting or chained replacements are rejected. Changing filesystem or routing configuration on a replacement is not supported. - Added native support for versioned documentation pages. Register versions in the new `docs.versions` configuration option, and store the pages for each version in a matching subdirectory of the documentation source directory (like `_docs/1.x` and `_docs/2.x`). Each version is compiled to a matching subdirectory of the documentation output directory, and gets its own sidebar, search index, and search page. A version switcher dropdown is shown in the documentation sidebar, the main navigation links to the default version's index page, and a redirect page is generated at the documentation root pointing to the default version. Sidebar and search configuration entries (`docs.sidebar.order`, `docs.sidebar.labels`, `docs.sidebar.exclude`, and `docs.exclude_from_search`) match version-agnostic identifiers and route keys, so a single entry applies to the page in every version, while full versioned keys allow version-specific overrides. Enabling the feature is all or nothing: documentation source files stored outside the version directories are ignored, so pages that should live at the documentation root belong in the normal page source directory (like `_pages/docs/index.md`). Versioning is disabled by default, and single-version sites are unaffected. ([#2516](https://github.com/hydephp/develop/pull/2516)) - Redirects can now be declared as source and destination path pairs in the `hyde.redirects` configuration array. Hyde registers them with the kernel, includes them in `route:list`, and generates them through the normal site build. - Added Blade Blocks for rendering Blade and Blade components from fenced code blocks in Markdown pages. The supported directives are `blade render` and `blade component="name"`, and the feature is controlled by `markdown.enable_blade`. ([#2504](https://github.com/hydephp/develop/pull/2504)) @@ -57,6 +58,7 @@ Having this document in code lets us know the devlopment state at any given poin ### Breaking Changes +- `FileCollection::getFiles($pageClass)` now includes files assigned to subclasses of the requested page class, matching the existing polymorphic behavior of `PageCollection::getPages()`. Custom extensions that register both a parent page class and its subclass should filter on `$file->pageClass` when they specifically need exact-class results. - Renamed the static page class property `$fileExtension` to `$sourceExtension`, and the `fileExtension()` and `setFileExtension()` methods to `sourceExtension()` and `setSourceExtension()`, making it explicit that these APIs describe source files. Custom page classes and code calling these APIs need the mechanical rename, which the planned automated upgrade script will handle (see the upgrade script rules section at the end of this document). - Removed the `GenerateSitemap` post-build task, as the sitemap is now generated through the page and route system. Sites that just enable or disable the sitemap through configuration are unaffected. Code referencing the task class — like a user-land `GenerateSitemap` build task relying on the same-basename override mechanism to replace the framework task — should bind a custom `SitemapGenerator` in the container instead. The `build:sitemap` command now compiles the registered page, and fails with an error (exit code 1 instead of 3) when the sitemap cannot be generated — because no base URL is configured or it is disabled in the configuration — instead of generating it anyway in the latter case. - Removed the `GenerateRssFeed` post-build task, as the RSS feed is now generated through the page and route system. Sites that just enable or disable the feed through configuration are unaffected. Code referencing the task class — like a user-land `GenerateRssFeed` build task relying on the same-basename override mechanism to replace the framework task — should bind a custom `RssFeedGenerator` in the container instead. The `build:rss` command now compiles the registered page, and fails with an error when the feed cannot be generated (no base URL, disabled in the configuration, or no Markdown posts), instead of silently generating an empty feed. @@ -87,6 +89,7 @@ Please fill in UPGRADE.md as you make changes. - Update `InMemoryPage` calls to supply only `contents` or `view`. Replace an empty-string positional contents placeholder with `null`, or use the named `view` argument. - Add `navigation.visible: true` or `navigation.hidden: false` to non-HTML pages that should remain in automatic navigation, and review that matter where it was previously a no-op, like on blog posts and pages in hidden subdirectories, as it now shows them. - Rename `$fileExtension` to `$sourceExtension` in custom page classes, and update any calls to `fileExtension()` or `setFileExtension()` to `sourceExtension()` and `setSourceExtension()`. +- If a custom extension registers both a parent page class and its subclass, replace `FileCollection::getFiles($parent)` calls that require exact-class results with an explicit `$file->pageClass === $parent` filter. - If you referenced the removed `GenerateSitemap` or `GenerateRssFeed` build task classes (for example to override one with a same-basename user-land task), customize the output by binding a replacement `SitemapGenerator` or `RssFeedGenerator` in the `register()` method of a service provider. - Replace `// filepath:` code block comments with the `title="…"` fence modifier, including the `#`, `/* */`, and `` comment variants. - Compare a few pages against your old site if you have custom CSS for code blocks or their labels, since the generated markup changed. The `hyde-code-block` and `hyde-code-block-label` classes are stable hooks to target instead of the markup structure. diff --git a/UPGRADE.md b/UPGRADE.md index d81d138ffe6..34a01be7187 100644 --- a/UPGRADE.md +++ b/UPGRADE.md @@ -369,6 +369,22 @@ The automated upgrade script will handle this rename for ordinary property decla method calls, and overridden method declarations. Dynamic references — variable method or property names, reflection, and string-based access — must be updated manually. +### Review Exact Page-Class File Queries + +`FileCollection::getFiles($pageClass)` now includes files assigned to subclasses of the requested page class, matching +`PageCollection::getPages()`. This only affects custom extensions that register or add files for both a parent page +class and its subclass. If such code needs exact-class results, filter the collection explicitly: + +```php +use App\Pages\CustomPage; +use Hyde\Hyde; +use Hyde\Support\Filesystem\SourceFile; + +$files = Hyde::files()->filter( + fn (SourceFile $file): bool => $file->pageClass === CustomPage::class, +); +``` + ## Step 9: Replace Your Code Block Filepath Comments Code block labels are now set with a `title="…"` modifier on the fence, and the `// filepath:` comment is no longer @@ -451,6 +467,7 @@ Use this checklist to track your upgrade progress: - [ ] Explicitly opted in any non-HTML pages that should remain in automatic navigation - [ ] Replaced any references to the removed `GenerateSitemap` and `GenerateRssFeed` build tasks with generator implementations bound in a service provider - [ ] Renamed `$fileExtension`, `fileExtension()`, and `setFileExtension()` to `$sourceExtension`, `sourceExtension()`, and `setSourceExtension()` in custom page classes and call sites +- [ ] Reviewed `FileCollection::getFiles()` calls that require exact page-class matching - [ ] Replaced `// filepath:` code block comments with the `title="…"` fence modifier - [ ] Ported any `filepath-label.blade.php` customizations to `markdown/code-block.blade.php`, and deleted the old file - [ ] Compared pages against your old site if you have custom CSS for code blocks or their labels diff --git a/docs/_data/partials/hyde-pages-api/hyde-kernel-extensions-methods.md b/docs/_data/partials/hyde-pages-api/hyde-kernel-extensions-methods.md index 4c12889d867..ea3338b9bcb 100644 --- a/docs/_data/partials/hyde-pages-api/hyde-kernel-extensions-methods.md +++ b/docs/_data/partials/hyde-pages-api/hyde-kernel-extensions-methods.md @@ -1,7 +1,7 @@
- + #### `registerExtension()` @@ -53,6 +53,27 @@ No description provided. Hyde::getRegisteredPageClasses(): array> ``` +#### `replacePageClass()` + +Replace a registered page class with an application subclass. + +Register replacements in a service provider's register method before the Hyde Kernel boots. Changing filesystem or routing behavior on the replacement is not supported. + +```php +Hyde::replacePageClass(class-string<HydePage> $original, class-string<HydePage> $replacement): void +``` + +- **Throws:** \BadMethodCallException If Kernel booting has already started +- **Throws:** \InvalidArgumentException If the classes are incompatible or the replacement conflicts with an existing mapping + +#### `resolvePageClass()` + +No description provided. + +```php +Hyde::resolvePageClass(class-string<HydePage> $pageClass): class-string +``` +
diff --git a/docs/architecture-concepts/page-models.md b/docs/architecture-concepts/page-models.md index e5eedbab6ab..1953db40aab 100644 --- a/docs/architecture-concepts/page-models.md +++ b/docs/architecture-concepts/page-models.md @@ -104,3 +104,48 @@ the routeKey property is used to generate the URL for the page. The matter and markdown properties as I'm sure you can guess, hold the page's front matter and markdown content. These can then also be processed by [page factories](dynamic-data-discovery) to generate the computed data like the title property. + +## Replacing a Page Class + +To customize a page model that Hyde discovers from the filesystem, extend the built-in class: + +```php +namespace App\Pages; + +use Hyde\Pages\MarkdownPost; + +class MyMarkdownPost extends MarkdownPost +{ + public function readingTime(): int + { + return (int) ceil(str_word_count($this->markdown->body()) / 200); + } +} +``` + +Register the replacement in the `register` method of a service provider, before the Hyde kernel boots: + +```php +namespace App\Providers; + +use App\Pages\MyMarkdownPost; +use Hyde\Hyde; +use Hyde\Pages\MarkdownPost; +use Illuminate\Support\ServiceProvider; + +class AppServiceProvider extends ServiceProvider +{ + public function register(): void + { + Hyde::replacePageClass( + MarkdownPost::class, + MyMarkdownPost::class, + ); + } +} +``` + +Hyde then uses `MyMarkdownPost` when discovering and parsing Markdown posts. The replacement must extend the original +page class. Replacement classes are intended to customize page behavior; changing their filesystem or routing +configuration is not supported. Use Hyde's existing configuration options to customize source and output directories. +Normal `instanceof MarkdownPost` checks continue to work. diff --git a/docs/architecture-concepts/the-hydekernel.md b/docs/architecture-concepts/the-hydekernel.md index 6f90671bdb9..5991b89fb05 100644 --- a/docs/architecture-concepts/the-hydekernel.md +++ b/docs/architecture-concepts/the-hydekernel.md @@ -422,7 +422,7 @@ Hyde::getMediaOutputDirectory(): string
- + #### `registerExtension()` @@ -474,6 +474,27 @@ No description provided. Hyde::getRegisteredPageClasses(): array> ``` +#### `replacePageClass()` + +Replace a registered page class with an application subclass. + +Register replacements in a service provider's register method before the Hyde Kernel boots. Changing filesystem or routing behavior on the replacement is not supported. + +```php +Hyde::replacePageClass(class-string<HydePage> $original, class-string<HydePage> $replacement): void +``` + +- **Throws:** \BadMethodCallException If Kernel booting has already started +- **Throws:** \InvalidArgumentException If the classes are incompatible or the replacement conflicts with an existing mapping + +#### `resolvePageClass()` + +No description provided. + +```php +Hyde::resolvePageClass(class-string<HydePage> $pageClass): class-string +``` +
From 2f585c5b18e8d31cce12eeb523a35060378dbd86 Mon Sep 17 00:00:00 2001 From: Emma De Silva Date: Sun, 6 Sep 2026 16:11:41 +0200 Subject: [PATCH 03/14] Keep the effective page registry list-shaped --- .../src/Foundation/Concerns/ManagesExtensions.php | 3 ++- packages/framework/tests/Unit/ExtensionsUnitTest.php | 11 +++++++++-- 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/packages/framework/src/Foundation/Concerns/ManagesExtensions.php b/packages/framework/src/Foundation/Concerns/ManagesExtensions.php index db618239e5a..df51f450656 100644 --- a/packages/framework/src/Foundation/Concerns/ManagesExtensions.php +++ b/packages/framework/src/Foundation/Concerns/ManagesExtensions.php @@ -12,6 +12,7 @@ use function array_keys; use function array_merge; use function array_unique; +use function array_values; use function in_array; use function is_a; use function is_subclass_of; @@ -110,7 +111,7 @@ public function getRegisteredPageClasses(): array return $extension::getPageClasses(); }, $this->getRegisteredExtensions())); - return array_unique(array_map($this->resolvePageClass(...), $classes)); + return array_values(array_unique(array_map($this->resolvePageClass(...), $classes))); } /** diff --git a/packages/framework/tests/Unit/ExtensionsUnitTest.php b/packages/framework/tests/Unit/ExtensionsUnitTest.php index 124bde61579..0986a219cc2 100644 --- a/packages/framework/tests/Unit/ExtensionsUnitTest.php +++ b/packages/framework/tests/Unit/ExtensionsUnitTest.php @@ -226,7 +226,14 @@ public function testEffectivePageClassRegistryIsDeduplicated() $this->kernel->replacePageClass(MarkdownPost::class, ReplacementMarkdownPost::class); $this->kernel->registerExtension(ReplacementPageExtension::class); - $this->assertSame(1, array_count_values($this->kernel->getRegisteredPageClasses())[ReplacementMarkdownPost::class]); + $this->assertSame([ + HtmlPage::class, + BladePage::class, + MarkdownPage::class, + ReplacementMarkdownPost::class, + DocumentationPage::class, + ReplaceableExtensionPage::class, + ], $this->kernel->getRegisteredPageClasses()); } public function testRegisteringTheSameReplacementTwiceIsIdempotent() @@ -423,6 +430,6 @@ class ReplacementPageExtension extends HydeExtension { public static function getPageClasses(): array { - return [ReplacementMarkdownPost::class]; + return [ReplacementMarkdownPost::class, ReplaceableExtensionPage::class]; } } From 782d914982e87a54f073a463f1745d258a233237 Mon Sep 17 00:00:00 2001 From: Emma De Silva Date: Sun, 6 Sep 2026 16:12:23 +0200 Subject: [PATCH 04/14] Match permalink page types through registered replacements --- .../Markdown/Processing/HeadingRenderer.php | 13 +++++----- .../tests/Unit/HeadingRendererUnitTest.php | 24 ++++++++++++++++++- 2 files changed, 30 insertions(+), 7 deletions(-) diff --git a/packages/framework/src/Markdown/Processing/HeadingRenderer.php b/packages/framework/src/Markdown/Processing/HeadingRenderer.php index 82eb8513d49..c8bb10351b3 100644 --- a/packages/framework/src/Markdown/Processing/HeadingRenderer.php +++ b/packages/framework/src/Markdown/Processing/HeadingRenderer.php @@ -4,6 +4,7 @@ namespace Hyde\Markdown\Processing; +use Hyde\Hyde; use Hyde\Pages\DocumentationPage; use Illuminate\Support\Str; use League\CommonMark\Extension\CommonMark\Node\Block\Heading; @@ -11,8 +12,8 @@ use League\CommonMark\Renderer\ChildNodeRendererInterface; use League\CommonMark\Renderer\NodeRendererInterface; -use function array_filter; -use function is_a; +use function array_map; +use function in_array; /** * Renders a heading node, and supports built-in permalink generation. @@ -63,10 +64,10 @@ public function canAddPermalink(string $content, int $level): bool && $level <= config('markdown.permalinks.max_level', 6) && ! str_contains($content, 'class="heading-permalink"') && $this->pageClass !== null - && array_filter( - config('markdown.permalinks.pages', [DocumentationPage::class]), - fn (string $pageClass): bool => is_a($this->pageClass, $pageClass, true) - ) !== []; + && in_array($this->pageClass, array_map( + Hyde::resolvePageClass(...), + config('markdown.permalinks.pages', [DocumentationPage::class]) + ), true); } /** @internal */ diff --git a/packages/framework/tests/Unit/HeadingRendererUnitTest.php b/packages/framework/tests/Unit/HeadingRendererUnitTest.php index eba8b101dfc..a372a98bd7e 100644 --- a/packages/framework/tests/Unit/HeadingRendererUnitTest.php +++ b/packages/framework/tests/Unit/HeadingRendererUnitTest.php @@ -4,6 +4,8 @@ namespace Hyde\Framework\Testing\Unit; +use Hyde\Foundation\HydeKernel; +use Hyde\Hyde; use Hyde\Markdown\Processing\HeadingRenderer; use Hyde\Pages\DocumentationPage; use Hyde\Pages\MarkdownPage; @@ -176,6 +178,26 @@ public function testCanAddPermalinkWithCustomPageClasses(): void } public function testCanonicalPermalinkConfigurationAppliesToPageSubclasses(): void + { + $kernel = Hyde::kernel(); + HydeKernel::setInstance(new HydeKernel()); + + self::mockConfig([ + 'markdown.permalinks.pages' => [DocumentationPage::class], + ]); + + try { + Hyde::replacePageClass(DocumentationPage::class, ReplacementDocumentationPage::class); + + $renderer = new HeadingRenderer(ReplacementDocumentationPage::class); + + $this->assertTrue($renderer->canAddPermalink('Test Content', 2)); + } finally { + HydeKernel::setInstance($kernel); + } + } + + public function testCanonicalPermalinkConfigurationDoesNotApplyToUnregisteredPageSubclasses(): void { self::mockConfig([ 'markdown.permalinks.pages' => [DocumentationPage::class], @@ -183,7 +205,7 @@ public function testCanonicalPermalinkConfigurationAppliesToPageSubclasses(): vo $renderer = new HeadingRenderer(ReplacementDocumentationPage::class); - $this->assertTrue($renderer->canAddPermalink('Test Content', 2)); + $this->assertFalse($renderer->canAddPermalink('Test Content', 2)); } public function testPostProcessMethodNormalizesInputToMatchCommonMark() From f894e4f9ac1a4a97c7280309ea6570322ca0551a Mon Sep 17 00:00:00 2001 From: Emma De Silva Date: Sun, 6 Sep 2026 16:13:29 +0200 Subject: [PATCH 05/14] Resolve sitemap page types without broad subclass matching --- .../XmlGenerators/SitemapGenerator.php | 36 +++++++++---------- .../Feature/PageClassReplacementTest.php | 9 ----- .../Feature/Services/SitemapServiceTest.php | 33 +++++++++++++++++ 3 files changed, 50 insertions(+), 28 deletions(-) diff --git a/packages/framework/src/Framework/Features/XmlGenerators/SitemapGenerator.php b/packages/framework/src/Framework/Features/XmlGenerators/SitemapGenerator.php index 8a13303510b..6edd3f429dd 100644 --- a/packages/framework/src/Framework/Features/XmlGenerators/SitemapGenerator.php +++ b/packages/framework/src/Framework/Features/XmlGenerators/SitemapGenerator.php @@ -19,8 +19,9 @@ use Hyde\Pages\DocumentationPage; use Hyde\Foundation\Facades\Routes; -use function is_a; +use function array_map; use function date; +use function in_array; /** * @see https://www.sitemaps.org/protocol.html @@ -72,7 +73,11 @@ protected function generatePriority(string $pageClass, string $identifier): stri { $priority = 0.5; - if ($this->isPageType($pageClass, [BladePage::class, MarkdownPage::class, DocumentationPage::class])) { + if (in_array($pageClass, array_map(Hyde::resolvePageClass(...), [ + BladePage::class, + MarkdownPage::class, + DocumentationPage::class, + ]), true)) { $priority = 0.9; if ($identifier === 'index') { @@ -80,7 +85,11 @@ protected function generatePriority(string $pageClass, string $identifier): stri } } - if ($this->isPageType($pageClass, [MarkdownPost::class, HtmlPage::class]) || $pageClass === InMemoryPage::class) { + if (in_array($pageClass, array_map(Hyde::resolvePageClass(...), [ + MarkdownPost::class, + InMemoryPage::class, + HtmlPage::class, + ]), true)) { $priority = 0.75; } @@ -99,7 +108,11 @@ protected function generateChangeFrequency(string $pageClass, string $identifier { $frequency = 'weekly'; - if ($this->isPageType($pageClass, [BladePage::class, MarkdownPage::class, DocumentationPage::class])) { + if (in_array($pageClass, array_map(Hyde::resolvePageClass(...), [ + BladePage::class, + MarkdownPage::class, + DocumentationPage::class, + ]), true)) { $frequency = 'daily'; } @@ -115,19 +128,4 @@ protected function getRouteInformation(Route $route): array { return [$route->getPageClass(), $route->getPage()->getIdentifier()]; } - - /** - * @param class-string<\Hyde\Pages\Concerns\HydePage> $pageClass - * @param array> $pageTypes - */ - private function isPageType(string $pageClass, array $pageTypes): bool - { - foreach ($pageTypes as $pageType) { - if (is_a($pageClass, $pageType, true)) { - return true; - } - } - - return false; - } } diff --git a/packages/framework/tests/Feature/PageClassReplacementTest.php b/packages/framework/tests/Feature/PageClassReplacementTest.php index decd9957f7e..f4a258201e4 100644 --- a/packages/framework/tests/Feature/PageClassReplacementTest.php +++ b/packages/framework/tests/Feature/PageClassReplacementTest.php @@ -5,7 +5,6 @@ namespace Hyde\Framework\Testing\Feature; use Hyde\Framework\HydeServiceProvider; -use Hyde\Framework\Features\XmlGenerators\SitemapGenerator; use Hyde\Hyde; use Hyde\Pages\MarkdownPost; use Hyde\Testing\TestCase; @@ -54,14 +53,6 @@ public function testServiceProviderCanReplaceADiscoveredPageClassBeforeKernelBoo $this->assertSame('posts/hello-world', $page->getRouteKey()); $this->assertSame('_posts', TestMarkdownPost::sourceDirectory()); $this->assertSame('posts', TestMarkdownPost::outputDirectory()); - - $sitemap = (new SitemapGenerator())->generate()->getXmlElement(); - $postEntry = collect($sitemap->url)->first( - fn ($url): bool => (string) $url->loc === 'posts/hello-world.html' - ); - - $this->assertNotNull($postEntry); - $this->assertSame('0.75', (string) $postEntry->priority); } public function testCanonicalAndReplacementStaticQueriesUseTheDiscoveredSubclass() diff --git a/packages/framework/tests/Feature/Services/SitemapServiceTest.php b/packages/framework/tests/Feature/Services/SitemapServiceTest.php index 8c02c5afe1d..0eb92344012 100644 --- a/packages/framework/tests/Feature/Services/SitemapServiceTest.php +++ b/packages/framework/tests/Feature/Services/SitemapServiceTest.php @@ -11,6 +11,7 @@ use Hyde\Framework\Features\XmlGenerators\SitemapGenerator; use Hyde\Hyde; use Hyde\Pages\InMemoryPage; +use Hyde\Pages\MarkdownPost; use Hyde\Support\Models\Route; use Hyde\Testing\TestCase; use Hyde\Foundation\HydeKernel; @@ -71,6 +72,34 @@ public function testGenerateAddsMarkdownPostsToXml() Filesystem::unlink('_posts/foo.md'); } + public function testPageClassReplacementsRetainSitemapMetadata() + { + Hyde::replacePageClass(MarkdownPost::class, SitemapReplacementMarkdownPost::class); + $this->markdown('_posts/2024-01-02-hello-world.md', 'Hello world'); + + $sitemap = (new SitemapGenerator())->generate()->getXmlElement(); + $postEntry = collect($sitemap->url)->first( + fn ($url): bool => (string) $url->loc === 'posts/hello-world.html' + ); + + $this->assertNotNull($postEntry); + $this->assertSame('0.75', (string) $postEntry->priority); + } + + public function testUnregisteredPageSubclassesUseDefaultSitemapMetadata() + { + Routes::addRoute(new Route(new SitemapReplacementMarkdownPost('custom'))); + + $sitemap = (new SitemapGenerator())->generate()->getXmlElement(); + $postEntry = collect($sitemap->url)->first( + fn ($url): bool => (string) $url->loc === 'posts/custom.html' + ); + + $this->assertNotNull($postEntry); + $this->assertSame('weekly', (string) $postEntry->changefreq); + $this->assertSame('0.5', (string) $postEntry->priority); + } + public function testGenerateDoesNotAddConfiguredRedirectsToXml() { config(['hyde.redirects' => ['old-page' => 'new-page']]); @@ -267,3 +296,7 @@ public function testLinksFallbackToRelativeLinksWhenSiteUrlIsLocalhost() $this->assertEquals('index.html', $service->getXmlElement()->url[1]->loc); } } + +class SitemapReplacementMarkdownPost extends MarkdownPost +{ +} From 7f35cbcfdc953b1033998c620660a3b454516839 Mon Sep 17 00:00:00 2001 From: Emma De Silva Date: Sun, 6 Sep 2026 16:15:48 +0200 Subject: [PATCH 06/14] Document replacement constructor compatibility --- docs/architecture-concepts/page-models.md | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/docs/architecture-concepts/page-models.md b/docs/architecture-concepts/page-models.md index 1953db40aab..1cfacd4786b 100644 --- a/docs/architecture-concepts/page-models.md +++ b/docs/architecture-concepts/page-models.md @@ -146,6 +146,7 @@ class AppServiceProvider extends ServiceProvider ``` Hyde then uses `MyMarkdownPost` when discovering and parsing Markdown posts. The replacement must extend the original -page class. Replacement classes are intended to customize page behavior; changing their filesystem or routing -configuration is not supported. Use Hyde's existing configuration options to customize source and output directories. -Normal `instanceof MarkdownPost` checks continue to work. +page class and keep its constructor compatible with the named arguments Hyde uses when parsing that page type. +Replacement classes are intended to customize page behavior; changing their filesystem or routing configuration is not +supported. Use Hyde's existing configuration options to customize source and output directories. Normal +`instanceof MarkdownPost` checks continue to work. From cb7fc2774d905b475d3c6594bdca850ca60fd661 Mon Sep 17 00:00:00 2001 From: Emma De Silva Date: Sun, 6 Sep 2026 16:23:22 +0200 Subject: [PATCH 07/14] Explain polymorphic file queries as collection consistency --- HYDEPHP_V3_PLANNING.md | 2 +- UPGRADE.md | 7 ++++--- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/HYDEPHP_V3_PLANNING.md b/HYDEPHP_V3_PLANNING.md index 7f917a1e2d6..de68185a1a5 100644 --- a/HYDEPHP_V3_PLANNING.md +++ b/HYDEPHP_V3_PLANNING.md @@ -58,7 +58,7 @@ Having this document in code lets us know the devlopment state at any given poin ### Breaking Changes -- `FileCollection::getFiles($pageClass)` now includes files assigned to subclasses of the requested page class, matching the existing polymorphic behavior of `PageCollection::getPages()`. Custom extensions that register both a parent page class and its subclass should filter on `$file->pageClass` when they specifically need exact-class results. +- `FileCollection::getFiles($pageClass)` now uses the same polymorphic page-class filtering as `PageCollection::getPages()` and `RouteCollection::getRoutes()`, so querying a parent page class includes files assigned to its subclasses. Custom extensions that register both a parent page class and its subclass should filter on `$file->pageClass` when they specifically need exact-class results. - Renamed the static page class property `$fileExtension` to `$sourceExtension`, and the `fileExtension()` and `setFileExtension()` methods to `sourceExtension()` and `setSourceExtension()`, making it explicit that these APIs describe source files. Custom page classes and code calling these APIs need the mechanical rename, which the planned automated upgrade script will handle (see the upgrade script rules section at the end of this document). - Removed the `GenerateSitemap` post-build task, as the sitemap is now generated through the page and route system. Sites that just enable or disable the sitemap through configuration are unaffected. Code referencing the task class — like a user-land `GenerateSitemap` build task relying on the same-basename override mechanism to replace the framework task — should bind a custom `SitemapGenerator` in the container instead. The `build:sitemap` command now compiles the registered page, and fails with an error (exit code 1 instead of 3) when the sitemap cannot be generated — because no base URL is configured or it is disabled in the configuration — instead of generating it anyway in the latter case. - Removed the `GenerateRssFeed` post-build task, as the RSS feed is now generated through the page and route system. Sites that just enable or disable the feed through configuration are unaffected. Code referencing the task class — like a user-land `GenerateRssFeed` build task relying on the same-basename override mechanism to replace the framework task — should bind a custom `RssFeedGenerator` in the container instead. The `build:rss` command now compiles the registered page, and fails with an error when the feed cannot be generated (no base URL, disabled in the configuration, or no Markdown posts), instead of silently generating an empty feed. diff --git a/UPGRADE.md b/UPGRADE.md index 34a01be7187..52721eea623 100644 --- a/UPGRADE.md +++ b/UPGRADE.md @@ -371,9 +371,10 @@ reflection, and string-based access — must be updated manually. ### Review Exact Page-Class File Queries -`FileCollection::getFiles($pageClass)` now includes files assigned to subclasses of the requested page class, matching -`PageCollection::getPages()`. This only affects custom extensions that register or add files for both a parent page -class and its subclass. If such code needs exact-class results, filter the collection explicitly: +`FileCollection::getFiles($pageClass)` now uses the same polymorphic page-class filtering as +`PageCollection::getPages()` and `RouteCollection::getRoutes()`. Querying a parent page class therefore includes files +assigned to its subclasses. This only affects custom extensions that register or add files for both a parent page class +and its subclass. If such code needs exact-class results, filter the collection explicitly: ```php use App\Pages\CustomPage; From af0b63e1024c378bfa2f254216fefd8907ccb76e Mon Sep 17 00:00:00 2001 From: Emma De Silva Date: Sun, 6 Sep 2026 16:23:58 +0200 Subject: [PATCH 08/14] Make page class resolution a supported extension API --- .../hyde-pages-api/hyde-kernel-extensions-methods.md | 6 ++++-- docs/architecture-concepts/extensions-api.md | 9 +++++++-- docs/architecture-concepts/the-hydekernel.md | 6 ++++-- .../src/Foundation/Concerns/ManagesExtensions.php | 5 ++++- 4 files changed, 19 insertions(+), 7 deletions(-) diff --git a/docs/_data/partials/hyde-pages-api/hyde-kernel-extensions-methods.md b/docs/_data/partials/hyde-pages-api/hyde-kernel-extensions-methods.md index ea3338b9bcb..692bc3d267b 100644 --- a/docs/_data/partials/hyde-pages-api/hyde-kernel-extensions-methods.md +++ b/docs/_data/partials/hyde-pages-api/hyde-kernel-extensions-methods.md @@ -1,7 +1,7 @@
- + #### `registerExtension()` @@ -68,7 +68,9 @@ Hyde::replacePageClass(class-string<HydePage> $original, class-string<H #### `resolvePageClass()` -No description provided. +Resolve a page class to its registered replacement, if any. + +Custom extension discovery handlers should resolve page classes before assigning them to source files or constructing pages so application replacements are honored. ```php Hyde::resolvePageClass(class-string<HydePage> $pageClass): class-string diff --git a/docs/architecture-concepts/extensions-api.md b/docs/architecture-concepts/extensions-api.md index 0c66d0e1be8..96a3f6b2a04 100644 --- a/docs/architecture-concepts/extensions-api.md +++ b/docs/architecture-concepts/extensions-api.md @@ -119,14 +119,19 @@ These callbacks provide powerful hooks into the Hyde system, allowing your exten Let's go crazy and implement a discovery handler to collect `JsonPage` files from an external API! We will do this by implementing the `discoverPages` method in our extension class, and from there inject pages retrieved from our API. +Custom handlers should resolve registered page classes before assigning them to source files or constructing pages so +applications can replace third-party page classes. ```php +use Hyde\Hyde; + class JsonPageExtension extends HydeExtension { public function discoverPages(PageCollection $collection): void { $pages = Http::get('https://example.com/my-api')->collect(); + $pageClass = Hyde::resolvePageClass(JsonPage::class); - $pages->each(function (array $page) use ($collection): void { - $collection->addPage(JsonPage::fromArray($page)); + $pages->each(function (array $page) use ($collection, $pageClass): void { + $collection->addPage($pageClass::fromArray($page)); }); } } diff --git a/docs/architecture-concepts/the-hydekernel.md b/docs/architecture-concepts/the-hydekernel.md index 5991b89fb05..bde3fa9b591 100644 --- a/docs/architecture-concepts/the-hydekernel.md +++ b/docs/architecture-concepts/the-hydekernel.md @@ -422,7 +422,7 @@ Hyde::getMediaOutputDirectory(): string
- + #### `registerExtension()` @@ -489,7 +489,9 @@ Hyde::replacePageClass(class-string<HydePage> $original, class-string<H #### `resolvePageClass()` -No description provided. +Resolve a page class to its registered replacement, if any. + +Custom extension discovery handlers should resolve page classes before assigning them to source files or constructing pages so application replacements are honored. ```php Hyde::resolvePageClass(class-string<HydePage> $pageClass): class-string diff --git a/packages/framework/src/Foundation/Concerns/ManagesExtensions.php b/packages/framework/src/Foundation/Concerns/ManagesExtensions.php index df51f450656..b58e42d1873 100644 --- a/packages/framework/src/Foundation/Concerns/ManagesExtensions.php +++ b/packages/framework/src/Foundation/Concerns/ManagesExtensions.php @@ -156,7 +156,10 @@ public function replacePageClass(string $original, string $replacement): void } /** - * @internal Resolve a canonical page class to the class used at runtime. + * Resolve a page class to its registered replacement, if any. + * + * Custom extension discovery handlers should resolve page classes before assigning + * them to source files or constructing pages so application replacements are honored. * * @param class-string $pageClass * @return class-string From ed2a64d473c73f542b692290f0ef12edb9584466 Mon Sep 17 00:00:00 2001 From: Emma De Silva Date: Sun, 6 Sep 2026 16:24:27 +0200 Subject: [PATCH 09/14] Cover replacements across versioned documentation --- .../VersionedDocumentationDiscoveryTest.php | 26 +++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/packages/framework/tests/Feature/VersionedDocumentationDiscoveryTest.php b/packages/framework/tests/Feature/VersionedDocumentationDiscoveryTest.php index 0e7e1283668..dcae82ee85c 100644 --- a/packages/framework/tests/Feature/VersionedDocumentationDiscoveryTest.php +++ b/packages/framework/tests/Feature/VersionedDocumentationDiscoveryTest.php @@ -21,6 +21,28 @@ #[\PHPUnit\Framework\Attributes\CoversClass(DocumentationVersions::class)] class VersionedDocumentationDiscoveryTest extends VersionedDocumentationTestCase { + public function testDocumentationPageReplacementWorksAcrossVersionedDocumentation() + { + $this->enableVersions(); + Hyde::replacePageClass(DocumentationPage::class, ReplacementVersionedDocumentationPage::class); + + $this->file('_docs/1.x/index.md'); + $this->file('_docs/1.x/installation.md'); + $this->file('_docs/2.x/index.md'); + $this->file('_docs/2.x/upgrading.md'); + + $this->rediscoverPages(); + + $pages = Hyde::pages()->getPages(DocumentationPage::class); + + $this->assertCount(4, $pages); + $this->assertContainsOnlyInstancesOf(ReplacementVersionedDocumentationPage::class, $pages); + $this->assertInstanceOf(ReplacementVersionedDocumentationPage::class, DocumentationPage::get('1.x/installation')); + $this->assertInstanceOf(ReplacementVersionedDocumentationPage::class, DocumentationPage::get('2.x/upgrading')); + $this->assertSame(['docs/1.x/installation'], $this->menuRouteKeys($this->sidebar('1.x'))); + $this->assertSame(['docs/2.x/upgrading'], $this->menuRouteKeys($this->sidebar('2.x'))); + } + public function testVersionedPagesAreDiscoveredWithVersionedRouteKeys() { $this->enableVersions(); @@ -116,3 +138,7 @@ public function testVersionedDocumentationUsesCustomDocumentationOutputDirectory } } } + +class ReplacementVersionedDocumentationPage extends DocumentationPage +{ +} From e29e927217e3bddd0b93ae72cc1178f686be8f3b Mon Sep 17 00:00:00 2001 From: Emma De Silva Date: Sun, 6 Sep 2026 16:24:47 +0200 Subject: [PATCH 10/14] Spell out replacement constructor requirements --- docs/architecture-concepts/page-models.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/architecture-concepts/page-models.md b/docs/architecture-concepts/page-models.md index 1cfacd4786b..9e50362545d 100644 --- a/docs/architecture-concepts/page-models.md +++ b/docs/architecture-concepts/page-models.md @@ -146,7 +146,8 @@ class AppServiceProvider extends ServiceProvider ``` Hyde then uses `MyMarkdownPost` when discovering and parsing Markdown posts. The replacement must extend the original -page class and keep its constructor compatible with the named arguments Hyde uses when parsing that page type. +page class. Do not change its constructor signature: Hyde passes `identifier`, `matter`, and `markdown` to Markdown page +replacements, `identifier` and `matter` to Blade page replacements, and a positional identifier to other page types. Replacement classes are intended to customize page behavior; changing their filesystem or routing configuration is not supported. Use Hyde's existing configuration options to customize source and output directories. Normal `instanceof MarkdownPost` checks continue to work. From 02dfcce9ec68864c959980c8b585a27be8fc36d0 Mon Sep 17 00:00:00 2001 From: Emma De Silva Date: Sun, 6 Sep 2026 16:25:01 +0200 Subject: [PATCH 11/14] Clarify sitemap metadata expectations for subclasses --- packages/framework/tests/Feature/Services/SitemapServiceTest.php | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/framework/tests/Feature/Services/SitemapServiceTest.php b/packages/framework/tests/Feature/Services/SitemapServiceTest.php index 0eb92344012..ae0706429cd 100644 --- a/packages/framework/tests/Feature/Services/SitemapServiceTest.php +++ b/packages/framework/tests/Feature/Services/SitemapServiceTest.php @@ -88,6 +88,7 @@ public function testPageClassReplacementsRetainSitemapMetadata() public function testUnregisteredPageSubclassesUseDefaultSitemapMetadata() { + // Only registered replacements inherit canonical sitemap metadata; unrelated subclasses keep the defaults. Routes::addRoute(new Route(new SitemapReplacementMarkdownPost('custom'))); $sitemap = (new SitemapGenerator())->generate()->getXmlElement(); From b10d94be6ac6f58c69b60e1a332d634d857be679 Mon Sep 17 00:00:00 2001 From: Emma De Silva Date: Sun, 6 Sep 2026 20:19:53 +0200 Subject: [PATCH 12/14] Resolve permalink page classes once per renderer --- .../src/Markdown/Processing/HeadingRenderer.php | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/packages/framework/src/Markdown/Processing/HeadingRenderer.php b/packages/framework/src/Markdown/Processing/HeadingRenderer.php index c8bb10351b3..787a066d370 100644 --- a/packages/framework/src/Markdown/Processing/HeadingRenderer.php +++ b/packages/framework/src/Markdown/Processing/HeadingRenderer.php @@ -30,11 +30,21 @@ class HeadingRenderer implements NodeRendererInterface /** @var array */ protected array $headingRegistry = []; + /** @var array> */ + protected array $permalinkPageClasses = []; + /** @param ?class-string<\Hyde\Pages\Concerns\HydePage> $pageClass */ public function __construct(?string $pageClass = null, array &$headingRegistry = []) { $this->pageClass = $pageClass; $this->headingRegistry = &$headingRegistry; + + if ($pageClass !== null) { + $this->permalinkPageClasses = array_map( + Hyde::resolvePageClass(...), + config('markdown.permalinks.pages', [DocumentationPage::class]) + ); + } } public function render(Node $node, ChildNodeRendererInterface $childRenderer): string @@ -64,10 +74,7 @@ public function canAddPermalink(string $content, int $level): bool && $level <= config('markdown.permalinks.max_level', 6) && ! str_contains($content, 'class="heading-permalink"') && $this->pageClass !== null - && in_array($this->pageClass, array_map( - Hyde::resolvePageClass(...), - config('markdown.permalinks.pages', [DocumentationPage::class]) - ), true); + && in_array($this->pageClass, $this->permalinkPageClasses, true); } /** @internal */ From 8d497a87fd066710b4db8e74a6a8b22e70fb4330 Mon Sep 17 00:00:00 2001 From: Emma De Silva Date: Sun, 6 Sep 2026 21:50:17 +0200 Subject: [PATCH 13/14] Order extension concern function imports --- .../framework/src/Foundation/Concerns/ManagesExtensions.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/framework/src/Foundation/Concerns/ManagesExtensions.php b/packages/framework/src/Foundation/Concerns/ManagesExtensions.php index b58e42d1873..760d7f727c2 100644 --- a/packages/framework/src/Foundation/Concerns/ManagesExtensions.php +++ b/packages/framework/src/Foundation/Concerns/ManagesExtensions.php @@ -8,8 +8,8 @@ use Hyde\Pages\Concerns\HydePage; use InvalidArgumentException; -use function array_map; use function array_keys; +use function array_map; use function array_merge; use function array_unique; use function array_values; From 9c5c7927a60e983b0d784edf4e6e024dcdb7317c Mon Sep 17 00:00:00 2001 From: Emma De Silva Date: Sun, 6 Sep 2026 21:50:22 +0200 Subject: [PATCH 14/14] Keep heading renderer kernel swaps exception safe --- .../framework/tests/Unit/HeadingRendererUnitTest.php | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/framework/tests/Unit/HeadingRendererUnitTest.php b/packages/framework/tests/Unit/HeadingRendererUnitTest.php index a372a98bd7e..3ffe4cf0b51 100644 --- a/packages/framework/tests/Unit/HeadingRendererUnitTest.php +++ b/packages/framework/tests/Unit/HeadingRendererUnitTest.php @@ -180,13 +180,13 @@ public function testCanAddPermalinkWithCustomPageClasses(): void public function testCanonicalPermalinkConfigurationAppliesToPageSubclasses(): void { $kernel = Hyde::kernel(); - HydeKernel::setInstance(new HydeKernel()); - - self::mockConfig([ - 'markdown.permalinks.pages' => [DocumentationPage::class], - ]); try { + HydeKernel::setInstance(new HydeKernel()); + self::mockConfig([ + 'markdown.permalinks.pages' => [DocumentationPage::class], + ]); + Hyde::replacePageClass(DocumentationPage::class, ReplacementDocumentationPage::class); $renderer = new HeadingRenderer(ReplacementDocumentationPage::class);