diff --git a/HYDEPHP_V3_PLANNING.md b/HYDEPHP_V3_PLANNING.md
index eed8207afc1..de68185a1a5 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 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.
@@ -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..52721eea623 100644
--- a/UPGRADE.md
+++ b/UPGRADE.md
@@ -369,6 +369,23 @@ 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 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;
+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 +468,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..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()`
@@ -53,6 +53,29 @@ 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()`
+
+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/page-models.md b/docs/architecture-concepts/page-models.md
index e5eedbab6ab..9e50362545d 100644
--- a/docs/architecture-concepts/page-models.md
+++ b/docs/architecture-concepts/page-models.md
@@ -104,3 +104,50 @@ 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. 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.
diff --git a/docs/architecture-concepts/the-hydekernel.md b/docs/architecture-concepts/the-hydekernel.md
index 6f90671bdb9..bde3fa9b591 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,29 @@ 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()`
+
+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 52de766e3cf..760d7f727c2 100644
--- a/packages/framework/src/Foundation/Concerns/ManagesExtensions.php
+++ b/packages/framework/src/Foundation/Concerns/ManagesExtensions.php
@@ -5,13 +5,16 @@
namespace Hyde\Foundation\Concerns;
use BadMethodCallException;
+use Hyde\Pages\Concerns\HydePage;
use InvalidArgumentException;
use function array_keys;
use function array_map;
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;
/**
@@ -103,9 +106,66 @@ 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_values(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;
+ }
+
+ /**
+ * 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
+ */
+ 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..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 in_array;
+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 (in_array($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 (in_array($pageClass, [MarkdownPost::class, InMemoryPage::class, HtmlPage::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 (in_array($pageClass, [BladePage::class, MarkdownPage::class, DocumentationPage::class])) {
+ if (in_array($pageClass, array_map(Hyde::resolvePageClass(...), [
+ BladePage::class,
+ MarkdownPage::class,
+ DocumentationPage::class,
+ ]), true)) {
$frequency = 'daily';
}
diff --git a/packages/framework/src/Markdown/Processing/HeadingRenderer.php b/packages/framework/src/Markdown/Processing/HeadingRenderer.php
index d4eedced24f..787a066d370 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,6 +12,9 @@
use League\CommonMark\Renderer\ChildNodeRendererInterface;
use League\CommonMark\Renderer\NodeRendererInterface;
+use function array_map;
+use function in_array;
+
/**
* Renders a heading node, and supports built-in permalink generation.
*
@@ -26,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
@@ -59,7 +73,8 @@ 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
+ && in_array($this->pageClass, $this->permalinkPageClasses, 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..f4a258201e4
--- /dev/null
+++ b/packages/framework/tests/Feature/PageClassReplacementTest.php
@@ -0,0 +1,113 @@
+ 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());
+ }
+
+ 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/Services/SitemapServiceTest.php b/packages/framework/tests/Feature/Services/SitemapServiceTest.php
index 8c02c5afe1d..ae0706429cd 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,35 @@ 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()
+ {
+ // Only registered replacements inherit canonical sitemap metadata; unrelated subclasses keep the defaults.
+ 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 +297,7 @@ public function testLinksFallbackToRelativeLinksWhenSiteUrlIsLocalhost()
$this->assertEquals('index.html', $service->getXmlElement()->url[1]->loc);
}
}
+
+class SitemapReplacementMarkdownPost extends MarkdownPost
+{
+}
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/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
+{
+}
diff --git a/packages/framework/tests/Unit/ExtensionsUnitTest.php b/packages/framework/tests/Unit/ExtensionsUnitTest.php
index 966612fd4d1..0986a219cc2 100644
--- a/packages/framework/tests/Unit/ExtensionsUnitTest.php
+++ b/packages/framework/tests/Unit/ExtensionsUnitTest.php
@@ -203,6 +203,105 @@ 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([
+ HtmlPage::class,
+ BladePage::class,
+ MarkdownPage::class,
+ ReplacementMarkdownPost::class,
+ DocumentationPage::class,
+ ReplaceableExtensionPage::class,
+ ], $this->kernel->getRegisteredPageClasses());
+ }
+
+ 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 +389,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, ReplaceableExtensionPage::class];
+ }
+}
diff --git a/packages/framework/tests/Unit/HeadingRendererUnitTest.php b/packages/framework/tests/Unit/HeadingRendererUnitTest.php
index e6e271e0298..3ffe4cf0b51 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;
@@ -175,6 +177,37 @@ public function testCanAddPermalinkWithCustomPageClasses(): void
$this->assertTrue($renderer->canAddPermalink('Test Content', 2));
}
+ public function testCanonicalPermalinkConfigurationAppliesToPageSubclasses(): void
+ {
+ $kernel = Hyde::kernel();
+
+ try {
+ HydeKernel::setInstance(new HydeKernel());
+ self::mockConfig([
+ 'markdown.permalinks.pages' => [DocumentationPage::class],
+ ]);
+
+ 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],
+ ]);
+
+ $renderer = new HeadingRenderer(ReplacementDocumentationPage::class);
+
+ $this->assertFalse($renderer->canAddPermalink('Test Content', 2));
+ }
+
public function testPostProcessMethodNormalizesInputToMatchCommonMark()
{
// Actual HTML output returned from Blade
@@ -303,3 +336,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
+{
+}