From 93c21d051cfe1af915fa60fdd39942c694f71ac8 Mon Sep 17 00:00:00 2001 From: Emma De Silva Date: Sat, 11 Jul 2026 05:25:57 +0200 Subject: [PATCH 01/52] Add design document for versioned documentation pages This doubles as a handoff document tracking implementation progress for the native versioned documentation feature. Co-Authored-By: Claude Fable 5 --- .../docs/versioned-documentation-design.md | 147 ++++++++++++++++++ 1 file changed, 147 insertions(+) create mode 100644 monorepo/docs/versioned-documentation-design.md diff --git a/monorepo/docs/versioned-documentation-design.md b/monorepo/docs/versioned-documentation-design.md new file mode 100644 index 00000000000..6e3cbed9797 --- /dev/null +++ b/monorepo/docs/versioned-documentation-design.md @@ -0,0 +1,147 @@ +# Versioned Documentation Pages — Design & Implementation Handoff + +**Branch:** `v3/support-versioned-documentation-pages` (targets `2.x` a.k.a. the v3 dev line) +**Status:** In progress — see the checklist at the bottom for exactly where work stands. + +This document serves two purposes: it records the design decisions for native versioned +documentation support in HydePHP v3, and it is a handoff file so that anyone (human or agent) +can resume the implementation mid-way with full context. + +## Feature summary + +Native support for versioned documentation pages, replacing the userland workaround used on +HydePHP.com (duplicated `DocumentationPage` subclasses, class-filtered sidebars, and hand-rolled +per-version search pages). + +The design is **filesystem-first**: versions are subdirectories of `_docs` (e.g. `_docs/1.x`, +`_docs/2.x`), compiled to matching subdirectories of the docs output directory (e.g. `docs/1.x`). +A version registry in core drives routing, sidebars, search, the version switcher UI, and the +docs-root redirect. Single-version sites (the default) are completely unaffected. + +## Configuration API (`config/docs.php`) + +```php +// Empty array (default) = versioning disabled, everything behaves as in v2. +'versions' => [ + // '1.x', + // '2.x', +], + +// Optional. Defaults to the last entry in the versions list (append-newest workflow). +'default_version' => null, +``` + +Version names must match `/^[a-zA-Z0-9][a-zA-Z0-9._-]*$/` (no slashes); violations throw +`InvalidConfigurationException`. The default version must be in the versions list. + +## Architecture + +New namespace: `Hyde\Framework\Features\Documentation\Versioning` + +- **`DocumentationVersion`** — readonly value object: `name`, `isDefault()`, `routeKeyPrefix()` + (e.g. `docs/1.x`), `homeRouteName()` (e.g. `docs/1.x/index`), `getHomeRoute()`. +- **`DocumentationVersions`** — static registry over config: `enabled()`, `all()`, + `get(string $name)`, `getOrFail()`, `default()`, `fromIdentifier(string $identifier)` + (maps a page identifier's first path segment to a registered version, or null), + `getEquivalentRoute(DocumentationVersion $target, DocumentationPage $page)` (same logical page + in another version, null when it does not exist there). +- **`HasDocumentationVersion`** — interface with `getDocumentationVersion(): ?DocumentationVersion`, + implemented by `DocumentationPage` and the versioned search pages, so the sidebar/search views + can resolve the version of whatever page is being rendered (docs pages *and* in-memory search + pages) without class checks. + +### Key decisions and their rationale + +1. **Version identity comes from the identifier's first path segment**, checked against the + registry. A directory `_docs/getting-started` is *not* a version unless listed in config, so + mixed layouts and gradual migrations keep working. Pages outside a version directory while + versioning is enabled remain plain docs pages (routed as before, excluded from version + sidebars/search shards). +2. **No new page class.** `DocumentationPage` itself becomes version-aware. A separate + `VersionedDocumentationPage` would force users (and Hyde internals: `Routes::getRoutes()`, + navigation, factories) to handle two classes. HydePHP.com's pain came precisely from + class-based version identity. +3. **Flattened output paths keep the version prefix.** With `docs.flattened_output_paths: true` + (default), `_docs/2.x/getting-started/installation.md` → route key `docs/2.x/installation`. + The version segment survives flattening; only the intra-version structure flattens. This keeps + HydePHP.com-style URLs and avoids cross-version collisions. +4. **`DocumentationPage::homeRouteName()` returns the default version's home** (`docs/2.x/index`) + when versioning is enabled and no explicit version is passed. All existing consumers (main nav + "Docs" link, labels, priorities) then do the right thing automatically. It accepts an optional + `DocumentationVersion|string` parameter for per-version homes. +5. **Sidebar config keys are version-agnostic by default.** `docs.sidebar.order/labels/exclude` + and `docs.exclude_from_search` entries match both the full identifier (`2.x/readme`) and the + version-stripped identifier (`readme`), so one config applies to all versions, with + version-specific overrides possible via the full identifier. +6. **Automatic sidebar grouping skips the version segment.** `2.x/getting-started/foo` groups + under `getting-started`; `2.x/foo` is ungrouped (top level of the version). +7. **Per-version sidebars are container singletons** — `navigation.sidebar.{version}` registered + in `NavigationServiceProvider` alongside the existing `navigation.sidebar` (which, when + versioning is on, holds the default version's sidebar). `DocumentationSidebar::get()` picks + the right one from the current render context. The sidebar Blade view now calls + `DocumentationSidebar::get()` instead of `app('navigation.sidebar')`. +8. **Per-version search shards.** `HydeCoreExtension::discoverPages()` registers one + `DocumentationSearchIndex` + `DocumentationSearchPage` per version (`docs/2.x/search.json`, + `docs/2.x/search.html`). `GeneratesDocumentationSearchIndex` takes an optional version and + filters pages to it. The `hyde-search` component resolves the index path from the rendered + page's version. No root `docs/search.json` is generated when versioning is enabled. +9. **Docs root redirect.** When versioning is enabled, an `InMemoryPage`-based redirect is + registered at `docs/index` pointing at the default version's home, unless the user has their + own page at that route key. Version pages remain self-canonical; no hreflang, no duplicate + HTML at the root. +10. **Version switcher** is a Blade component (`hyde::components.docs.version-switcher`) rendered + in the sidebar header when versioning is enabled. Alpine dropdown with real anchor links; + links to the equivalent page in the target version, falling back to that version's home. + +### Integration points (files touched) + +| File | Change | +| --- | --- | +| `packages/framework/src/Framework/Features/Documentation/Versioning/*` | New classes (registry, value object, interface) | +| `packages/framework/src/Pages/DocumentationPage.php` | `getDocumentationVersion()`, version-preserving flattening, version-aware home helpers | +| `packages/framework/src/Framework/Factories/NavigationDataFactory.php` | Version-stripped identifier in group/label/order/exclude lookups; `homeRouteName()` for default nav order | +| `packages/framework/src/Framework/Features/Navigation/NavigationMenuGenerator.php` | Optional version filter for sidebar generation, version-aware home exclusion and empty-sidebar fallback | +| `packages/framework/src/Framework/Features/Navigation/DocumentationSidebar.php` | Render-context-aware `get()` | +| `packages/framework/src/Foundation/Providers/NavigationServiceProvider.php` | Per-version sidebar singletons | +| `packages/framework/src/Framework/Actions/GeneratesDocumentationSearchIndex.php` | Optional version filter, version-stripped exclude matching | +| `packages/framework/src/Framework/Features/Documentation/DocumentationSearchIndex.php` + `DocumentationSearchPage.php` | Optional version (route key, output path, enabled check) | +| `packages/framework/src/Foundation/HydeCoreExtension.php` | Per-version search page registration + docs root redirect | +| `packages/framework/resources/views/components/docs/sidebar.blade.php` | Use `DocumentationSidebar::get()` | +| `packages/framework/resources/views/components/docs/sidebar-brand.blade.php` | Include version switcher; version-aware home link | +| `packages/framework/resources/views/components/docs/version-switcher.blade.php` | New component | +| `packages/framework/resources/views/components/docs/hyde-search.blade.php` | Version-aware search index path | +| `config/docs.php` | New `versions` + `default_version` settings | + +### Out of scope (documented future enhancements) + +- `noindex` policies for legacy versions (`docs.versions_noindex` or per-version metadata). +- Per-page redirects from unversioned URLs (`docs/foo` → `docs/2.x/foo`) for sites migrating + from unversioned to versioned docs. +- A combined cross-version search index with version facets. +- `make:page --version` flag (you can simply pass the version directory in the page name: + `php hyde make:page "2.x/installation" --docs`). +- Version aliases (`latest`) — can be emulated with a version named `latest` or a redirect. + +## Testing + +- New `packages/framework/tests/Feature/VersionedDocumentationTest.php` — end-to-end feature + coverage (registry, route keys, sidebars, search shards, redirect, switcher). +- Existing suites must stay green with versioning disabled (default) — particularly + `DocumentationPageTest`, `DocumentationSearchIndexTest`, `DocumentationSearchPageTest`, + `AutomaticNavigationConfigurationsTest`, sidebar view tests. +- Run: `vendor/bin/phpunit --testsuite FeatureFramework --filter ` from the repo root. + +## Implementation checklist (update as you go!) + +- [ ] 1. Design document committed (this file) +- [ ] 2. `DocumentationVersion` + `DocumentationVersions` registry + config + tests +- [ ] 3. Version-aware `DocumentationPage` (route keys, output paths, home routes) + tests +- [ ] 4. Version-aware sidebars/navigation (factory, generator, provider, views) + tests +- [ ] 5. Per-version search index + search pages + build:search verification + tests +- [ ] 6. Version switcher component + version-aware search view +- [ ] 7. Docs root redirect +- [ ] 8. Release notes (`HYDEPHP_V3_PLANNING.md`), `UPGRADE.md`, broad test run, final doc pass + +Each step is one commit (or a few small ones). If you are resuming: `git log --oneline` against +this checklist tells you where things stand; the checklist boxes are updated in the same commit +as the work they describe. From 21e9ac09143f60dc468577d4000334e1e32d17e0 Mon Sep 17 00:00:00 2001 From: Emma De Silva Date: Sat, 11 Jul 2026 05:31:37 +0200 Subject: [PATCH 02/52] Add documentation version registry for versioned documentation pages Adds the DocumentationVersion value object, the DocumentationVersions registry reading the new docs.versions and docs.default_version config options, and the HasDocumentationVersion interface. Versioning is disabled by default when the versions list is empty. Co-Authored-By: Claude Fable 5 --- config/docs.php | 25 +++ packages/framework/config/docs.php | 25 +++ .../Versioning/DocumentationVersion.php | 67 +++++++ .../Versioning/DocumentationVersions.php | 164 +++++++++++++++++ .../Versioning/HasDocumentationVersion.php | 20 ++ .../Feature/DocumentationVersionsTest.php | 173 ++++++++++++++++++ 6 files changed, 474 insertions(+) create mode 100644 packages/framework/src/Framework/Features/Documentation/Versioning/DocumentationVersion.php create mode 100644 packages/framework/src/Framework/Features/Documentation/Versioning/DocumentationVersions.php create mode 100644 packages/framework/src/Framework/Features/Documentation/Versioning/HasDocumentationVersion.php create mode 100644 packages/framework/tests/Feature/DocumentationVersionsTest.php diff --git a/config/docs.php b/config/docs.php index e1416fedce9..e5da4d6a4d6 100644 --- a/config/docs.php +++ b/config/docs.php @@ -12,6 +12,31 @@ return [ + /* + |-------------------------------------------------------------------------- + | Documentation Versions + |-------------------------------------------------------------------------- + | + | Hyde supports hosting multiple versions of your documentation side by + | side. Each version listed here maps to a subdirectory of the source + | directory (`_docs/1.x`) compiled to a matching subdirectory of the + | output directory (`docs/1.x`), with its own sidebar and search. + | + | Leave the list empty to disable versioning (the default behaviour). + | + | The default version is linked in the main navigation menu and receives + | the generated `docs/index` redirect page. It defaults to the last + | entry in the list, but can be set explicitly if you prefer. + | + */ + + 'versions' => [ + // '1.x', + // '2.x', + ], + + 'default_version' => null, + /* |-------------------------------------------------------------------------- | Sidebar Settings diff --git a/packages/framework/config/docs.php b/packages/framework/config/docs.php index e1416fedce9..e5da4d6a4d6 100644 --- a/packages/framework/config/docs.php +++ b/packages/framework/config/docs.php @@ -12,6 +12,31 @@ return [ + /* + |-------------------------------------------------------------------------- + | Documentation Versions + |-------------------------------------------------------------------------- + | + | Hyde supports hosting multiple versions of your documentation side by + | side. Each version listed here maps to a subdirectory of the source + | directory (`_docs/1.x`) compiled to a matching subdirectory of the + | output directory (`docs/1.x`), with its own sidebar and search. + | + | Leave the list empty to disable versioning (the default behaviour). + | + | The default version is linked in the main navigation menu and receives + | the generated `docs/index` redirect page. It defaults to the last + | entry in the list, but can be set explicitly if you prefer. + | + */ + + 'versions' => [ + // '1.x', + // '2.x', + ], + + 'default_version' => null, + /* |-------------------------------------------------------------------------- | Sidebar Settings diff --git a/packages/framework/src/Framework/Features/Documentation/Versioning/DocumentationVersion.php b/packages/framework/src/Framework/Features/Documentation/Versioning/DocumentationVersion.php new file mode 100644 index 00000000000..e424dbc31a4 --- /dev/null +++ b/packages/framework/src/Framework/Features/Documentation/Versioning/DocumentationVersion.php @@ -0,0 +1,67 @@ +name; + } + + /** + * Is this the default version? The default version is linked in the main navigation, + * and is the target of the generated documentation root redirect page. + */ + public function isDefault(): bool + { + return $this->isDefault; + } + + /** + * Get the route key prefix for pages belonging to this version. For example, `docs/1.x`. + */ + public function routeKeyPrefix(): string + { + return unslash(DocumentationPage::outputDirectory().'/'.$this->name); + } + + /** + * Get the route key for this version's index page. For example, `docs/1.x/index`. + */ + public function homeRouteName(): string + { + return $this->routeKeyPrefix().'/index'; + } + + /** + * Get the route for this version's index page, if it exists. + */ + public function home(): ?Route + { + return Routes::find($this->homeRouteName()); + } +} diff --git a/packages/framework/src/Framework/Features/Documentation/Versioning/DocumentationVersions.php b/packages/framework/src/Framework/Features/Documentation/Versioning/DocumentationVersions.php new file mode 100644 index 00000000000..34afb0b80ee --- /dev/null +++ b/packages/framework/src/Framework/Features/Documentation/Versioning/DocumentationVersions.php @@ -0,0 +1,164 @@ + 0; + } + + /** + * Get all registered documentation versions, keyed by version name. + * + * @return \Illuminate\Support\Collection + */ + public static function all(): Collection + { + /** @var array $versions */ + $versions = Config::getArray('docs.versions', []); + + $default = self::defaultVersionName($versions); + + return (new Collection($versions))->mapWithKeys(function (string $name) use ($default): array { + return [self::validateVersionName($name) => new DocumentationVersion($name, $name === $default)]; + }); + } + + /** + * Get a registered version by name, or null if it is not registered. + */ + public static function get(string $name): ?DocumentationVersion + { + return self::all()->get($name); + } + + /** + * Get the default documentation version. + * + * This is the version set in the `docs.default_version` configuration, + * falling back to the last entry in the `docs.versions` list. + */ + public static function default(): DocumentationVersion + { + return self::all()->firstOrFail(fn (DocumentationVersion $version): bool => $version->isDefault()); + } + + /** + * Get the version a documentation page identifier belongs to, or null if it does not belong to a registered version. + * + * The version is determined by the first path segment of the identifier, for example `1.x/installation`. + */ + public static function fromIdentifier(string $identifier): ?DocumentationVersion + { + if (! self::enabled() || ! str_contains($identifier, '/')) { + return null; + } + + return self::get(Str::before($identifier, '/')); + } + + /** + * Get the version a route key belongs to, or null if it does not belong to a registered version. + */ + public static function fromRouteKey(string $routeKey): ?DocumentationVersion + { + if (! self::enabled()) { + return null; + } + + return self::all()->first(function (DocumentationVersion $version) use ($routeKey): bool { + return str_starts_with($routeKey, $version->routeKeyPrefix().'/'); + }); + } + + /** + * Get the route for the page equivalent to the given page, but in another version. + * + * Returns null when the given page does not belong to a version, + * or when the equivalent page does not exist in the target version. + */ + public static function getEquivalentRoute(HydePage $page, DocumentationVersion $targetVersion): ?Route + { + $currentVersion = self::fromRouteKey($page->getRouteKey()); + + if ($currentVersion === null) { + return null; + } + + $pathWithinVersion = Str::after($page->getRouteKey(), $currentVersion->routeKeyPrefix().'/'); + + return Routes::find($targetVersion->routeKeyPrefix().'/'.$pathWithinVersion); + } + + /** + * Strip the version prefix from a documentation page identifier, if it has one. + * + * For example, `1.x/getting-started/installation` becomes `getting-started/installation`. + */ + public static function stripVersionPrefix(string $identifier): string + { + $version = self::fromIdentifier($identifier); + + return $version === null ? $identifier : Str::after($identifier, $version->name.'/'); + } + + /** @param array $versions */ + protected static function defaultVersionName(array $versions): ?string + { + if ($versions === []) { + return null; + } + + $default = Config::getNullableString('docs.default_version') ?? $versions[array_key_last($versions)]; + + if (! in_array($default, $versions, true)) { + throw new InvalidConfigurationException(sprintf("The default documentation version '%s' is not present in the `docs.versions` configuration.", $default), 'docs', 'default_version'); + } + + return $default; + } + + protected static function validateVersionName(string $name): string + { + if (! preg_match(self::VERSION_NAME_PATTERN, $name)) { + throw new InvalidConfigurationException(sprintf("Invalid documentation version name '%s'. Version names may only contain alphanumeric characters, dots, dashes, and underscores.", $name), 'docs', 'versions'); + } + + return $name; + } +} diff --git a/packages/framework/src/Framework/Features/Documentation/Versioning/HasDocumentationVersion.php b/packages/framework/src/Framework/Features/Documentation/Versioning/HasDocumentationVersion.php new file mode 100644 index 00000000000..852eee9edbb --- /dev/null +++ b/packages/framework/src/Framework/Features/Documentation/Versioning/HasDocumentationVersion.php @@ -0,0 +1,20 @@ +assertFalse(DocumentationVersions::enabled()); + } + + public function testVersioningIsEnabledWhenVersionsAreRegistered() + { + config(['docs.versions' => ['1.x', '2.x']]); + + $this->assertTrue(DocumentationVersions::enabled()); + } + + public function testAllReturnsEmptyCollectionWhenDisabled() + { + $this->assertTrue(DocumentationVersions::all()->isEmpty()); + } + + public function testAllReturnsRegisteredVersionsKeyedByName() + { + config(['docs.versions' => ['1.x', '2.x']]); + + $versions = DocumentationVersions::all(); + + $this->assertCount(2, $versions); + $this->assertSame(['1.x', '2.x'], $versions->keys()->all()); + $this->assertContainsOnlyInstancesOf(DocumentationVersion::class, $versions); + } + + public function testDefaultVersionIsTheLastEntryByDefault() + { + config(['docs.versions' => ['1.x', '2.x']]); + + $this->assertSame('2.x', DocumentationVersions::default()->name); + $this->assertTrue(DocumentationVersions::get('2.x')->isDefault()); + $this->assertFalse(DocumentationVersions::get('1.x')->isDefault()); + } + + public function testDefaultVersionCanBeSetExplicitly() + { + config(['docs.versions' => ['1.x', '2.x'], 'docs.default_version' => '1.x']); + + $this->assertSame('1.x', DocumentationVersions::default()->name); + } + + public function testUnknownDefaultVersionThrows() + { + config(['docs.versions' => ['1.x'], 'docs.default_version' => '2.x']); + + $this->expectException(InvalidConfigurationException::class); + $this->expectExceptionMessage("The default documentation version '2.x' is not present in the `docs.versions` configuration."); + + DocumentationVersions::all(); + } + + public function testInvalidVersionNameThrows() + { + config(['docs.versions' => ['1.x/nested']]); + + $this->expectException(InvalidConfigurationException::class); + $this->expectExceptionMessage("Invalid documentation version name '1.x/nested'."); + + DocumentationVersions::all(); + } + + public function testGetReturnsNullForUnregisteredVersion() + { + config(['docs.versions' => ['1.x']]); + + $this->assertNull(DocumentationVersions::get('2.x')); + } + + public function testFromIdentifierResolvesVersionFromFirstPathSegment() + { + config(['docs.versions' => ['1.x', '2.x']]); + + $this->assertSame('1.x', DocumentationVersions::fromIdentifier('1.x/installation')->name); + $this->assertSame('2.x', DocumentationVersions::fromIdentifier('2.x/getting-started/installation')->name); + } + + public function testFromIdentifierReturnsNullForUnversionedIdentifiers() + { + config(['docs.versions' => ['1.x', '2.x']]); + + $this->assertNull(DocumentationVersions::fromIdentifier('installation')); + $this->assertNull(DocumentationVersions::fromIdentifier('getting-started/installation')); + $this->assertNull(DocumentationVersions::fromIdentifier('1.x')); + } + + public function testFromIdentifierReturnsNullWhenVersioningIsDisabled() + { + $this->assertNull(DocumentationVersions::fromIdentifier('1.x/installation')); + } + + public function testFromRouteKeyResolvesVersion() + { + config(['docs.versions' => ['1.x', '2.x']]); + + $this->assertSame('1.x', DocumentationVersions::fromRouteKey('docs/1.x/installation')->name); + $this->assertNull(DocumentationVersions::fromRouteKey('docs/installation')); + $this->assertNull(DocumentationVersions::fromRouteKey('posts/1.x/installation')); + } + + public function testStripVersionPrefix() + { + config(['docs.versions' => ['1.x', '2.x']]); + + $this->assertSame('installation', DocumentationVersions::stripVersionPrefix('1.x/installation')); + $this->assertSame('getting-started/installation', DocumentationVersions::stripVersionPrefix('2.x/getting-started/installation')); + $this->assertSame('installation', DocumentationVersions::stripVersionPrefix('installation')); + $this->assertSame('3.x/installation', DocumentationVersions::stripVersionPrefix('3.x/installation')); + } + + public function testVersionRouteKeyPrefixAndHomeRouteName() + { + config(['docs.versions' => ['1.x']]); + + $version = DocumentationVersions::get('1.x'); + + $this->assertSame('docs/1.x', $version->routeKeyPrefix()); + $this->assertSame('docs/1.x/index', $version->homeRouteName()); + } + + public function testVersionRouteKeyPrefixRespectsCustomOutputDirectory() + { + config(['docs.versions' => ['1.x']]); + + DocumentationPage::setOutputDirectory('documentation'); + + $this->assertSame('documentation/1.x', DocumentationVersions::get('1.x')->routeKeyPrefix()); + } + + public function testVersionCastsToStringName() + { + $this->assertSame('1.x', (string) new DocumentationVersion('1.x')); + } + + public function testGetEquivalentRouteFindsPageInOtherVersion() + { + config(['docs.versions' => ['1.x', '2.x'], 'docs.flattened_output_paths' => false]); + + $this->file('_docs/1.x/installation.md'); + $this->file('_docs/2.x/installation.md'); + $this->file('_docs/2.x/upgrading.md'); + + Hyde::boot(); // Reboot to rediscover new pages + + $page = DocumentationPage::get('2.x/installation'); + + $route = DocumentationVersions::getEquivalentRoute($page, DocumentationVersions::get('1.x')); + + $this->assertNotNull($route); + $this->assertSame('docs/1.x/installation', $route->getRouteKey()); + + $this->assertNull(DocumentationVersions::getEquivalentRoute(DocumentationPage::get('2.x/upgrading'), DocumentationVersions::get('1.x'))); + } +} From 378ed4293d19bbb418a41f9e31839fbaeaec5623 Mon Sep 17 00:00:00 2001 From: Emma De Silva Date: Sat, 11 Jul 2026 05:35:28 +0200 Subject: [PATCH 03/52] Make documentation pages version-aware Documentation pages stored in registered version subdirectories now resolve their version, keep the version prefix when flattening output paths, and the home route helpers accept a version, defaulting to the default version when versioning is enabled. The version registry now also tolerates being called from configuration files while the configuration itself is being loaded, treating versioning as disabled at that point. Co-Authored-By: Claude Fable 5 --- .../Versioning/DocumentationVersions.php | 17 ++ .../framework/src/Pages/DocumentationPage.php | 58 ++++++- .../Feature/VersionedDocumentationTest.php | 163 ++++++++++++++++++ 3 files changed, 231 insertions(+), 7 deletions(-) create mode 100644 packages/framework/tests/Feature/VersionedDocumentationTest.php diff --git a/packages/framework/src/Framework/Features/Documentation/Versioning/DocumentationVersions.php b/packages/framework/src/Framework/Features/Documentation/Versioning/DocumentationVersions.php index 34afb0b80ee..ed9d5e84965 100644 --- a/packages/framework/src/Framework/Features/Documentation/Versioning/DocumentationVersions.php +++ b/packages/framework/src/Framework/Features/Documentation/Versioning/DocumentationVersions.php @@ -10,6 +10,7 @@ use Hyde\Pages\Concerns\HydePage; use Illuminate\Support\Collection; use Hyde\Foundation\Facades\Routes; +use Illuminate\Support\Facades\Facade; use Hyde\Framework\Exceptions\InvalidConfigurationException; use function count; @@ -39,6 +40,13 @@ final class DocumentationVersions */ public static function enabled(): bool { + // Version helpers may be called from configuration files while the configuration itself is being + // loaded (for example `DocumentationPage::homeRouteName()`), in which case we consider + // versioning to be disabled, as the version registry cannot be read yet. + if (! self::configurationAvailable()) { + return false; + } + return count(Config::getArray('docs.versions', [])) > 0; } @@ -49,6 +57,10 @@ public static function enabled(): bool */ public static function all(): Collection { + if (! self::enabled()) { + return new Collection(); + } + /** @var array $versions */ $versions = Config::getArray('docs.versions', []); @@ -137,6 +149,11 @@ public static function stripVersionPrefix(string $identifier): string return $version === null ? $identifier : Str::after($identifier, $version->name.'/'); } + protected static function configurationAvailable(): bool + { + return Facade::getFacadeApplication()?->bound('config') ?? false; + } + /** @param array $versions */ protected static function defaultVersionName(array $versions): ?string { diff --git a/packages/framework/src/Pages/DocumentationPage.php b/packages/framework/src/Pages/DocumentationPage.php index 6777f4aba3f..bd52060c4a3 100644 --- a/packages/framework/src/Pages/DocumentationPage.php +++ b/packages/framework/src/Pages/DocumentationPage.php @@ -8,6 +8,9 @@ use Hyde\Foundation\Facades\Routes; use Hyde\Pages\Concerns\BaseMarkdownPage; use Hyde\Support\Models\Route; +use Hyde\Framework\Features\Documentation\Versioning\DocumentationVersion; +use Hyde\Framework\Features\Documentation\Versioning\DocumentationVersions; +use Hyde\Framework\Features\Documentation\Versioning\HasDocumentationVersion; use function trim; use function sprintf; @@ -20,22 +23,51 @@ * Documentation pages are stored in the _docs directory and using the .md extension. * The Markdown will be compiled to HTML using the documentation page layout to the _site/docs/ directory. * + * When documentation versioning is enabled through the `docs.versions` configuration, pages stored in + * version subdirectories (like `_docs/1.x`) belong to that version and are compiled to a matching + * subdirectory of the output directory (like `_site/docs/1.x`). + * * @see https://hydephp.com/docs/2.x/documentation-pages */ -class DocumentationPage extends BaseMarkdownPage +class DocumentationPage extends BaseMarkdownPage implements HasDocumentationVersion { public static string $sourceDirectory = '_docs'; public static string $outputDirectory = 'docs'; public static string $template = 'hyde::layouts/docs'; - public static function home(): ?Route + /** + * Get the route for the documentation index page, if it exists. + * + * When versioning is enabled, this defaults to the default version's index page, + * but a specific version can be passed to get the index page for that version. + */ + public static function home(DocumentationVersion|string|null $version = null): ?Route { - return Routes::find(static::homeRouteName()); + return Routes::find(static::homeRouteName($version)); } - public static function homeRouteName(): string + /** + * Get the route key for the documentation index page. + * + * When versioning is enabled, this defaults to the default version's index page, + * but a specific version can be passed to get the route key for that version. + */ + public static function homeRouteName(DocumentationVersion|string|null $version = null): string { - return unslash(static::baseRouteKey().'/index'); + $version ??= DocumentationVersions::enabled() ? DocumentationVersions::default() : null; + + return $version === null + ? unslash(static::baseRouteKey().'/index') + : unslash(static::baseRouteKey()."/$version/index"); + } + + /** + * Get the documentation version this page belongs to, or null if versioning + * is disabled, or the page is not stored in a version subdirectory. + */ + public function getDocumentationVersion(): ?DocumentationVersion + { + return DocumentationVersions::fromIdentifier($this->identifier); } /** @see https://hydephp.com/docs/2.x/documentation-pages#automatic-edit-page-button */ @@ -52,11 +84,12 @@ public function getOnlineSourcePath(): string|false * Get the route key for the page. * * If flattened outputs are enabled, this will use the identifier basename so nested pages are flattened. + * Pages belonging to a documentation version keep the version prefix, so only the structure within the version is flattened. */ public function getRouteKey(): string { return Config::getBool('docs.flattened_output_paths', true) - ? unslash(static::outputDirectory().'/'.basename(parent::getRouteKey())) + ? unslash(static::outputDirectory().'/'.$this->versionedBasename(basename(parent::getRouteKey()))) : parent::getRouteKey(); } @@ -64,11 +97,22 @@ public function getRouteKey(): string * Get the path where the compiled page will be saved. * * If flattened outputs are enabled, this will use the identifier basename so nested pages are flattened. + * Pages belonging to a documentation version keep the version prefix, so only the structure within the version is flattened. */ public function getOutputPath(): string { return Config::getBool('docs.flattened_output_paths', true) - ? static::outputPath(basename($this->identifier)) + ? static::outputPath($this->versionedBasename(basename($this->identifier))) : parent::getOutputPath(); } + + /** + * Prefix a flattened page basename with the page's version name, if the page belongs to a version. + */ + protected function versionedBasename(string $basename): string + { + $version = $this->getDocumentationVersion(); + + return $version === null ? $basename : "$version->name/$basename"; + } } diff --git a/packages/framework/tests/Feature/VersionedDocumentationTest.php b/packages/framework/tests/Feature/VersionedDocumentationTest.php new file mode 100644 index 00000000000..e603c2725d2 --- /dev/null +++ b/packages/framework/tests/Feature/VersionedDocumentationTest.php @@ -0,0 +1,163 @@ + ['1.x', '2.x']]); + } + + // Section: Page version resolution + + public function testPageVersionIsNullWhenVersioningIsDisabled() + { + $this->assertNull((new DocumentationPage('1.x/installation'))->getDocumentationVersion()); + } + + public function testPageVersionIsNullForPagesOutsideVersionDirectories() + { + $this->enableVersions(); + + $this->assertNull((new DocumentationPage('installation'))->getDocumentationVersion()); + $this->assertNull((new DocumentationPage('getting-started/installation'))->getDocumentationVersion()); + } + + public function testPageVersionIsResolvedFromIdentifierPrefix() + { + $this->enableVersions(); + + $this->assertSame('1.x', (new DocumentationPage('1.x/installation'))->getDocumentationVersion()->name); + $this->assertSame('2.x', (new DocumentationPage('2.x/getting-started/installation'))->getDocumentationVersion()->name); + } + + // Section: Route keys and output paths + + public function testFlattenedRouteKeysKeepVersionPrefix() + { + $this->enableVersions(); + + $page = new DocumentationPage('2.x/getting-started/installation'); + + $this->assertSame('docs/2.x/installation', $page->getRouteKey()); + $this->assertSame('docs/2.x/installation.html', $page->getOutputPath()); + } + + public function testFlattenedRouteKeysForTopLevelVersionPages() + { + $this->enableVersions(); + + $page = new DocumentationPage('1.x/installation'); + + $this->assertSame('docs/1.x/installation', $page->getRouteKey()); + $this->assertSame('docs/1.x/installation.html', $page->getOutputPath()); + } + + public function testFlattenedRouteKeysStripNumericalPrefixesWithinVersions() + { + $this->enableVersions(); + + $page = new DocumentationPage('2.x/01-installation'); + + $this->assertSame('docs/2.x/installation', $page->getRouteKey()); + } + + public function testNonFlattenedRouteKeysAreUnchanged() + { + $this->enableVersions(); + + config(['docs.flattened_output_paths' => false]); + + $page = new DocumentationPage('2.x/getting-started/installation'); + + $this->assertSame('docs/2.x/getting-started/installation', $page->getRouteKey()); + $this->assertSame('docs/2.x/getting-started/installation.html', $page->getOutputPath()); + } + + public function testUnversionedPagesFlattenAsBeforeWhenVersioningIsEnabled() + { + $this->enableVersions(); + + $page = new DocumentationPage('getting-started/installation'); + + $this->assertSame('docs/installation', $page->getRouteKey()); + $this->assertSame('docs/installation.html', $page->getOutputPath()); + } + + public function testFlattenedRouteKeysAreUnchangedWhenVersioningIsDisabled() + { + $page = new DocumentationPage('getting-started/installation'); + + $this->assertSame('docs/installation', $page->getRouteKey()); + $this->assertSame('docs/installation.html', $page->getOutputPath()); + } + + // Section: Home routes + + public function testHomeRouteNameUsesDefaultVersionWhenVersioningIsEnabled() + { + $this->enableVersions(); + + $this->assertSame('docs/2.x/index', DocumentationPage::homeRouteName()); + } + + public function testHomeRouteNameAcceptsExplicitVersion() + { + $this->enableVersions(); + + $this->assertSame('docs/1.x/index', DocumentationPage::homeRouteName('1.x')); + $this->assertSame('docs/1.x/index', DocumentationPage::homeRouteName(DocumentationVersions::get('1.x'))); + } + + public function testHomeRouteNameIsUnchangedWhenVersioningIsDisabled() + { + $this->assertSame('docs/index', DocumentationPage::homeRouteName()); + } + + public function testHomeFindsVersionIndexRoutes() + { + $this->enableVersions(); + + $this->file('_docs/1.x/index.md'); + $this->file('_docs/2.x/index.md'); + + Hyde::boot(); // Reboot to rediscover new pages + + $this->assertSame('docs/2.x/index', DocumentationPage::home()->getRouteKey()); + $this->assertSame('docs/1.x/index', DocumentationPage::home('1.x')->getRouteKey()); + } + + // Section: Discovery + + public function testVersionedPagesAreDiscoveredWithVersionedRouteKeys() + { + $this->enableVersions(); + + $this->file('_docs/1.x/installation.md'); + $this->file('_docs/2.x/installation.md'); + $this->file('_docs/2.x/getting-started/advanced.md'); + + Hyde::boot(); // Reboot to rediscover new pages + + $routes = Hyde::routes()->getRoutes(DocumentationPage::class)->keys()->sort()->values()->all(); + + $this->assertSame(['docs/1.x/installation', 'docs/2.x/advanced', 'docs/2.x/installation'], $routes); + } +} From 7bce1ae0560f6434901d5f4af4fba09fada56d1f Mon Sep 17 00:00:00 2001 From: Emma De Silva Date: Sat, 11 Jul 2026 05:47:07 +0200 Subject: [PATCH 04/52] Add version-aware documentation sidebars and navigation Each documentation version now gets its own sidebar, registered as navigation.sidebar.{version} container singletons, with the plain navigation.sidebar binding holding the default version's sidebar. DocumentationSidebar::get() resolves the sidebar for the version of the page currently being rendered. Sidebar and navigation configuration entries (order, labels, exclude) now also match version-agnostic identifiers and route keys, so a single configuration entry applies to the page in every version, while still allowing version-specific overrides via full keys. Automatic sidebar grouping and index page titles skip the version directory segment, and the main navigation menu links to the default version's documentation index page. Co-Authored-By: Claude Fable 5 --- .../docs/mobile-navigation.blade.php | 7 +- .../components/docs/sidebar-brand.blade.php | 4 +- .../views/components/docs/sidebar.blade.php | 2 +- .../Providers/NavigationServiceProvider.php | 9 + .../Factories/HydePageDataFactory.php | 13 +- .../Factories/NavigationDataFactory.php | 44 ++++- .../Versioning/DocumentationVersions.php | 16 ++ .../Navigation/DocumentationSidebar.php | 37 +++- .../Navigation/NavigationMenuGenerator.php | 42 +++- .../Feature/VersionedDocumentationTest.php | 182 ++++++++++++++++++ 10 files changed, 329 insertions(+), 27 deletions(-) diff --git a/packages/framework/resources/views/components/docs/mobile-navigation.blade.php b/packages/framework/resources/views/components/docs/mobile-navigation.blade.php index 3897a031442..c3bdbabfee6 100644 --- a/packages/framework/resources/views/components/docs/mobile-navigation.blade.php +++ b/packages/framework/resources/views/components/docs/mobile-navigation.blade.php @@ -1,7 +1,10 @@