From b04dd5358e911bb03c431463fb586a13d325a8c5 Mon Sep 17 00:00:00 2001 From: Emma De Silva Date: Mon, 13 Jul 2026 05:05:11 +0200 Subject: [PATCH 001/117] Create EPIC_NON_HTML_PAGES.md --- EPIC_NON_HTML_PAGES.md | 221 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 221 insertions(+) create mode 100644 EPIC_NON_HTML_PAGES.md diff --git a/EPIC_NON_HTML_PAGES.md b/EPIC_NON_HTML_PAGES.md new file mode 100644 index 00000000000..c98ba927e67 --- /dev/null +++ b/EPIC_NON_HTML_PAGES.md @@ -0,0 +1,221 @@ +# Epic: First-class non-HTML pages (robots.txt, llms.txt, sitemap, RSS) + +> Status: Draft — v3 development branch +> +> Theme: Make non-HTML output files (txt, xml, json) first-class pages instead of +> build-task side effects, so they flow through routing, the build pipeline, the +> realtime compiler, and user-land extension points like every other page. + +## Motivation + +There is currently no easy way to add plain-text files like `robots.txt` or `llms.txt` +to a Hyde site. While investigating this, we found that the framework already contains +three different mechanisms for emitting non-HTML files, each with its own tradeoffs: + +1. **Post-build tasks** (`GenerateSitemap`, `GenerateRssFeed`) write directly to + `_site/` with `file_put_contents`, bypassing the page/route system entirely. + Consequence: `hyde serve` cannot serve `sitemap.xml` or `feed.xml` at all, and + they are invisible to `route:list` and the build manifest. +2. **Virtual pages** (`DocumentationSearchIndex extends InMemoryPage`) participate in + routing and the build, but only by manually overriding `getOutputPath()` because + `HydePage::outputPath()` hardcodes the `.html` suffix + (`packages/framework/src/Pages/Concerns/HydePage.php:213`). The realtime compiler + needs a hardcoded exemption to serve it + (`packages/realtime-compiler/src/Routing/Router.php:72-75`). +3. **Verbatim source files** (`HtmlPage`) are autodiscovered from `_pages` and copied + as-is — but only for `.html`. + +Unifying these under one model gives us `robots.txt`/`llms.txt` support nearly for +free, fixes real bugs, and simplifies the framework. + +### Bugs and gaps this epic fixes + +- **`search.json` leaks into sitemaps in production.** `SitemapGenerator::generate()` + excludes only `Redirect` pages, so every other route is included. Verified live: + `https://hydephp.com/sitemap.xml` contains `docs/1.x/search.json` and + `docs/2.x/search.json`. There is no per-page sitemap exclusion mechanism at all. +- **`hyde serve` does not serve `sitemap.xml` or the RSS feed**, because they only + exist as post-build artifacts. +- **`InMemoryPage::make('robots.txt', contents: ...)` outputs `robots.txt.html`**, + making the natural "assemble it in code" escape hatch a trap. +- **The realtime compiler special-cases `search.json` by string suffix** instead of + asking the route system. + +### What already works in our favor + +- `PageRouter::getContentType()` already maps `json`/`xml`/`txt` output paths to + correct Content-Type headers (`packages/realtime-compiler/src/Routing/PageRouter.php:59-69`). +- `BuildService::getPageTypes()` derives page classes from the live page collection, + and `StaticPageBuilder` writes whatever `getOutputPath()` returns — dynamic pages + need zero build-service changes. +- `HydeCoreExtension::discoverPages()` is the proven registration point for + feature-gated virtual pages (search index, redirects), and users/packages can do + the same via `Hyde::kernel()->booting()` callbacks or a `HydeExtension`. +- The route-key-with-extension convention is already de facto established: + `DocumentationSearchIndex` uses route key `docs/search.json` equal to its output path. + +## Design decisions + +### D1: Route key equals output path; only `.html` is implicit + +Formalize the convention `DocumentationSearchIndex` already uses: a page's route key +is its output path, except that HTML pages drop the `.html` suffix (pretty URLs). +So `robots.txt`, `sitemap.xml`, and `docs/search.json` are route keys as-is, while +`about.html` keeps route key `about`. + +This is preferred over a "clean route key + separate output extension" model because +it is proven in production, requires no realtime-compiler lookup changes +(`PageRouter::normalizePath()` only strips `.html`), and lets `docs/search` (page) +and `docs/search.json` (index) coexist as distinct routes, which they already do. + +### D2: Output extension is declared, not inferred + +Do **not** infer "this identifier has an extension" from a dot in the identifier — +versioned docs route keys like `docs/1.x/index` would false-positive +(`pathinfo('docs/1.x')['extension'] === 'x'`). Instead the extension is declared: + +- File-discovered classes declare it statically, mirroring the existing + `HtmlPage::$fileExtension` pattern: `TextPage::$outputFileExtension = '.txt'`. +- `HydePage` gets `public static string $outputFileExtension = '.html'` (or an + instance-level `getOutputFileExtension()` hook), and `outputPath()` uses it instead + of the hardcoded `'.html'`. This removes the `getOutputPath()` override dance. +- For `InMemoryPage`, decide in the PR whether to (a) accept a small allowlist of + trailing extensions in the identifier (`.txt`, `.json`, `.xml`), or (b) add an + explicit constructor/`make()` parameter. Leaning (a) with allowlist since + `InMemoryPage::make('robots.txt', contents: $txt)` is the DX we actually want. + +### D3: Sitemap inclusion becomes a page-level concern + +Replace the `instanceof Redirect` filter in `SitemapGenerator` with a +`HydePage::showInSitemap(): bool` method backed by front matter (`sitemap: false`), +with defaults: `false` for redirects and for pages whose output is not `.html`, +`true` otherwise. This fixes the `search.json` leak, prevents the new +sitemap/feed/robots pages from self-listing, and gives users per-page opt-out — +a standalone feature in its own right. + +### D4: Generators become pages; generator actions stay + +`SitemapPage`, `RssFeedPage` (and later `RobotsTxtPage`, `LlmsTxtPage`) extend +`InMemoryPage` with a `compile()` that delegates to the existing generator classes — +exactly the `DocumentationSearchIndex` → `GeneratesDocumentationSearchIndex` split. +The XML generator actions (`SitemapGenerator`, `RssFeedGenerator`) are untouched. +Registration happens in `HydeCoreExtension::discoverPages()` behind the existing +`Features::hasSitemap()` / `Features::hasRss()` conditions, replacing the +registrations in `BuildTaskService::registerFrameworkTasks()`. + +### D5: Source files beat generators + +If a user provides `_pages/robots.txt`, the framework does not register its generated +`RobotsTxtPage`. Same pattern as `discoverDocumentationRootRedirect()`, which skips +when a user-defined route exists. This gives a smooth escalation path: +feature default → config tweaks → file on disk → fully dynamic page in code. + +## Work breakdown (one PR each, in dependency order) + +### PR 1 — Foundation: declared output extensions on `HydePage` + +Goal: any page class can emit a non-`.html` file without overriding `getOutputPath()`. + +- Add `$outputFileExtension` (default `'.html'`) to `HydePage`; use it in + `outputPath()` (`HydePage.php:211-214`). +- Route keys follow D1; audit `RouteKey` and `Route` for assumptions. +- Let `InMemoryPage` respect a declared/allowlisted extension per D2, so + `InMemoryPage::make('robots.txt', contents: ...)` outputs `robots.txt`. +- Refactor `DocumentationSearchIndex` to drop its `getOutputPath()` override. +- Pure refactor for existing sites: no compiled-output changes. + +### PR 2 — Realtime compiler: route-first resolution for non-HTML paths + +Goal: `hyde serve` serves any registered route regardless of extension; no +filename special cases. + +- In `Router::shouldProxy()`, replace the `search.json` suffix check with a generic + "is there a registered route for this path?" check (`PageRouter::hasRoute()`), + so pages win over asset proxying. +- Regression tests: versioned-docs dotted paths (`docs/1.x/...`), media assets, + missing-asset 404s, and `search.json` still served. +- `PageRouter::getContentType()` already handles txt/xml/json; extend the map only + if new types come up. + +### PR 3 — `TextPage` class (the headline feature) + +Goal: drop `_pages/robots.txt` in, get `_site/robots.txt` out. + +- New `TextPage extends HydePage`: `$sourceDirectory = '_pages'`, + `$outputDirectory = ''`, `$fileExtension = '.txt'`, + `$outputFileExtension = '.txt'`; `compile()` returns file contents verbatim + (mirror `HtmlPage`). +- Register in `HydeCoreExtension::getPageClasses()`. No `Feature::TextPages` enum + case — the feature is always on, since it is inert without source files. +- Hidden from navigation menus by default; excluded from sitemap via PR 4's + mechanism (or an interim guard if PR 4 lands later). +- Docs: static-pages page gains a "Text pages" section, including the in-code + variant (service provider / `booting()` callback assembling contents dynamically, + e.g. looping over routes to build a robots.txt — the `InMemoryPage` example). + +### PR 4 — Sitemap inclusion policy + +Goal: pages control their own sitemap presence; fix the production `search.json` leak. + +- `HydePage::showInSitemap()` per D3 + `sitemap: false` front matter support. +- `SitemapGenerator::generate()` filters on it instead of `instanceof Redirect`. +- Changelog note: search indexes no longer appear in sitemaps (bugfix). +- Independent of PRs 1-3; must land before or with PR 5. + +### PR 5 — Convert sitemap and RSS from build tasks to pages + +Goal: `sitemap.xml` and `feed.xml` are routes — served by `hyde serve`, listed in +`route:list`, included in the build manifest, overridable in user land. + +- `SitemapPage` / `RssFeedPage` extend `InMemoryPage`, `compile()` delegates to the + existing generators; RSS route key comes from `RssFeedGenerator::getFilename()` + (config `hyde.rss.filename`). +- Register in `HydeCoreExtension::discoverPages()` behind `Features::hasSitemap()` / + `Features::hasRss()`; remove `GenerateSitemap`/`GenerateRssFeed` from + `BuildTaskService::registerFrameworkTasks()` (evaluate deprecation vs. removal — + v3 allows breaking changes, but third-party code may reference the task classes). +- Rewire `build:sitemap` / `build:rss` commands to `StaticPageBuilder::handle(new …Page())`. +- Verify `GlobalMetadataBag` head links and the `hyde.url` requirements still hold + (`Features::hasSitemap()` already requires a site URL). +- Nice side effect: build output shows them under "Dynamic Pages" with the standard + progress display. + +### PR 6 — Generated `robots.txt` + +Goal: sensible robots.txt out of the box, zero config. + +- `RobotsTxtPage extends InMemoryPage`, route key `robots.txt`; default output + `User-agent: * / Allow: /` plus a `Sitemap:` line when `Features::hasSitemap()`. +- Config (e.g. `hyde.robots`) for disallow rules / disabling; source-file precedence + per D5 (a `_pages/robots.txt` `TextPage` wins). +- Depends on PRs 1, 2, 5 patterns. + +### PR 7 — Generated `llms.txt` + +Goal: best-in-class llms.txt support — no other SSG generates this well out of the box. + +- `GeneratesLlmsTxt` action per the llms.txt spec: site name as H1, `hyde.description` + ("about" blockquote), sections of route links using page titles and the new + documentation page abstracts (#2523) as link descriptions. +- `LlmsTxtPage extends InMemoryPage` wired like robots.txt (feature-gated, config + for section grouping/exclusions, source-file precedence). +- Consider `llms-full.txt` (full page contents) as a follow-up, not in scope. + +### PR 8 — Documentation & release notes + +- Document `TextPage`, in-code virtual pages, `sitemap: false` front matter, + robots/llms config, and the "source file beats generator" rule. +- Update `HYDEPHP_V3_PLANNING.md` release notes: new features (TextPage, robots.txt, + llms.txt, serve support for sitemap/RSS), breaking changes (build task classes + removed/relocated, search.json removed from sitemaps). + +## Out of scope (noted for later) + +- Blade-processed text files (`robots.blade.txt`) analogous to `BladePage` — wait for + demand; the in-code `InMemoryPage` path covers dynamic cases. +- `llms-full.txt` / per-page markdown exports. +- Generalizing `GenerateBuildManifest` or search-index generation commands beyond + what PR 5 requires. +- Reconsidering the page-type `Feature` enum cases (`HtmlPages`, `BladePages`, etc.) + altogether — arguably redundant since not creating source files has the same + effect. Worth a separate v3 discussion; this epic simply doesn't add new ones. From a4987a04df7cdb4fba9b9b21fe9efb35722101db Mon Sep 17 00:00:00 2001 From: Emma De Silva Date: Mon, 13 Jul 2026 05:38:32 +0200 Subject: [PATCH 002/117] Add agent instructions for epic-driven development Co-Authored-By: Claude Fable 5 --- CLAUDE.md | 74 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 74 insertions(+) create mode 100644 CLAUDE.md diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 00000000000..7191cb2e81a --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,74 @@ +# Agent instructions for the HydePHP monorepo + +This is the hydephp/develop monorepo. Framework code lives in `packages/framework`, +shared test utilities in `packages/testing`, the dev server in `packages/realtime-compiler`. +We are developing HydePHP v3; the main branch for PRs is `2.x`. + +## Epic-driven workflow + +- Multi-PR efforts are specified in epic documents at the repo root (like + `EPIC_NON_HTML_PAGES.md`). The epic is the source of truth: before implementing, + re-read the relevant PR section and design decisions; after implementing, check the + diff against them line by line. +- Deviations from the design and decisions the epic left open MUST be written back + into the epic in the same PR, and implemented sections marked (like "✅ Implemented"). +- Before deviating, verify it is a *good* deviation: check side effects against the + rest of the epic (later PRs, design decisions) so the change doesn't drift from the + overall design. Also watch for silent under-delivery — implementing a design rule + only for the cases the current PR exercises will break later PRs that rely on it. +- Work on the epic happens in `v3/-*` branches off the epic base branch. + Check `git branch --list 'v3/*'` and the epic's status annotations (which may only + exist on PR branches) before assuming a PR is unimplemented. + +## Commits + +- Make atomic commits as you go: one logical change per commit (implementation, + tests, docs/release notes separately when they are separable). Do not batch a + whole PR into one commit at the end. + +## Testing + +The goal is full confidence: feature tests give 100% coverage by exercising all user +paths end-to-end (e.g. register a page, run the real `build` command, assert the +output file), and unit tests cover every code unit. + +- Run suites with `vendor/bin/pest --testsuite FeatureFramework|UnitFramework|FeatureHyde`. + Use `--filter` for targeted runs. Run `php monorepo/HydeStan/run.php` before finishing. +- Every public method on page classes is part of the `BaseHydePageUnitTest` contract + (`packages/testing/src/Common/`): adding public API to `HydePage` means adding an + abstract test there and implementing it in all page unit tests in + `packages/framework/tests/Unit/Pages/`. `TestAllPageTypesHaveUnitTestsTest` enforces + one unit test class per page class. +- The suites read the real project root and pollute the working tree: they delete + `_media/app.css` and leave untracked junk (`_assets/`, `_docs/docs.md`, `_docs/index.md`, + `_pages/root.md`, `_pages/root1.md`, `_posts/my-new-post.md`, `_media/app.js`, `_site/`). + Dozens of environmental failures follow from that state — they are not regressions. + Restore with `git checkout -- _media/app.css` and delete the junk between runs. +- To prove a change introduces no regressions: run the suite at HEAD, then + `git checkout HEAD~N -- packages` (pre-change baseline), clean the tree, re-run, + and diff the sorted `FAILED` lines. Identical lists means no regressions. Restore + with `git checkout HEAD -- packages`. Don't pipe long pest runs through `tail` — + redirect to a file and grep it. +- Known pre-existing failure: `FeaturedImageUnitTest` on PHP 8.5 (`MediaFile::findHash()`). + +## Release notes and docs + +- Add release-notes entries to `HYDEPHP_V3_PLANNING.md` and upgrade steps to + `UPGRADE.md` as part of the PR that makes the change. +- Breaking-change notes must describe realistic impact. If the note describes a + scenario nobody would plausibly be in (like relying on double-extension output + such as `data.json.html`), say that no real impact is expected instead of + prescribing a migration. + +## Code comments + +If you catch yourself writing a code comment, stop and think about why. A comment is +usually a smell that the code is doing something weird — step back and look at what +you are actually trying to do before reaching for a comment. Comments are only for: + +1. Adding better type support (docblocks, `@param`/`@var` annotations). +2. Documenting public APIs. +3. Explaining why unusual-looking code has to be that way, when there is no other option. + +Never write comments that narrate what the next line does, where code came from, or +why a change is correct — that belongs in the PR description, not the code. From 1c9ef873cbd34bdae0d2336f729950f0b598a8e4 Mon Sep 17 00:00:00 2001 From: Emma De Silva Date: Mon, 13 Jul 2026 05:14:39 +0200 Subject: [PATCH 003/117] Add support for declaring non-HTML output file extensions on pages Adds a static $outputFileExtension property (default '.html') to HydePage, used by outputPath() instead of the hardcoded HTML suffix, along with an outputFileExtension() accessor matching the existing static accessors. InMemoryPage identifiers can now declare a '.json', '.txt', or '.xml' extension to compile to that path as-is, so InMemoryPage::make('robots.txt', contents: ...) outputs '_site/robots.txt'. This formalizes the route key convention already used by the documentation search index: only the HTML extension is implicit in route keys, and pages compiled to non-HTML files keep their extension in the route key. The DocumentationSearchIndex getOutputPath() override is now redundant and removed. Co-Authored-By: Claude Fable 5 --- .../DocumentationSearchIndex.php | 5 --- .../framework/src/Pages/Concerns/HydePage.php | 18 ++++++++-- packages/framework/src/Pages/InMemoryPage.php | 33 +++++++++++++++++++ .../framework/src/Support/Models/RouteKey.php | 5 ++- 4 files changed, 52 insertions(+), 9 deletions(-) diff --git a/packages/framework/src/Framework/Features/Documentation/DocumentationSearchIndex.php b/packages/framework/src/Framework/Features/Documentation/DocumentationSearchIndex.php index b6cff21ceeb..bde991b7e74 100644 --- a/packages/framework/src/Framework/Features/Documentation/DocumentationSearchIndex.php +++ b/packages/framework/src/Framework/Features/Documentation/DocumentationSearchIndex.php @@ -49,9 +49,4 @@ public static function routeKey(?DocumentationVersion $version = null): string { return RouteKey::fromPage(DocumentationPage::class, $version === null ? 'search' : "$version->name/search").'.json'; } - - public function getOutputPath(): string - { - return static::routeKey($this->version); - } } diff --git a/packages/framework/src/Pages/Concerns/HydePage.php b/packages/framework/src/Pages/Concerns/HydePage.php index e60e44dc187..215c9ce2f51 100644 --- a/packages/framework/src/Pages/Concerns/HydePage.php +++ b/packages/framework/src/Pages/Concerns/HydePage.php @@ -57,6 +57,7 @@ abstract class HydePage implements PageSchema, SerializableContract public static string $sourceDirectory; public static string $outputDirectory; public static string $fileExtension; + public static string $outputFileExtension = '.html'; public static string $template; public readonly string $identifier; @@ -173,6 +174,14 @@ public static function fileExtension(): string return static::$fileExtension ?? ''; } + /** + * Get the file extension of the compiled output files for the page type, including the leading dot. + */ + public static function outputFileExtension(): string + { + return static::$outputFileExtension; + } + /** * Set the output directory for the page type. */ @@ -210,7 +219,7 @@ public static function sourcePath(string $identifier): string */ public static function outputPath(string $identifier): string { - return RouteKey::fromPage(static::class, $identifier).'.html'; + return RouteKey::fromPage(static::class, $identifier).static::outputFileExtension(); } /** @@ -292,12 +301,15 @@ public function getOutputPath(): string /** * Get the route key for the page. * - * The route key is the page URL path, relative to the site root, but without any file extensions. + * The route key is the page URL path, relative to the site root, but without the HTML file extension. * For example, if the page will be saved to `_site/docs/index.html`, the key is `docs/index`. + * Pages compiled to non-HTML files keep their extension in the route key, so a page + * saved to `_site/docs/search.json` has the route key `docs/search.json`. * * Route keys are used to identify page routes, similar to how named routes work in Laravel, * only that here the name is not just arbitrary, but also defines the output location, - * as the route key is used to determine the output path which is `$routeKey.html`. + * as the route key is used to determine the output path which is `$routeKey.html`, + * or the route key as-is for pages compiled to non-HTML files. */ public function getRouteKey(): string { diff --git a/packages/framework/src/Pages/InMemoryPage.php b/packages/framework/src/Pages/InMemoryPage.php index 0a62949f60b..19a56a382d1 100644 --- a/packages/framework/src/Pages/InMemoryPage.php +++ b/packages/framework/src/Pages/InMemoryPage.php @@ -8,9 +8,12 @@ use Hyde\Framework\Actions\AnonymousViewCompiler; use Hyde\Markdown\Models\FrontMatter; use Hyde\Pages\Concerns\HydePage; +use Hyde\Support\Models\RouteKey; use Illuminate\Support\Facades\View; use InvalidArgumentException; +use function str_ends_with; + /** * Extendable class for in-memory (or virtual) Hyde pages that are not based on source files. * @@ -74,6 +77,7 @@ public static function make( * * View values ending in `.blade.php` are treated as Blade file paths. Other values are treated * as registered Laravel view keys. + * Identifiers ending in `.json`, `.txt`, or `.xml` retain that extension in the output path. * * @param string $identifier * @param FrontMatter|array $matter @@ -102,6 +106,35 @@ public function __construct( $this->view = $view ?? ''; } + /** + * Qualify a page identifier into a target output file path, relative to the _site output directory. + * + * If the identifier declares a supported non-HTML output file extension, like "robots.txt", + * the page is saved to that path as-is, instead of having the HTML extension appended. + */ + public static function outputPath(string $identifier): string + { + if (static::identifierDeclaresOutputFileExtension($identifier)) { + return (string) RouteKey::fromPage(static::class, $identifier); + } + + return parent::outputPath($identifier); + } + + /** + * Determine if the given page identifier declares a supported non-HTML output file extension. + */ + protected static function identifierDeclaresOutputFileExtension(string $identifier): bool + { + foreach (['.json', '.txt', '.xml'] as $extension) { + if (str_ends_with($identifier, $extension)) { + return true; + } + } + + return false; + } + /** * Get the literal contents or invoke the configured content closure. */ diff --git a/packages/framework/src/Support/Models/RouteKey.php b/packages/framework/src/Support/Models/RouteKey.php index a97916c8263..217cdf00c75 100644 --- a/packages/framework/src/Support/Models/RouteKey.php +++ b/packages/framework/src/Support/Models/RouteKey.php @@ -16,11 +16,14 @@ * Route keys provide the core bindings of the HydePHP routing system as they are what canonically identifies a page. * This class both provides a data object for normalized type-hintable values, and general related helper methods. * - * In short, the route key is the URL path relative to the site webroot, without the file extension. + * In short, the route key is the URL path relative to the site webroot, without the HTML file extension. * * For example, `_pages/index.blade.php` would be compiled to `_site/index.html` and thus has the route key of `index`. * As another example, `_posts/welcome.md` would be compiled to `_site/posts/welcome.html` and thus has the route key of `posts/welcome`. * + * Only the HTML extension is implicit: pages compiled to non-HTML files keep their extension in the route key, + * so the documentation search index saved to `_site/docs/search.json` has the route key `docs/search.json`. + * * Note that if the source page's output directory is changed, the route key will change accordingly. * This can potentially cause links to break when changing the output directory for a page class. */ From 76ac215214dba8318301a5cb9015c6349f63066b Mon Sep 17 00:00:00 2001 From: Emma De Silva Date: Mon, 13 Jul 2026 05:18:01 +0200 Subject: [PATCH 004/117] Add unit tests for output file extension behavior Adds testOutputFileExtension to the BaseHydePageUnitTest contract with implementations for all page classes, HydePage baseline and child class override tests, and InMemoryPage tests covering identifier-declared extensions including edge cases like dotted identifiers and unsupported extensions. Co-Authored-By: Claude Fable 5 --- .../framework/tests/Feature/HydePageTest.php | 48 ++++++++++++ .../tests/Unit/Pages/BladePageUnitTest.php | 5 ++ .../Unit/Pages/DocumentationPageUnitTest.php | 5 ++ .../tests/Unit/Pages/HtmlPageUnitTest.php | 8 ++ .../tests/Unit/Pages/InMemoryPageTest.php | 74 +++++++++++++++++++ .../tests/Unit/Pages/InMemoryPageUnitTest.php | 8 ++ .../tests/Unit/Pages/MarkdownPageUnitTest.php | 8 ++ .../tests/Unit/Pages/MarkdownPostUnitTest.php | 8 ++ .../src/Common/BaseHydePageUnitTest.php | 2 + 9 files changed, 166 insertions(+) diff --git a/packages/framework/tests/Feature/HydePageTest.php b/packages/framework/tests/Feature/HydePageTest.php index 9ca64d1495f..5395f7d557e 100644 --- a/packages/framework/tests/Feature/HydePageTest.php +++ b/packages/framework/tests/Feature/HydePageTest.php @@ -51,6 +51,11 @@ public function testBaseFileExtension() $this->assertSame('', HydePage::fileExtension()); } + public function testBaseOutputFileExtension() + { + $this->assertSame('.html', HydePage::outputFileExtension()); + } + public function testBaseSourcePath() { $this->assertSame('hello-world', HydePage::sourcePath('hello-world')); @@ -98,6 +103,11 @@ public function testFileExtension() $this->assertSame('.md', TestPage::fileExtension()); } + public function testOutputFileExtension() + { + $this->assertSame('.html', TestPage::outputFileExtension()); + } + public function testSourcePath() { $this->assertSame('source/hello-world.md', TestPage::sourcePath('hello-world')); @@ -108,6 +118,33 @@ public function testOutputPath() $this->assertSame('output/hello-world.html', TestPage::outputPath('hello-world')); } + public function testOutputFileExtensionCanBeOverriddenByChildClasses() + { + $this->assertSame('.txt', NonHtmlOutputTestPage::outputFileExtension()); + } + + public function testOutputPathUsesTheOutputFileExtensionOfThePageClass() + { + $this->assertSame('output/hello-world.txt', NonHtmlOutputTestPage::outputPath('hello-world')); + } + + public function testGetOutputPathUsesTheOutputFileExtensionOfThePageClass() + { + $this->assertSame('output/hello-world.txt', (new NonHtmlOutputTestPage('hello-world'))->getOutputPath()); + } + + public function testGetLinkForPageWithNonHtmlOutputFileExtension() + { + $this->assertSame('output/hello-world.txt', (new NonHtmlOutputTestPage('hello-world'))->getLink()); + } + + public function testGetLinkForPageWithNonHtmlOutputFileExtensionIsNotAffectedByPrettyUrls() + { + config(['hyde.pretty_urls' => true]); + + $this->assertSame('output/hello-world.txt', (new NonHtmlOutputTestPage('hello-world'))->getLink()); + } + public function testPath() { $this->assertSame(Hyde::path('source/hello-world'), TestPage::path('hello-world')); @@ -1267,6 +1304,17 @@ class TestPage extends HydePage public static string $template = 'template'; } +class NonHtmlOutputTestPage extends HydePage +{ + use VoidCompiler; + + public static string $sourceDirectory = 'source'; + public static string $outputDirectory = 'output'; + public static string $fileExtension = '.txt'; + public static string $outputFileExtension = '.txt'; + public static string $template = 'template'; +} + class ConfigurableSourcesTestPage extends HydePage { use VoidCompiler; diff --git a/packages/framework/tests/Unit/Pages/BladePageUnitTest.php b/packages/framework/tests/Unit/Pages/BladePageUnitTest.php index 0e8ea4b2884..8fbcd3b7919 100644 --- a/packages/framework/tests/Unit/Pages/BladePageUnitTest.php +++ b/packages/framework/tests/Unit/Pages/BladePageUnitTest.php @@ -36,6 +36,11 @@ public function testFileExtension() $this->assertSame('.blade.php', BladePage::fileExtension()); } + public function testOutputFileExtension() + { + $this->assertSame('.html', BladePage::outputFileExtension()); + } + public function testSourcePath() { $this->assertSame('_pages/hello-world.blade.php', BladePage::sourcePath('hello-world')); diff --git a/packages/framework/tests/Unit/Pages/DocumentationPageUnitTest.php b/packages/framework/tests/Unit/Pages/DocumentationPageUnitTest.php index 3e37ce76cb4..dcbd6253a9b 100644 --- a/packages/framework/tests/Unit/Pages/DocumentationPageUnitTest.php +++ b/packages/framework/tests/Unit/Pages/DocumentationPageUnitTest.php @@ -38,6 +38,11 @@ public function testFileExtension() $this->assertSame('.md', DocumentationPage::fileExtension()); } + public function testOutputFileExtension() + { + $this->assertSame('.html', DocumentationPage::outputFileExtension()); + } + public function testSourcePath() { $this->assertSame('_docs/hello-world.md', DocumentationPage::sourcePath('hello-world')); diff --git a/packages/framework/tests/Unit/Pages/HtmlPageUnitTest.php b/packages/framework/tests/Unit/Pages/HtmlPageUnitTest.php index 0838e934a41..1c04eac09a9 100644 --- a/packages/framework/tests/Unit/Pages/HtmlPageUnitTest.php +++ b/packages/framework/tests/Unit/Pages/HtmlPageUnitTest.php @@ -48,6 +48,14 @@ public function testFileExtension() ); } + public function testOutputFileExtension() + { + $this->assertSame( + '.html', + HtmlPage::outputFileExtension() + ); + } + public function testSourcePath() { $this->assertSame( diff --git a/packages/framework/tests/Unit/Pages/InMemoryPageTest.php b/packages/framework/tests/Unit/Pages/InMemoryPageTest.php index 2e6c2312cc2..95a44642707 100644 --- a/packages/framework/tests/Unit/Pages/InMemoryPageTest.php +++ b/packages/framework/tests/Unit/Pages/InMemoryPageTest.php @@ -407,4 +407,78 @@ public function contentPrefix(): string { return $this->contentPrefix; } + + public function testIdentifierCanDeclareTxtOutputFileExtension() + { + $this->assertSame('robots.txt', InMemoryPage::outputPath('robots.txt')); + } + + public function testIdentifierCanDeclareJsonOutputFileExtension() + { + $this->assertSame('data.json', InMemoryPage::outputPath('data.json')); + } + + public function testIdentifierCanDeclareXmlOutputFileExtension() + { + $this->assertSame('sitemap.xml', InMemoryPage::outputPath('sitemap.xml')); + } + + public function testIdentifierCanDeclareOutputFileExtensionForNestedPages() + { + $this->assertSame('docs/search.json', InMemoryPage::outputPath('docs/search.json')); + } + + public function testIdentifierWithoutExtensionGetsHtmlOutputFileExtension() + { + $this->assertSame('foo.html', InMemoryPage::outputPath('foo')); + } + + public function testIdentifierWithUnsupportedExtensionGetsHtmlOutputFileExtension() + { + $this->assertSame('foo.md.html', InMemoryPage::outputPath('foo.md')); + } + + public function testIdentifierWithHtmlExtensionGetsHtmlOutputFileExtensionAppended() + { + $this->assertSame('foo.html.html', InMemoryPage::outputPath('foo.html')); + } + + public function testDottedIdentifierIsNotMistakenForDeclaredOutputFileExtension() + { + $this->assertSame('docs/1.x.html', InMemoryPage::outputPath('docs/1.x')); + } + + public function testGetOutputPathForIdentifierWithDeclaredOutputFileExtension() + { + $this->assertSame('robots.txt', (new InMemoryPage('robots.txt'))->getOutputPath()); + } + + public function testGetRouteKeyForIdentifierWithDeclaredOutputFileExtension() + { + $this->assertSame('robots.txt', (new InMemoryPage('robots.txt'))->getRouteKey()); + } + + public function testGetLinkForIdentifierWithDeclaredOutputFileExtension() + { + $this->assertSame('robots.txt', (new InMemoryPage('robots.txt'))->getLink()); + } + + public function testGetLinkForIdentifierWithDeclaredOutputFileExtensionIsNotAffectedByPrettyUrls() + { + config(['hyde.pretty_urls' => true]); + + $this->assertSame('robots.txt', (new InMemoryPage('robots.txt'))->getLink()); + } + + public function testGetCanonicalUrlForIdentifierWithDeclaredOutputFileExtension() + { + config(['hyde.url' => 'https://example.com']); + + $this->assertSame('https://example.com/robots.txt', (new InMemoryPage('robots.txt'))->getCanonicalUrl()); + } + + public function testCompiledContentsAreNotAffectedByDeclaredOutputFileExtension() + { + $this->assertSame('User-agent: *', (new InMemoryPage('robots.txt', contents: 'User-agent: *'))->compile()); + } } diff --git a/packages/framework/tests/Unit/Pages/InMemoryPageUnitTest.php b/packages/framework/tests/Unit/Pages/InMemoryPageUnitTest.php index ed76a546fca..1ee4d7d72b4 100644 --- a/packages/framework/tests/Unit/Pages/InMemoryPageUnitTest.php +++ b/packages/framework/tests/Unit/Pages/InMemoryPageUnitTest.php @@ -52,6 +52,14 @@ public function testFileExtension() ); } + public function testOutputFileExtension() + { + $this->assertSame( + '.html', + InMemoryPage::outputFileExtension() + ); + } + public function testSourcePath() { $this->assertSame( diff --git a/packages/framework/tests/Unit/Pages/MarkdownPageUnitTest.php b/packages/framework/tests/Unit/Pages/MarkdownPageUnitTest.php index 2401f58e84f..978385acd20 100644 --- a/packages/framework/tests/Unit/Pages/MarkdownPageUnitTest.php +++ b/packages/framework/tests/Unit/Pages/MarkdownPageUnitTest.php @@ -50,6 +50,14 @@ public function testFileExtension() ); } + public function testOutputFileExtension() + { + $this->assertSame( + '.html', + MarkdownPage::outputFileExtension() + ); + } + public function testSourcePath() { $this->assertSame( diff --git a/packages/framework/tests/Unit/Pages/MarkdownPostUnitTest.php b/packages/framework/tests/Unit/Pages/MarkdownPostUnitTest.php index bd72a040fa8..8d1f34d68b7 100644 --- a/packages/framework/tests/Unit/Pages/MarkdownPostUnitTest.php +++ b/packages/framework/tests/Unit/Pages/MarkdownPostUnitTest.php @@ -50,6 +50,14 @@ public function testFileExtension() ); } + public function testOutputFileExtension() + { + $this->assertSame( + '.html', + MarkdownPost::outputFileExtension() + ); + } + public function testSourcePath() { $this->assertSame( diff --git a/packages/testing/src/Common/BaseHydePageUnitTest.php b/packages/testing/src/Common/BaseHydePageUnitTest.php index a02b84be84f..7f16a63c517 100644 --- a/packages/testing/src/Common/BaseHydePageUnitTest.php +++ b/packages/testing/src/Common/BaseHydePageUnitTest.php @@ -104,6 +104,8 @@ abstract public function testToCoreDataObject(); abstract public function testFileExtension(); + abstract public function testOutputFileExtension(); + abstract public function testSourceDirectory(); abstract public function testCompile(); From d37fbeb97b70a8f0d6cd7ad4e32f0ffac420e006 Mon Sep 17 00:00:00 2001 From: Emma De Silva Date: Mon, 13 Jul 2026 05:19:49 +0200 Subject: [PATCH 005/117] Add feature tests for non-HTML in-memory page output Covers the user path of registering pages like robots.txt in a kernel booting callback and having them compiled to their declared output paths through the standard build command, including route registration, nested paths, and view-based compilation. Co-Authored-By: Claude Fable 5 --- .../tests/Feature/NonHtmlPageOutputTest.php | 109 ++++++++++++++++++ 1 file changed, 109 insertions(+) create mode 100644 packages/framework/tests/Feature/NonHtmlPageOutputTest.php diff --git a/packages/framework/tests/Feature/NonHtmlPageOutputTest.php b/packages/framework/tests/Feature/NonHtmlPageOutputTest.php new file mode 100644 index 00000000000..6de6afcd95c --- /dev/null +++ b/packages/framework/tests/Feature/NonHtmlPageOutputTest.php @@ -0,0 +1,109 @@ +booting(function (HydeKernel $kernel): void { + $kernel->pages()->addPage(new InMemoryPage('robots.txt', contents: "User-agent: *\nAllow: /")); + }); + + $this->artisan('build')->assertExitCode(0); + + $this->assertFileExists(Hyde::path('_site/robots.txt')); + $this->assertSame("User-agent: *\nAllow: /", file_get_contents(Hyde::path('_site/robots.txt'))); + $this->assertFileDoesNotExist(Hyde::path('_site/robots.txt.html')); + } + + public function testBuildCommandCompilesInMemoryPageWithJsonExtensionToDeclaredOutputPath() + { + Hyde::kernel()->booting(function (HydeKernel $kernel): void { + $kernel->pages()->addPage(new InMemoryPage('data.json', contents: '{"foo": "bar"}')); + }); + + $this->artisan('build')->assertExitCode(0); + + $this->assertFileExists(Hyde::path('_site/data.json')); + $this->assertSame('{"foo": "bar"}', file_get_contents(Hyde::path('_site/data.json'))); + } + + public function testBuildCommandCompilesInMemoryPageWithXmlExtensionToDeclaredOutputPath() + { + Hyde::kernel()->booting(function (HydeKernel $kernel): void { + $kernel->pages()->addPage(new InMemoryPage('custom.xml', contents: '')); + }); + + $this->artisan('build')->assertExitCode(0); + + $this->assertFileExists(Hyde::path('_site/custom.xml')); + $this->assertSame('', file_get_contents(Hyde::path('_site/custom.xml'))); + } + + public function testBuildCommandCompilesInMemoryPageWithDeclaredExtensionInNestedPath() + { + Hyde::kernel()->booting(function (HydeKernel $kernel): void { + $kernel->pages()->addPage(new InMemoryPage('foo/bar.txt', contents: 'baz')); + }); + + $this->artisan('build')->assertExitCode(0); + + $this->assertFileExists(Hyde::path('_site/foo/bar.txt')); + $this->assertSame('baz', file_get_contents(Hyde::path('_site/foo/bar.txt'))); + } + + public function testInMemoryPageWithDeclaredExtensionIsRegisteredAsRoute() + { + Hyde::kernel()->booting(function (HydeKernel $kernel): void { + $kernel->pages()->addPage(new InMemoryPage('robots.txt', contents: 'User-agent: *')); + }); + + $this->assertTrue(Routes::exists('robots.txt')); + $this->assertSame('robots.txt', Routes::get('robots.txt')->getOutputPath()); + } + + public function testStaticPageBuilderCompilesInMemoryPageWithDeclaredExtension() + { + StaticPageBuilder::handle(new InMemoryPage('llms.txt', contents: '# Hello World')); + + $this->assertFileExists(Hyde::path('_site/llms.txt')); + $this->assertSame('# Hello World', file_get_contents(Hyde::path('_site/llms.txt'))); + } + + public function testInMemoryPageWithDeclaredExtensionCanCompileUsingView() + { + $this->file('_pages/robots.blade.php', 'User-agent: {{ $agent }}'); + + StaticPageBuilder::handle(new InMemoryPage('robots.txt', ['agent' => '*'], view: 'robots')); + + $this->assertFileExists(Hyde::path('_site/robots.txt')); + $this->assertSame('User-agent: *', file_get_contents(Hyde::path('_site/robots.txt'))); + } +} From 4ef8368bee63f93caddb2b59286d37b238266c97 Mon Sep 17 00:00:00 2001 From: Emma De Silva Date: Mon, 13 Jul 2026 05:23:30 +0200 Subject: [PATCH 006/117] Add release notes for non-HTML page output support Co-Authored-By: Claude Fable 5 --- HYDEPHP_V3_PLANNING.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/HYDEPHP_V3_PLANNING.md b/HYDEPHP_V3_PLANNING.md index a91417df524..b56223c793a 100644 --- a/HYDEPHP_V3_PLANNING.md +++ b/HYDEPHP_V3_PLANNING.md @@ -24,6 +24,7 @@ Having this document in code lets us know the devlopment state at any given poin - 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)) - Added built-in terminal code blocks using the `terminal` fence language. Command prompts are styled for selection-free copying, and `terminal xml` supports four Symfony-style Console formatter tags. ([#2188](https://github.com/hydephp/develop/issues/2188), [#2485](https://github.com/hydephp/develop/issues/2485)) +- Pages can now compile to non-HTML output files. Page classes declare their output file extension through the new static `$outputFileExtension` property (defaulting to `.html`), and in-memory page identifiers can declare a `.json`, `.txt`, or `.xml` extension directly, so `InMemoryPage::make('robots.txt', contents: ...)` compiles to `_site/robots.txt` through the standard site build. Only the HTML extension is implicit in route keys: pages compiled to non-HTML files keep their extension in the route key, formalizing the convention already used by the documentation search index. ### Feature Changes @@ -42,6 +43,7 @@ Having this document in code lets us know the devlopment state at any given poin ### Breaking Changes +- In-memory pages (including redirects) with identifiers ending in `.json`, `.txt`, or `.xml` now compile to that path as-is, instead of having `.html` appended to the output filename. Sites relying on the old double-extension output (like `data.json.html`) need to rename such identifiers. - Removed `Redirect::create()`, `Redirect::store()`, and the `Redirect` constructor's `showText` argument. Redirects must now be declared in `hyde.redirects`, keeping all generated output inside the kernel-owned build graph. Redirect routes are intrinsically excluded from navigation menus and sitemaps, and always include an accessible fallback link. - Removed the `InMemoryPage` instance macro API. Dynamic contents should now be supplied with a closure, while custom methods and other behavior belong in an `InMemoryPage` subclass. - Removed `InMemoryPage` content-source precedence. Calls that previously supplied both `contents` and `view` must retain only the intended source; positional view calls that used an empty-string contents placeholder must use `null` instead. From 1d5b3d7ef5230174e795ee5d4e370b425a2c2a7e Mon Sep 17 00:00:00 2001 From: Emma De Silva Date: Mon, 13 Jul 2026 05:34:05 +0200 Subject: [PATCH 007/117] Include declared non-HTML output file extensions in route keys Completes the route key convention for page classes declaring a non-HTML output file extension: RouteKey::fromPage() now appends the declared extension to the key (unless the identifier already ends with it), so the route key always equals the output path with only the HTML extension implicit. Without this, a class declaring '.txt' would get the route key 'robots' but the output path 'robots.txt', breaking route resolution. Co-Authored-By: Claude Fable 5 --- .../framework/src/Pages/Concerns/HydePage.php | 5 +++- .../framework/src/Support/Models/RouteKey.php | 20 ++++++++++++- .../framework/tests/Feature/HydePageTest.php | 5 ++++ .../framework/tests/Unit/RouteKeyTest.php | 28 +++++++++++++++++++ 4 files changed, 56 insertions(+), 2 deletions(-) diff --git a/packages/framework/src/Pages/Concerns/HydePage.php b/packages/framework/src/Pages/Concerns/HydePage.php index 215c9ce2f51..d730f0382f4 100644 --- a/packages/framework/src/Pages/Concerns/HydePage.php +++ b/packages/framework/src/Pages/Concerns/HydePage.php @@ -219,7 +219,10 @@ public static function sourcePath(string $identifier): string */ public static function outputPath(string $identifier): string { - return RouteKey::fromPage(static::class, $identifier).static::outputFileExtension(); + $routeKey = RouteKey::fromPage(static::class, $identifier); + + // Since only the HTML extension is implicit in route keys, other extensions are already part of the key. + return static::outputFileExtension() === '.html' ? "$routeKey.html" : (string) $routeKey; } /** diff --git a/packages/framework/src/Support/Models/RouteKey.php b/packages/framework/src/Support/Models/RouteKey.php index 217cdf00c75..0faaeb1310f 100644 --- a/packages/framework/src/Support/Models/RouteKey.php +++ b/packages/framework/src/Support/Models/RouteKey.php @@ -11,6 +11,7 @@ use Hyde\Framework\Features\Blogging\BlogPostDatePrefixHelper; use function Hyde\unslash; +use function str_ends_with; /** * Route keys provide the core bindings of the HydePHP routing system as they are what canonically identifies a page. @@ -56,7 +57,24 @@ public static function fromPage(string $pageClass, string $identifier): self { $identifier = self::stripPrefixIfNeeded($pageClass, $identifier); - return new self(unslash("{$pageClass::baseRouteKey()}/$identifier")); + return new self(unslash("{$pageClass::baseRouteKey()}/$identifier".self::outputFileExtensionIfNeeded($pageClass, $identifier))); + } + + /** + * Since only the HTML extension is implicit in route keys, pages compiled to non-HTML + * files include the output file extension declared by their page class in the key. + * + * @param class-string<\Hyde\Pages\Concerns\HydePage> $pageClass + */ + protected static function outputFileExtensionIfNeeded(string $pageClass, string $identifier): string + { + $extension = $pageClass::outputFileExtension(); + + if ($extension === '.html' || str_ends_with($identifier, $extension)) { + return ''; + } + + return $extension; } /** diff --git a/packages/framework/tests/Feature/HydePageTest.php b/packages/framework/tests/Feature/HydePageTest.php index 5395f7d557e..35ea2a5d4ac 100644 --- a/packages/framework/tests/Feature/HydePageTest.php +++ b/packages/framework/tests/Feature/HydePageTest.php @@ -128,6 +128,11 @@ public function testOutputPathUsesTheOutputFileExtensionOfThePageClass() $this->assertSame('output/hello-world.txt', NonHtmlOutputTestPage::outputPath('hello-world')); } + public function testGetRouteKeyForPageWithNonHtmlOutputFileExtensionIncludesExtension() + { + $this->assertSame('output/hello-world.txt', (new NonHtmlOutputTestPage('hello-world'))->getRouteKey()); + } + public function testGetOutputPathUsesTheOutputFileExtensionOfThePageClass() { $this->assertSame('output/hello-world.txt', (new NonHtmlOutputTestPage('hello-world'))->getOutputPath()); diff --git a/packages/framework/tests/Unit/RouteKeyTest.php b/packages/framework/tests/Unit/RouteKeyTest.php index 0eb00c91316..7540c533271 100644 --- a/packages/framework/tests/Unit/RouteKeyTest.php +++ b/packages/framework/tests/Unit/RouteKeyTest.php @@ -9,6 +9,7 @@ use Hyde\Pages\InMemoryPage; use Hyde\Pages\MarkdownPage; use Hyde\Pages\MarkdownPost; +use Hyde\Pages\Concerns\HydePage; use Hyde\Pages\DocumentationPage; use Hyde\Support\Models\RouteKey; use Hyde\Testing\UnitTestCase; @@ -81,6 +82,23 @@ public function testFromPageWithInMemoryPage() $this->assertEquals(new RouteKey('foo/bar'), RouteKey::fromPage(InMemoryPage::class, 'foo/bar')); } + public function testFromPageWithInMemoryPageIdentifierDeclaringOutputFileExtension() + { + $this->assertEquals(new RouteKey('robots.txt'), RouteKey::fromPage(InMemoryPage::class, 'robots.txt')); + $this->assertEquals(new RouteKey('docs/search.json'), RouteKey::fromPage(InMemoryPage::class, 'docs/search.json')); + } + + public function testFromPageWithNonHtmlOutputFileExtensionIncludesExtensionInRouteKey() + { + $this->assertEquals(new RouteKey('foo.txt'), RouteKey::fromPage(NonHtmlOutputPageStub::class, 'foo')); + $this->assertEquals(new RouteKey('foo/bar.txt'), RouteKey::fromPage(NonHtmlOutputPageStub::class, 'foo/bar')); + } + + public function testFromPageWithNonHtmlOutputFileExtensionDoesNotDuplicateExtensionAlreadyInIdentifier() + { + $this->assertEquals(new RouteKey('foo.txt'), RouteKey::fromPage(NonHtmlOutputPageStub::class, 'foo.txt')); + } + public function testFromPageWithCustomOutputDirectory() { MarkdownPage::setOutputDirectory('foo'); @@ -136,3 +154,13 @@ public function testItDoesNotExtractNonNumericalFilenamePrefixes() $this->assertSame('docs/abc-bar', RouteKey::fromPage(DocumentationPage::class, 'abc-bar')->get()); } } + +class NonHtmlOutputPageStub extends HydePage +{ + public static string $outputFileExtension = '.txt'; + + public function compile(): string + { + return ''; + } +} From 6806699ccbf903650012f1e636f79147901b4fcc Mon Sep 17 00:00:00 2001 From: Emma De Silva Date: Mon, 13 Jul 2026 05:34:05 +0200 Subject: [PATCH 008/117] Document PR 1 decisions in the epic and refine release notes Records the resolved design decisions (identifier allowlist chosen for InMemoryPage, route key extension handling in RouteKey::fromPage, no setter, redirects intentionally not special-cased) and rewords the breaking-change note to reflect that double-extension output was never practically relied upon. Co-Authored-By: Claude Fable 5 --- EPIC_NON_HTML_PAGES.md | 28 +++++++++++++++++++++++++++- HYDEPHP_V3_PLANNING.md | 2 +- 2 files changed, 28 insertions(+), 2 deletions(-) diff --git a/EPIC_NON_HTML_PAGES.md b/EPIC_NON_HTML_PAGES.md index c98ba927e67..4d412c57e56 100644 --- a/EPIC_NON_HTML_PAGES.md +++ b/EPIC_NON_HTML_PAGES.md @@ -68,6 +68,11 @@ it is proven in production, requires no realtime-compiler lookup changes (`PageRouter::normalizePath()` only strips `.html`), and lets `docs/search` (page) and `docs/search.json` (index) coexist as distinct routes, which they already do. +> **Implemented (PR 1):** `RouteKey::fromPage()` appends the page class's declared +> non-HTML output extension to the key (skipping it when the identifier already ends +> with it), so classes like PR 3's `TextPage` are D1-compliant out of the box and +> PR 2 can rely on "route key == request path with only `.html` stripped" universally. + ### D2: Output extension is declared, not inferred Do **not** infer "this identifier has an extension" from a dot in the identifier — @@ -84,6 +89,13 @@ versioned docs route keys like `docs/1.x/index` would false-positive explicit constructor/`make()` parameter. Leaning (a) with allowlist since `InMemoryPage::make('robots.txt', contents: $txt)` is the DX we actually want. +> **Decided (PR 1):** option (a). The allowlist (`.json`, `.txt`, `.xml`) lives in +> `InMemoryPage::identifierDeclaresOutputFileExtension()`, overridable by subclasses. +> `Redirect` deliberately inherits it (no special case): a redirect declared for a +> non-HTML path now emits its file at that exact path instead of an unreachable +> double-extension path. Neither behavior can make a meta-refresh work when the +> server serves the file as non-HTML — document that limitation in PR 8. + ### D3: Sitemap inclusion becomes a page-level concern Replace the `instanceof Redirect` filter in `SitemapGenerator` with a @@ -112,7 +124,7 @@ feature default → config tweaks → file on disk → fully dynamic page in cod ## Work breakdown (one PR each, in dependency order) -### PR 1 — Foundation: declared output extensions on `HydePage` +### PR 1 — Foundation: declared output extensions on `HydePage` ✅ Implemented Goal: any page class can emit a non-`.html` file without overriding `getOutputPath()`. @@ -124,6 +136,20 @@ Goal: any page class can emit a non-`.html` file without overriding `getOutputPa - Refactor `DocumentationSearchIndex` to drop its `getOutputPath()` override. - Pure refactor for existing sites: no compiled-output changes. +Implementation notes (branch `v3/non-html-pages-foundation`): + +- An `outputFileExtension()` accessor accompanies the property, matching the other + static accessors, and is part of the `BaseHydePageUnitTest` contract. No setter + was added — the existing setters exist for config-driven source customization, + which does not apply here; subclasses redeclare the property. +- Non-HTML extension handling was placed in `RouteKey::fromPage()` (see D1 note) + rather than only in `outputPath()`, so route keys and output paths cannot drift. +- One qualification to "no compiled-output changes": in-memory page identifiers + (including `hyde.redirects` paths) that already end in an allowlisted extension + previously produced double-extension output (`data.json.html`); they now compile + to the declared path as-is. Recorded in the v3 release notes as a breaking change, + though the old output was almost certainly never intended or relied upon. + ### PR 2 — Realtime compiler: route-first resolution for non-HTML paths Goal: `hyde serve` serves any registered route regardless of extension; no diff --git a/HYDEPHP_V3_PLANNING.md b/HYDEPHP_V3_PLANNING.md index b56223c793a..ce24cc3f5c2 100644 --- a/HYDEPHP_V3_PLANNING.md +++ b/HYDEPHP_V3_PLANNING.md @@ -43,7 +43,7 @@ Having this document in code lets us know the devlopment state at any given poin ### Breaking Changes -- In-memory pages (including redirects) with identifiers ending in `.json`, `.txt`, or `.xml` now compile to that path as-is, instead of having `.html` appended to the output filename. Sites relying on the old double-extension output (like `data.json.html`) need to rename such identifiers. +- In-memory page identifiers ending in `.json`, `.txt`, or `.xml` (including redirect paths declared in `hyde.redirects`) now compile to that path as-is instead of gaining a second `.html` extension. The old double-extension outputs (like `data.json.html`) were almost certainly never intended, so no real sites are expected to be affected. - Removed `Redirect::create()`, `Redirect::store()`, and the `Redirect` constructor's `showText` argument. Redirects must now be declared in `hyde.redirects`, keeping all generated output inside the kernel-owned build graph. Redirect routes are intrinsically excluded from navigation menus and sitemaps, and always include an accessible fallback link. - Removed the `InMemoryPage` instance macro API. Dynamic contents should now be supplied with a closure, while custom methods and other behavior belong in an `InMemoryPage` subclass. - Removed `InMemoryPage` content-source precedence. Calls that previously supplied both `contents` and `view` must retain only the intended source; positional view calls that used an empty-string contents placeholder must use `null` instead. From 1c9c4629ec4342e55a545d157aa082056e556e75 Mon Sep 17 00:00:00 2001 From: Emma De Silva Date: Mon, 13 Jul 2026 14:15:39 +0200 Subject: [PATCH 009/117] Build documentation --- .../partials/hyde-pages-api/hyde-page-methods.md | 14 +++++++++++--- .../hyde-pages-api/in-memory-page-methods.md | 12 ++++++++++++ 2 files changed, 23 insertions(+), 3 deletions(-) diff --git a/docs/_data/partials/hyde-pages-api/hyde-page-methods.md b/docs/_data/partials/hyde-pages-api/hyde-page-methods.md index bb6922fd9cd..f04bf3483dd 100644 --- a/docs/_data/partials/hyde-pages-api/hyde-page-methods.md +++ b/docs/_data/partials/hyde-pages-api/hyde-page-methods.md @@ -1,7 +1,7 @@
- + #### `make()` @@ -82,6 +82,14 @@ Get the file extension of the source files for the page type. HydePage::fileExtension(): string ``` +#### `outputFileExtension()` + +Get the file extension of the compiled output files for the page type, including the leading dot. + +```php +HydePage::outputFileExtension(): string +``` + #### `setSourceDirectory()` Set the output directory for the page type. @@ -193,9 +201,9 @@ $page->getOutputPath(): string // Path relative to the site output directory. Get the route key for the page. -The route key is the page URL path, relative to the site root, but without any file extensions. For example, if the page will be saved to `_site/docs/index.html`, the key is `docs/index`. +The route key is the page URL path, relative to the site root, but without the HTML file extension. For example, if the page will be saved to `_site/docs/index.html`, the key is `docs/index`. Pages compiled to non-HTML files keep their extension in the route key, so a page saved to `_site/docs/search.json` has the route key `docs/search.json`. -Route keys are used to identify page routes, similar to how named routes work in Laravel, only that here the name is not just arbitrary, but also defines the output location, as the route key is used to determine the output path which is `$routeKey.html`. +Route keys are used to identify page routes, similar to how named routes work in Laravel, only that here the name is not just arbitrary, but also defines the output location, as the route key is used to determine the output path which is `$routeKey.html`, or the route key as-is for pages compiled to non-HTML files. ```php $page->getRouteKey(): string diff --git a/docs/_data/partials/hyde-pages-api/in-memory-page-methods.md b/docs/_data/partials/hyde-pages-api/in-memory-page-methods.md index b4842f630d2..bfe84ac1fd0 100644 --- a/docs/_data/partials/hyde-pages-api/in-memory-page-methods.md +++ b/docs/_data/partials/hyde-pages-api/in-memory-page-methods.md @@ -12,6 +12,16 @@ Static alias for the constructor. InMemoryPage::make(string|(Closure(): string)|(Closure(static):, Hyde\Markdown\Models\FrontMatter|array $matter, Closure|string|null $contents, string $view): static ``` +#### `outputPath()` + +Qualify a page identifier into a target output file path, relative to the _site output directory. + +If the identifier declares a supported non-HTML output file extension, like "robots.txt", the page is saved to that path as-is, instead of having the HTML extension appended. + +```php +InMemoryPage::outputPath(string $identifier): string +``` + #### `__construct()` Create a new in-memory (virtual) page instance. @@ -22,6 +32,8 @@ Contents and views cannot be used together. Omit both to create an empty page. A View values ending in `.blade.php` are treated as Blade file paths. Other values are treated as registered Laravel view keys. +Identifiers ending in `.json`, `.txt`, or `.xml` retain that extension in the output path. + ```php /** @param string|(Closure(): string)|(Closure(static): string)|null $contents */ $page = new InMemoryPage(string $identifier, FrontMatter|array $matter, string|(Closure(): string)|(Closure(static):, string|null $view): void From 9932365e4a264bb546f107b6365140defbfc48aa Mon Sep 17 00:00:00 2001 From: Emma De Silva Date: Mon, 13 Jul 2026 14:44:18 +0200 Subject: [PATCH 010/117] Rename page source file extension API to sourceExtension Renames HydePage::$fileExtension to $sourceExtension, with the fileExtension() and setFileExtension() methods becoming sourceExtension() and setSourceExtension(). The old name was underspecified: it actually meant the source file extension, which becomes ambiguous now that pages also declare an output extension. Co-Authored-By: Claude Fable 5 --- .../src/Foundation/Kernel/FileCollection.php | 2 +- packages/framework/src/Pages/BladePage.php | 2 +- .../src/Pages/Concerns/BaseMarkdownPage.php | 2 +- .../framework/src/Pages/Concerns/HydePage.php | 20 +++--- packages/framework/src/Pages/HtmlPage.php | 2 +- packages/framework/src/Pages/InMemoryPage.php | 2 +- .../tests/Feature/DiscoveryServiceTest.php | 6 +- .../Feature/HydeExtensionFeatureTest.php | 4 +- .../framework/tests/Feature/HydePageTest.php | 62 +++++++++---------- .../tests/Unit/ExtensionsUnitTest.php | 2 +- .../NumericalPageOrderingHelperUnitTest.php | 24 +++---- .../tests/Unit/Pages/BladePageUnitTest.php | 4 +- .../Unit/Pages/DocumentationPageUnitTest.php | 4 +- .../tests/Unit/Pages/HtmlPageUnitTest.php | 4 +- .../tests/Unit/Pages/InMemoryPageUnitTest.php | 4 +- .../tests/Unit/Pages/MarkdownPageUnitTest.php | 4 +- .../tests/Unit/Pages/MarkdownPostUnitTest.php | 4 +- .../src/Http/DashboardController.php | 4 +- .../src/Common/BaseHydePageUnitTest.php | 2 +- 19 files changed, 79 insertions(+), 79 deletions(-) diff --git a/packages/framework/src/Foundation/Kernel/FileCollection.php b/packages/framework/src/Foundation/Kernel/FileCollection.php index 49c67e49aa3..7659cbbd9df 100644 --- a/packages/framework/src/Foundation/Kernel/FileCollection.php +++ b/packages/framework/src/Foundation/Kernel/FileCollection.php @@ -59,7 +59,7 @@ protected function runExtensionHandlers(): void protected function discoverFilesFor(string $pageClass): void { // Scan the source directory, and directories therein, for files that match the model's file extension. - foreach (Filesystem::findFiles($pageClass::sourceDirectory(), $pageClass::fileExtension(), true) as $path) { + foreach (Filesystem::findFiles($pageClass::sourceDirectory(), $pageClass::sourceExtension(), true) as $path) { if (! str_starts_with(basename((string) $path), '_')) { $this->addFile(SourceFile::make($path, $pageClass)); } diff --git a/packages/framework/src/Pages/BladePage.php b/packages/framework/src/Pages/BladePage.php index 5dd6bb8084b..22384dd26c9 100644 --- a/packages/framework/src/Pages/BladePage.php +++ b/packages/framework/src/Pages/BladePage.php @@ -21,7 +21,7 @@ class BladePage extends HydePage { public static string $sourceDirectory = '_pages'; public static string $outputDirectory = ''; - public static string $fileExtension = '.blade.php'; + public static string $sourceExtension = '.blade.php'; /** @param string $identifier The identifier, which also serves as the view key. */ public function __construct(string $identifier = '', FrontMatter|array $matter = []) diff --git a/packages/framework/src/Pages/Concerns/BaseMarkdownPage.php b/packages/framework/src/Pages/Concerns/BaseMarkdownPage.php index ba738c6a422..d9f32e95d08 100644 --- a/packages/framework/src/Pages/Concerns/BaseMarkdownPage.php +++ b/packages/framework/src/Pages/Concerns/BaseMarkdownPage.php @@ -25,7 +25,7 @@ abstract class BaseMarkdownPage extends HydePage implements MarkdownDocumentCont { public Markdown $markdown; - public static string $fileExtension = '.md'; + public static string $sourceExtension = '.md'; /** @inheritDoc */ public static function make(string $identifier = '', FrontMatter|array $matter = [], Markdown|string $markdown = ''): static diff --git a/packages/framework/src/Pages/Concerns/HydePage.php b/packages/framework/src/Pages/Concerns/HydePage.php index d730f0382f4..65f383326d8 100644 --- a/packages/framework/src/Pages/Concerns/HydePage.php +++ b/packages/framework/src/Pages/Concerns/HydePage.php @@ -56,7 +56,7 @@ abstract class HydePage implements PageSchema, SerializableContract public static string $sourceDirectory; public static string $outputDirectory; - public static string $fileExtension; + public static string $sourceExtension; public static string $outputFileExtension = '.html'; public static string $template; @@ -96,7 +96,7 @@ public function __construct(string $identifier = '', FrontMatter|array $matter = */ public static function isDiscoverable(): bool { - return isset(static::$sourceDirectory, static::$outputDirectory, static::$fileExtension) && filled(static::$sourceDirectory); + return isset(static::$sourceDirectory, static::$outputDirectory, static::$sourceExtension) && filled(static::$sourceDirectory); } // Section: Query @@ -167,11 +167,11 @@ public static function outputDirectory(): string } /** - * Get the file extension of the source files for the page type. + * Get the file extension of the source files for the page type, such as `.md` or `.blade.php`. */ - public static function fileExtension(): string + public static function sourceExtension(): string { - return static::$fileExtension ?? ''; + return static::$sourceExtension ?? ''; } /** @@ -199,11 +199,11 @@ public static function setOutputDirectory(string $outputDirectory): void } /** - * Set the file extension for the page type. + * Set the source file extension for the page type. */ - public static function setFileExtension(string $fileExtension): void + public static function setSourceExtension(string $sourceExtension): void { - static::$fileExtension = rtrim('.'.ltrim($fileExtension, '.'), '.'); + static::$sourceExtension = rtrim('.'.ltrim($sourceExtension, '.'), '.'); } /** @@ -211,7 +211,7 @@ public static function setFileExtension(string $fileExtension): void */ public static function sourcePath(string $identifier): string { - return unslash(static::sourceDirectory().'/'.unslash($identifier).static::fileExtension()); + return unslash(static::sourceDirectory().'/'.unslash($identifier).static::sourceExtension()); } /** @@ -244,7 +244,7 @@ public static function pathToIdentifier(string $path): string { return unslash(Str::between(Hyde::pathToRelative($path), static::sourceDirectory().'/', - static::fileExtension()) + static::sourceExtension()) ); } diff --git a/packages/framework/src/Pages/HtmlPage.php b/packages/framework/src/Pages/HtmlPage.php index eef1f7a9cce..24870704505 100644 --- a/packages/framework/src/Pages/HtmlPage.php +++ b/packages/framework/src/Pages/HtmlPage.php @@ -18,7 +18,7 @@ class HtmlPage extends HydePage { public static string $sourceDirectory = '_pages'; public static string $outputDirectory = ''; - public static string $fileExtension = '.html'; + public static string $sourceExtension = '.html'; public function contents(): string { diff --git a/packages/framework/src/Pages/InMemoryPage.php b/packages/framework/src/Pages/InMemoryPage.php index 19a56a382d1..bcd3ab6e05c 100644 --- a/packages/framework/src/Pages/InMemoryPage.php +++ b/packages/framework/src/Pages/InMemoryPage.php @@ -34,7 +34,7 @@ class InMemoryPage extends HydePage { public static string $sourceDirectory; public static string $outputDirectory; - public static string $fileExtension; + public static string $sourceExtension; /** * The literal page contents, or a closure that generates them at compile time. diff --git a/packages/framework/tests/Feature/DiscoveryServiceTest.php b/packages/framework/tests/Feature/DiscoveryServiceTest.php index 814bc0a437e..de5de419a09 100644 --- a/packages/framework/tests/Feature/DiscoveryServiceTest.php +++ b/packages/framework/tests/Feature/DiscoveryServiceTest.php @@ -68,17 +68,17 @@ public function testGetSourceFileListForModelMethodFindsCustomizedSourceDirector MarkdownPage::setSourceDirectory('_pages'); } - public function testGetSourceFileListForModelMethodFindsCustomizedFileExtension() + public function testGetSourceFileListForModelMethodFindsCustomizedSourceExtension() { $this->directory('foo'); MarkdownPage::setSourceDirectory('foo'); - MarkdownPage::setFileExtension('.foo'); + MarkdownPage::setSourceExtension('.foo'); $this->unitTestMarkdownBasedPageList(MarkdownPage::class, 'foo/foo.foo', 'foo'); MarkdownPage::setSourceDirectory('_pages'); - MarkdownPage::setFileExtension('.md'); + MarkdownPage::setSourceExtension('.md'); } public function testGetMediaAssetFiles() diff --git a/packages/framework/tests/Feature/HydeExtensionFeatureTest.php b/packages/framework/tests/Feature/HydeExtensionFeatureTest.php index d48aa5d2e69..4fe121218cf 100644 --- a/packages/framework/tests/Feature/HydeExtensionFeatureTest.php +++ b/packages/framework/tests/Feature/HydeExtensionFeatureTest.php @@ -199,7 +199,7 @@ class HydeExtensionTestPage extends HydePage { public static string $sourceDirectory = 'foo'; public static string $outputDirectory = 'foo'; - public static string $fileExtension = '.txt'; + public static string $sourceExtension = '.txt'; public function compile(): string { @@ -211,7 +211,7 @@ class TestPageClass extends HydePage { public static string $sourceDirectory = 'foo'; public static string $outputDirectory = 'foo'; - public static string $fileExtension = '.txt'; + public static string $sourceExtension = '.txt'; public function compile(): string { diff --git a/packages/framework/tests/Feature/HydePageTest.php b/packages/framework/tests/Feature/HydePageTest.php index 35ea2a5d4ac..ffa414062ad 100644 --- a/packages/framework/tests/Feature/HydePageTest.php +++ b/packages/framework/tests/Feature/HydePageTest.php @@ -46,9 +46,9 @@ public function testBaseOutputDirectory() $this->assertSame('', HydePage::outputDirectory()); } - public function testBaseFileExtension() + public function testBaseSourceExtension() { - $this->assertSame('', HydePage::fileExtension()); + $this->assertSame('', HydePage::sourceExtension()); } public function testBaseOutputFileExtension() @@ -98,9 +98,9 @@ public function testOutputDirectory() $this->assertSame('output', TestPage::outputDirectory()); } - public function testFileExtension() + public function testSourceExtension() { - $this->assertSame('.md', TestPage::fileExtension()); + $this->assertSame('.md', TestPage::sourceExtension()); } public function testOutputFileExtension() @@ -288,27 +288,27 @@ public function testSetOutputDirectoryTrimsTrailingSlashes() $this->resetDirectoryConfiguration(); } - public function testGetFileExtensionReturnsStaticProperty() + public function testGetSourceExtensionReturnsStaticProperty() { - MarkdownPage::setFileExtension('.foo'); + MarkdownPage::setSourceExtension('.foo'); - $this->assertSame('.foo', MarkdownPage::fileExtension()); + $this->assertSame('.foo', MarkdownPage::sourceExtension()); $this->resetDirectoryConfiguration(); } - public function testSetFileExtensionForcesLeadingPeriod() + public function testSetSourceExtensionForcesLeadingPeriod() { - MarkdownPage::setFileExtension('foo'); + MarkdownPage::setSourceExtension('foo'); - $this->assertSame('.foo', MarkdownPage::fileExtension()); + $this->assertSame('.foo', MarkdownPage::sourceExtension()); $this->resetDirectoryConfiguration(); } - public function testSetFileExtensionRemovesTrailingPeriod() + public function testSetSourceExtensionRemovesTrailingPeriod() { - MarkdownPage::setFileExtension('foo.'); + MarkdownPage::setSourceExtension('foo.'); - $this->assertSame('.foo', MarkdownPage::fileExtension()); + $this->assertSame('.foo', MarkdownPage::sourceExtension()); $this->resetDirectoryConfiguration(); } @@ -330,10 +330,10 @@ public function testSetOutputDirectory() $this->assertSame('foo', ConfigurableSourcesTestPage::outputDirectory()); } - public function testSetFileExtension() + public function testSetSourceExtension() { - ConfigurableSourcesTestPage::setFileExtension('.foo'); - $this->assertSame('.foo', ConfigurableSourcesTestPage::fileExtension()); + ConfigurableSourcesTestPage::setSourceExtension('.foo'); + $this->assertSame('.foo', ConfigurableSourcesTestPage::sourceExtension()); } public function testStaticGetMethodReturnsDiscoveredPage() @@ -396,7 +396,7 @@ public function testQualifyBasenameTrimsSlashesFromInput() public function testQualifyBasenameUsesTheStaticProperties() { MarkdownPage::setSourceDirectory('foo'); - MarkdownPage::setFileExtension('txt'); + MarkdownPage::setSourceExtension('txt'); $this->assertSame('foo/bar.txt', MarkdownPage::sourcePath('bar')); @@ -539,7 +539,7 @@ public function testAllPageModelsHaveConfiguredOutputDirectory() } } - public function testAllPageModelsHaveConfiguredFileExtension() + public function testAllPageModelsHaveConfiguredSourceExtension() { $pages = [ BladePage::class => '.blade.php', @@ -550,7 +550,7 @@ public function testAllPageModelsHaveConfiguredFileExtension() foreach ($pages as $page => $expected) { assert(is_a($page, HydePage::class, true)); - $this->assertSame($expected, $page::fileExtension()); + $this->assertSame($expected, $page::sourceExtension()); } } @@ -569,14 +569,14 @@ public function testAbstractMarkdownPageHasMarkdownDocumentProperty() $this->assertTrue(property_exists(BaseMarkdownPage::class, 'markdown')); } - public function testAbstractMarkdownPageHasFileExtensionProperty() + public function testAbstractMarkdownPageHasSourceExtensionProperty() { - $this->assertTrue(property_exists(BaseMarkdownPage::class, 'fileExtension')); + $this->assertTrue(property_exists(BaseMarkdownPage::class, 'sourceExtension')); } - public function testAbstractMarkdownPageFileExtensionPropertyIsSetToMd() + public function testAbstractMarkdownPageSourceExtensionPropertyIsSetToMd() { - $this->assertSame('.md', BaseMarkdownPage::fileExtension()); + $this->assertSame('.md', BaseMarkdownPage::sourceExtension()); } public function testAbstractMarkdownPageConstructorArgumentsAreOptional() @@ -1295,7 +1295,7 @@ protected function resetDirectoryConfiguration(): void MarkdownPage::setSourceDirectory('_pages'); MarkdownPost::setSourceDirectory('_posts'); DocumentationPage::setSourceDirectory('_docs'); - MarkdownPage::setFileExtension('.md'); + MarkdownPage::setSourceExtension('.md'); } } @@ -1305,7 +1305,7 @@ class TestPage extends HydePage public static string $sourceDirectory = 'source'; public static string $outputDirectory = 'output'; - public static string $fileExtension = '.md'; + public static string $sourceExtension = '.md'; public static string $template = 'template'; } @@ -1315,7 +1315,7 @@ class NonHtmlOutputTestPage extends HydePage public static string $sourceDirectory = 'source'; public static string $outputDirectory = 'output'; - public static string $fileExtension = '.txt'; + public static string $sourceExtension = '.txt'; public static string $outputFileExtension = '.txt'; public static string $template = 'template'; } @@ -1326,7 +1326,7 @@ class ConfigurableSourcesTestPage extends HydePage public static string $sourceDirectory; public static string $outputDirectory; - public static string $fileExtension; + public static string $sourceExtension; public static string $template; } @@ -1336,7 +1336,7 @@ class DiscoverableTestPage extends HydePage public static string $sourceDirectory = 'foo'; public static string $outputDirectory = 'bar'; - public static string $fileExtension = 'baz'; + public static string $sourceExtension = 'baz'; public static string $template; } @@ -1346,7 +1346,7 @@ class NonDiscoverableTestPage extends HydePage public static string $sourceDirectory; public static string $outputDirectory; - public static string $fileExtension; + public static string $sourceExtension; } class PartiallyDiscoverablePage extends HydePage @@ -1355,7 +1355,7 @@ class PartiallyDiscoverablePage extends HydePage public static string $sourceDirectory = 'foo'; public static string $outputDirectory; - public static string $fileExtension; + public static string $sourceExtension; } class DiscoverablePageWithInvalidSourceDirectory extends HydePage @@ -1364,7 +1364,7 @@ class DiscoverablePageWithInvalidSourceDirectory extends HydePage public static string $sourceDirectory = ''; public static string $outputDirectory = ''; - public static string $fileExtension = ''; + public static string $sourceExtension = ''; } class MissingSourceDirectoryMarkdownPage extends BaseMarkdownPage diff --git a/packages/framework/tests/Unit/ExtensionsUnitTest.php b/packages/framework/tests/Unit/ExtensionsUnitTest.php index d1666295977..966612fd4d1 100644 --- a/packages/framework/tests/Unit/ExtensionsUnitTest.php +++ b/packages/framework/tests/Unit/ExtensionsUnitTest.php @@ -275,7 +275,7 @@ class HydeExtensionTestPage extends HydePage { public static string $sourceDirectory = 'foo'; public static string $outputDirectory = 'foo'; - public static string $fileExtension = '.txt'; + public static string $sourceExtension = '.txt'; public function compile(): string { diff --git a/packages/framework/tests/Unit/NumericalPageOrderingHelperUnitTest.php b/packages/framework/tests/Unit/NumericalPageOrderingHelperUnitTest.php index f317b106524..28f113c77aa 100644 --- a/packages/framework/tests/Unit/NumericalPageOrderingHelperUnitTest.php +++ b/packages/framework/tests/Unit/NumericalPageOrderingHelperUnitTest.php @@ -127,9 +127,9 @@ public function testIdentifiersForDeeplyNestedPagesWithoutNumericalPrefixesAreNo #[\PHPUnit\Framework\Attributes\DataProvider('pageTypeProvider')] public function testIdentifiersWithNumericalPrefixesAreDetectedForPageType(string $type) { - $this->assertTrue(NumericalPageOrderingHelper::hasNumericalPrefix('01-home.'.$type::$fileExtension)); - $this->assertTrue(NumericalPageOrderingHelper::hasNumericalPrefix('02-about.'.$type::$fileExtension)); - $this->assertTrue(NumericalPageOrderingHelper::hasNumericalPrefix('03-contact.'.$type::$fileExtension)); + $this->assertTrue(NumericalPageOrderingHelper::hasNumericalPrefix('01-home.'.$type::$sourceExtension)); + $this->assertTrue(NumericalPageOrderingHelper::hasNumericalPrefix('02-about.'.$type::$sourceExtension)); + $this->assertTrue(NumericalPageOrderingHelper::hasNumericalPrefix('03-contact.'.$type::$sourceExtension)); } /** @@ -138,9 +138,9 @@ public function testIdentifiersWithNumericalPrefixesAreDetectedForPageType(strin #[\PHPUnit\Framework\Attributes\DataProvider('pageTypeProvider')] public function testIdentifiersWithoutNumericalPrefixesAreNotDetectedForPageType(string $type) { - $this->assertFalse(NumericalPageOrderingHelper::hasNumericalPrefix('home.'.$type::$fileExtension)); - $this->assertFalse(NumericalPageOrderingHelper::hasNumericalPrefix('about.'.$type::$fileExtension)); - $this->assertFalse(NumericalPageOrderingHelper::hasNumericalPrefix('contact.'.$type::$fileExtension)); + $this->assertFalse(NumericalPageOrderingHelper::hasNumericalPrefix('home.'.$type::$sourceExtension)); + $this->assertFalse(NumericalPageOrderingHelper::hasNumericalPrefix('about.'.$type::$sourceExtension)); + $this->assertFalse(NumericalPageOrderingHelper::hasNumericalPrefix('contact.'.$type::$sourceExtension)); } /** @@ -149,9 +149,9 @@ public function testIdentifiersWithoutNumericalPrefixesAreNotDetectedForPageType #[\PHPUnit\Framework\Attributes\DataProvider('pageTypeProvider')] public function testIdentifiersWithNumericalPrefixesAreDetectedWhenUsingSnakeCaseDelimitersForPageType(string $type) { - $this->assertTrue(NumericalPageOrderingHelper::hasNumericalPrefix('01_home.'.$type::$fileExtension)); - $this->assertTrue(NumericalPageOrderingHelper::hasNumericalPrefix('02_about.'.$type::$fileExtension)); - $this->assertTrue(NumericalPageOrderingHelper::hasNumericalPrefix('03_contact.'.$type::$fileExtension)); + $this->assertTrue(NumericalPageOrderingHelper::hasNumericalPrefix('01_home.'.$type::$sourceExtension)); + $this->assertTrue(NumericalPageOrderingHelper::hasNumericalPrefix('02_about.'.$type::$sourceExtension)); + $this->assertTrue(NumericalPageOrderingHelper::hasNumericalPrefix('03_contact.'.$type::$sourceExtension)); } /** @@ -160,9 +160,9 @@ public function testIdentifiersWithNumericalPrefixesAreDetectedWhenUsingSnakeCas #[\PHPUnit\Framework\Attributes\DataProvider('pageTypeProvider')] public function testSplitNumericPrefixForDeeplyNestedPagesForPageType(string $type) { - $this->assertSame([1, 'foo/bar/home.'.$type::$fileExtension], NumericalPageOrderingHelper::splitNumericPrefix('foo/bar/01-home.'.$type::$fileExtension)); - $this->assertSame([2, 'foo/bar/about.'.$type::$fileExtension], NumericalPageOrderingHelper::splitNumericPrefix('foo/bar/02-about.'.$type::$fileExtension)); - $this->assertSame([3, 'foo/bar/contact.'.$type::$fileExtension], NumericalPageOrderingHelper::splitNumericPrefix('foo/bar/03-contact.'.$type::$fileExtension)); + $this->assertSame([1, 'foo/bar/home.'.$type::$sourceExtension], NumericalPageOrderingHelper::splitNumericPrefix('foo/bar/01-home.'.$type::$sourceExtension)); + $this->assertSame([2, 'foo/bar/about.'.$type::$sourceExtension], NumericalPageOrderingHelper::splitNumericPrefix('foo/bar/02-about.'.$type::$sourceExtension)); + $this->assertSame([3, 'foo/bar/contact.'.$type::$sourceExtension], NumericalPageOrderingHelper::splitNumericPrefix('foo/bar/03-contact.'.$type::$sourceExtension)); } public function testSplitNumericPrefixForDeeplyNestedPages() diff --git a/packages/framework/tests/Unit/Pages/BladePageUnitTest.php b/packages/framework/tests/Unit/Pages/BladePageUnitTest.php index 8fbcd3b7919..959dd789bcc 100644 --- a/packages/framework/tests/Unit/Pages/BladePageUnitTest.php +++ b/packages/framework/tests/Unit/Pages/BladePageUnitTest.php @@ -31,9 +31,9 @@ public function testBaseRouteKey() $this->assertSame('', BladePage::baseRouteKey()); } - public function testFileExtension() + public function testSourceExtension() { - $this->assertSame('.blade.php', BladePage::fileExtension()); + $this->assertSame('.blade.php', BladePage::sourceExtension()); } public function testOutputFileExtension() diff --git a/packages/framework/tests/Unit/Pages/DocumentationPageUnitTest.php b/packages/framework/tests/Unit/Pages/DocumentationPageUnitTest.php index dcbd6253a9b..f68f6d21930 100644 --- a/packages/framework/tests/Unit/Pages/DocumentationPageUnitTest.php +++ b/packages/framework/tests/Unit/Pages/DocumentationPageUnitTest.php @@ -33,9 +33,9 @@ public function testBaseRouteKey() $this->assertSame('docs', DocumentationPage::baseRouteKey()); } - public function testFileExtension() + public function testSourceExtension() { - $this->assertSame('.md', DocumentationPage::fileExtension()); + $this->assertSame('.md', DocumentationPage::sourceExtension()); } public function testOutputFileExtension() diff --git a/packages/framework/tests/Unit/Pages/HtmlPageUnitTest.php b/packages/framework/tests/Unit/Pages/HtmlPageUnitTest.php index 1c04eac09a9..12a3f6a8b0c 100644 --- a/packages/framework/tests/Unit/Pages/HtmlPageUnitTest.php +++ b/packages/framework/tests/Unit/Pages/HtmlPageUnitTest.php @@ -40,11 +40,11 @@ public function testBaseRouteKey() ); } - public function testFileExtension() + public function testSourceExtension() { $this->assertSame( '.html', - HtmlPage::fileExtension() + HtmlPage::sourceExtension() ); } diff --git a/packages/framework/tests/Unit/Pages/InMemoryPageUnitTest.php b/packages/framework/tests/Unit/Pages/InMemoryPageUnitTest.php index 1ee4d7d72b4..6c51649e874 100644 --- a/packages/framework/tests/Unit/Pages/InMemoryPageUnitTest.php +++ b/packages/framework/tests/Unit/Pages/InMemoryPageUnitTest.php @@ -44,11 +44,11 @@ public function testBaseRouteKey() ); } - public function testFileExtension() + public function testSourceExtension() { $this->assertSame( '', - InMemoryPage::fileExtension() + InMemoryPage::sourceExtension() ); } diff --git a/packages/framework/tests/Unit/Pages/MarkdownPageUnitTest.php b/packages/framework/tests/Unit/Pages/MarkdownPageUnitTest.php index 978385acd20..d855f0916c9 100644 --- a/packages/framework/tests/Unit/Pages/MarkdownPageUnitTest.php +++ b/packages/framework/tests/Unit/Pages/MarkdownPageUnitTest.php @@ -42,11 +42,11 @@ public function testBaseRouteKey() ); } - public function testFileExtension() + public function testSourceExtension() { $this->assertSame( '.md', - MarkdownPage::fileExtension() + MarkdownPage::sourceExtension() ); } diff --git a/packages/framework/tests/Unit/Pages/MarkdownPostUnitTest.php b/packages/framework/tests/Unit/Pages/MarkdownPostUnitTest.php index 8d1f34d68b7..51744fe3e72 100644 --- a/packages/framework/tests/Unit/Pages/MarkdownPostUnitTest.php +++ b/packages/framework/tests/Unit/Pages/MarkdownPostUnitTest.php @@ -42,11 +42,11 @@ public function testBaseRouteKey() ); } - public function testFileExtension() + public function testSourceExtension() { $this->assertSame( '.md', - MarkdownPost::fileExtension() + MarkdownPost::sourceExtension() ); } diff --git a/packages/realtime-compiler/src/Http/DashboardController.php b/packages/realtime-compiler/src/Http/DashboardController.php index 5a46b46bcf1..64f5ce2c55b 100644 --- a/packages/realtime-compiler/src/Http/DashboardController.php +++ b/packages/realtime-compiler/src/Http/DashboardController.php @@ -467,8 +467,8 @@ protected function formatPageIdentifier(string $title): string { $title = trim($title, '/\\'); - if (str_ends_with(strtolower($title), HtmlPage::fileExtension())) { - $title = substr($title, 0, -strlen(HtmlPage::fileExtension())); + if (str_ends_with(strtolower($title), HtmlPage::sourceExtension())) { + $title = substr($title, 0, -strlen(HtmlPage::sourceExtension())); } $directory = str_contains($title, '/') diff --git a/packages/testing/src/Common/BaseHydePageUnitTest.php b/packages/testing/src/Common/BaseHydePageUnitTest.php index 7f16a63c517..7191e62e561 100644 --- a/packages/testing/src/Common/BaseHydePageUnitTest.php +++ b/packages/testing/src/Common/BaseHydePageUnitTest.php @@ -102,7 +102,7 @@ abstract public function testHas(); abstract public function testToCoreDataObject(); - abstract public function testFileExtension(); + abstract public function testSourceExtension(); abstract public function testOutputFileExtension(); From 46af211232ecc9860e4acca93c949dcb813265b9 Mon Sep 17 00:00:00 2001 From: Emma De Silva Date: Mon, 13 Jul 2026 14:45:19 +0200 Subject: [PATCH 011/117] Rename the output file extension API to outputExtension Pairs $outputExtension with $sourceExtension so the two declarations read as the symmetrical concepts they are, and documents the accessor by its purpose (values like `.html` or `.txt`) rather than leading with the serialization detail of the leading dot. Co-Authored-By: Claude Fable 5 --- .../framework/src/Pages/Concerns/HydePage.php | 12 ++++---- packages/framework/src/Pages/InMemoryPage.php | 4 +-- .../framework/src/Support/Models/RouteKey.php | 6 ++-- .../framework/tests/Feature/HydePageTest.php | 24 ++++++++-------- .../tests/Unit/Pages/BladePageUnitTest.php | 4 +-- .../Unit/Pages/DocumentationPageUnitTest.php | 4 +-- .../tests/Unit/Pages/HtmlPageUnitTest.php | 4 +-- .../tests/Unit/Pages/InMemoryPageTest.php | 28 +++++++++---------- .../tests/Unit/Pages/InMemoryPageUnitTest.php | 4 +-- .../tests/Unit/Pages/MarkdownPageUnitTest.php | 4 +-- .../tests/Unit/Pages/MarkdownPostUnitTest.php | 4 +-- .../framework/tests/Unit/RouteKeyTest.php | 8 +++--- .../src/Common/BaseHydePageUnitTest.php | 2 +- 13 files changed, 55 insertions(+), 53 deletions(-) diff --git a/packages/framework/src/Pages/Concerns/HydePage.php b/packages/framework/src/Pages/Concerns/HydePage.php index 65f383326d8..cd9275e6f11 100644 --- a/packages/framework/src/Pages/Concerns/HydePage.php +++ b/packages/framework/src/Pages/Concerns/HydePage.php @@ -57,7 +57,7 @@ abstract class HydePage implements PageSchema, SerializableContract public static string $sourceDirectory; public static string $outputDirectory; public static string $sourceExtension; - public static string $outputFileExtension = '.html'; + public static string $outputExtension = '.html'; public static string $template; public readonly string $identifier; @@ -175,11 +175,13 @@ public static function sourceExtension(): string } /** - * Get the file extension of the compiled output files for the page type, including the leading dot. + * Get the output file extension for the page type, such as `.html` or `.txt`. + * + * The value includes the leading dot, so it can be used directly as a file name suffix. */ - public static function outputFileExtension(): string + public static function outputExtension(): string { - return static::$outputFileExtension; + return static::$outputExtension; } /** @@ -222,7 +224,7 @@ public static function outputPath(string $identifier): string $routeKey = RouteKey::fromPage(static::class, $identifier); // Since only the HTML extension is implicit in route keys, other extensions are already part of the key. - return static::outputFileExtension() === '.html' ? "$routeKey.html" : (string) $routeKey; + return static::outputExtension() === '.html' ? "$routeKey.html" : (string) $routeKey; } /** diff --git a/packages/framework/src/Pages/InMemoryPage.php b/packages/framework/src/Pages/InMemoryPage.php index bcd3ab6e05c..866c5319f6e 100644 --- a/packages/framework/src/Pages/InMemoryPage.php +++ b/packages/framework/src/Pages/InMemoryPage.php @@ -114,7 +114,7 @@ public function __construct( */ public static function outputPath(string $identifier): string { - if (static::identifierDeclaresOutputFileExtension($identifier)) { + if (static::identifierDeclaresOutputExtension($identifier)) { return (string) RouteKey::fromPage(static::class, $identifier); } @@ -124,7 +124,7 @@ public static function outputPath(string $identifier): string /** * Determine if the given page identifier declares a supported non-HTML output file extension. */ - protected static function identifierDeclaresOutputFileExtension(string $identifier): bool + protected static function identifierDeclaresOutputExtension(string $identifier): bool { foreach (['.json', '.txt', '.xml'] as $extension) { if (str_ends_with($identifier, $extension)) { diff --git a/packages/framework/src/Support/Models/RouteKey.php b/packages/framework/src/Support/Models/RouteKey.php index 0faaeb1310f..c5361c9f412 100644 --- a/packages/framework/src/Support/Models/RouteKey.php +++ b/packages/framework/src/Support/Models/RouteKey.php @@ -57,7 +57,7 @@ public static function fromPage(string $pageClass, string $identifier): self { $identifier = self::stripPrefixIfNeeded($pageClass, $identifier); - return new self(unslash("{$pageClass::baseRouteKey()}/$identifier".self::outputFileExtensionIfNeeded($pageClass, $identifier))); + return new self(unslash("{$pageClass::baseRouteKey()}/$identifier".self::outputExtensionIfNeeded($pageClass, $identifier))); } /** @@ -66,9 +66,9 @@ public static function fromPage(string $pageClass, string $identifier): self * * @param class-string<\Hyde\Pages\Concerns\HydePage> $pageClass */ - protected static function outputFileExtensionIfNeeded(string $pageClass, string $identifier): string + protected static function outputExtensionIfNeeded(string $pageClass, string $identifier): string { - $extension = $pageClass::outputFileExtension(); + $extension = $pageClass::outputExtension(); if ($extension === '.html' || str_ends_with($identifier, $extension)) { return ''; diff --git a/packages/framework/tests/Feature/HydePageTest.php b/packages/framework/tests/Feature/HydePageTest.php index ffa414062ad..01debb8b107 100644 --- a/packages/framework/tests/Feature/HydePageTest.php +++ b/packages/framework/tests/Feature/HydePageTest.php @@ -51,9 +51,9 @@ public function testBaseSourceExtension() $this->assertSame('', HydePage::sourceExtension()); } - public function testBaseOutputFileExtension() + public function testBaseOutputExtension() { - $this->assertSame('.html', HydePage::outputFileExtension()); + $this->assertSame('.html', HydePage::outputExtension()); } public function testBaseSourcePath() @@ -103,9 +103,9 @@ public function testSourceExtension() $this->assertSame('.md', TestPage::sourceExtension()); } - public function testOutputFileExtension() + public function testOutputExtension() { - $this->assertSame('.html', TestPage::outputFileExtension()); + $this->assertSame('.html', TestPage::outputExtension()); } public function testSourcePath() @@ -118,32 +118,32 @@ public function testOutputPath() $this->assertSame('output/hello-world.html', TestPage::outputPath('hello-world')); } - public function testOutputFileExtensionCanBeOverriddenByChildClasses() + public function testOutputExtensionCanBeOverriddenByChildClasses() { - $this->assertSame('.txt', NonHtmlOutputTestPage::outputFileExtension()); + $this->assertSame('.txt', NonHtmlOutputTestPage::outputExtension()); } - public function testOutputPathUsesTheOutputFileExtensionOfThePageClass() + public function testOutputPathUsesTheOutputExtensionOfThePageClass() { $this->assertSame('output/hello-world.txt', NonHtmlOutputTestPage::outputPath('hello-world')); } - public function testGetRouteKeyForPageWithNonHtmlOutputFileExtensionIncludesExtension() + public function testGetRouteKeyForPageWithNonHtmlOutputExtensionIncludesExtension() { $this->assertSame('output/hello-world.txt', (new NonHtmlOutputTestPage('hello-world'))->getRouteKey()); } - public function testGetOutputPathUsesTheOutputFileExtensionOfThePageClass() + public function testGetOutputPathUsesTheOutputExtensionOfThePageClass() { $this->assertSame('output/hello-world.txt', (new NonHtmlOutputTestPage('hello-world'))->getOutputPath()); } - public function testGetLinkForPageWithNonHtmlOutputFileExtension() + public function testGetLinkForPageWithNonHtmlOutputExtension() { $this->assertSame('output/hello-world.txt', (new NonHtmlOutputTestPage('hello-world'))->getLink()); } - public function testGetLinkForPageWithNonHtmlOutputFileExtensionIsNotAffectedByPrettyUrls() + public function testGetLinkForPageWithNonHtmlOutputExtensionIsNotAffectedByPrettyUrls() { config(['hyde.pretty_urls' => true]); @@ -1316,7 +1316,7 @@ class NonHtmlOutputTestPage extends HydePage public static string $sourceDirectory = 'source'; public static string $outputDirectory = 'output'; public static string $sourceExtension = '.txt'; - public static string $outputFileExtension = '.txt'; + public static string $outputExtension = '.txt'; public static string $template = 'template'; } diff --git a/packages/framework/tests/Unit/Pages/BladePageUnitTest.php b/packages/framework/tests/Unit/Pages/BladePageUnitTest.php index 959dd789bcc..9885229d110 100644 --- a/packages/framework/tests/Unit/Pages/BladePageUnitTest.php +++ b/packages/framework/tests/Unit/Pages/BladePageUnitTest.php @@ -36,9 +36,9 @@ public function testSourceExtension() $this->assertSame('.blade.php', BladePage::sourceExtension()); } - public function testOutputFileExtension() + public function testOutputExtension() { - $this->assertSame('.html', BladePage::outputFileExtension()); + $this->assertSame('.html', BladePage::outputExtension()); } public function testSourcePath() diff --git a/packages/framework/tests/Unit/Pages/DocumentationPageUnitTest.php b/packages/framework/tests/Unit/Pages/DocumentationPageUnitTest.php index f68f6d21930..45c0eac8347 100644 --- a/packages/framework/tests/Unit/Pages/DocumentationPageUnitTest.php +++ b/packages/framework/tests/Unit/Pages/DocumentationPageUnitTest.php @@ -38,9 +38,9 @@ public function testSourceExtension() $this->assertSame('.md', DocumentationPage::sourceExtension()); } - public function testOutputFileExtension() + public function testOutputExtension() { - $this->assertSame('.html', DocumentationPage::outputFileExtension()); + $this->assertSame('.html', DocumentationPage::outputExtension()); } public function testSourcePath() diff --git a/packages/framework/tests/Unit/Pages/HtmlPageUnitTest.php b/packages/framework/tests/Unit/Pages/HtmlPageUnitTest.php index 12a3f6a8b0c..dde8733a710 100644 --- a/packages/framework/tests/Unit/Pages/HtmlPageUnitTest.php +++ b/packages/framework/tests/Unit/Pages/HtmlPageUnitTest.php @@ -48,11 +48,11 @@ public function testSourceExtension() ); } - public function testOutputFileExtension() + public function testOutputExtension() { $this->assertSame( '.html', - HtmlPage::outputFileExtension() + HtmlPage::outputExtension() ); } diff --git a/packages/framework/tests/Unit/Pages/InMemoryPageTest.php b/packages/framework/tests/Unit/Pages/InMemoryPageTest.php index 95a44642707..d6fc7aeac76 100644 --- a/packages/framework/tests/Unit/Pages/InMemoryPageTest.php +++ b/packages/framework/tests/Unit/Pages/InMemoryPageTest.php @@ -408,76 +408,76 @@ public function contentPrefix(): string return $this->contentPrefix; } - public function testIdentifierCanDeclareTxtOutputFileExtension() + public function testIdentifierCanDeclareTxtOutputExtension() { $this->assertSame('robots.txt', InMemoryPage::outputPath('robots.txt')); } - public function testIdentifierCanDeclareJsonOutputFileExtension() + public function testIdentifierCanDeclareJsonOutputExtension() { $this->assertSame('data.json', InMemoryPage::outputPath('data.json')); } - public function testIdentifierCanDeclareXmlOutputFileExtension() + public function testIdentifierCanDeclareXmlOutputExtension() { $this->assertSame('sitemap.xml', InMemoryPage::outputPath('sitemap.xml')); } - public function testIdentifierCanDeclareOutputFileExtensionForNestedPages() + public function testIdentifierCanDeclareOutputExtensionForNestedPages() { $this->assertSame('docs/search.json', InMemoryPage::outputPath('docs/search.json')); } - public function testIdentifierWithoutExtensionGetsHtmlOutputFileExtension() + public function testIdentifierWithoutExtensionGetsHtmlOutputExtension() { $this->assertSame('foo.html', InMemoryPage::outputPath('foo')); } - public function testIdentifierWithUnsupportedExtensionGetsHtmlOutputFileExtension() + public function testIdentifierWithUnsupportedExtensionGetsHtmlOutputExtension() { $this->assertSame('foo.md.html', InMemoryPage::outputPath('foo.md')); } - public function testIdentifierWithHtmlExtensionGetsHtmlOutputFileExtensionAppended() + public function testIdentifierWithHtmlExtensionGetsHtmlOutputExtensionAppended() { $this->assertSame('foo.html.html', InMemoryPage::outputPath('foo.html')); } - public function testDottedIdentifierIsNotMistakenForDeclaredOutputFileExtension() + public function testDottedIdentifierIsNotMistakenForDeclaredOutputExtension() { $this->assertSame('docs/1.x.html', InMemoryPage::outputPath('docs/1.x')); } - public function testGetOutputPathForIdentifierWithDeclaredOutputFileExtension() + public function testGetOutputPathForIdentifierWithDeclaredOutputExtension() { $this->assertSame('robots.txt', (new InMemoryPage('robots.txt'))->getOutputPath()); } - public function testGetRouteKeyForIdentifierWithDeclaredOutputFileExtension() + public function testGetRouteKeyForIdentifierWithDeclaredOutputExtension() { $this->assertSame('robots.txt', (new InMemoryPage('robots.txt'))->getRouteKey()); } - public function testGetLinkForIdentifierWithDeclaredOutputFileExtension() + public function testGetLinkForIdentifierWithDeclaredOutputExtension() { $this->assertSame('robots.txt', (new InMemoryPage('robots.txt'))->getLink()); } - public function testGetLinkForIdentifierWithDeclaredOutputFileExtensionIsNotAffectedByPrettyUrls() + public function testGetLinkForIdentifierWithDeclaredOutputExtensionIsNotAffectedByPrettyUrls() { config(['hyde.pretty_urls' => true]); $this->assertSame('robots.txt', (new InMemoryPage('robots.txt'))->getLink()); } - public function testGetCanonicalUrlForIdentifierWithDeclaredOutputFileExtension() + public function testGetCanonicalUrlForIdentifierWithDeclaredOutputExtension() { config(['hyde.url' => 'https://example.com']); $this->assertSame('https://example.com/robots.txt', (new InMemoryPage('robots.txt'))->getCanonicalUrl()); } - public function testCompiledContentsAreNotAffectedByDeclaredOutputFileExtension() + public function testCompiledContentsAreNotAffectedByDeclaredOutputExtension() { $this->assertSame('User-agent: *', (new InMemoryPage('robots.txt', contents: 'User-agent: *'))->compile()); } diff --git a/packages/framework/tests/Unit/Pages/InMemoryPageUnitTest.php b/packages/framework/tests/Unit/Pages/InMemoryPageUnitTest.php index 6c51649e874..d30cfadc0d1 100644 --- a/packages/framework/tests/Unit/Pages/InMemoryPageUnitTest.php +++ b/packages/framework/tests/Unit/Pages/InMemoryPageUnitTest.php @@ -52,11 +52,11 @@ public function testSourceExtension() ); } - public function testOutputFileExtension() + public function testOutputExtension() { $this->assertSame( '.html', - InMemoryPage::outputFileExtension() + InMemoryPage::outputExtension() ); } diff --git a/packages/framework/tests/Unit/Pages/MarkdownPageUnitTest.php b/packages/framework/tests/Unit/Pages/MarkdownPageUnitTest.php index d855f0916c9..b0442fe5815 100644 --- a/packages/framework/tests/Unit/Pages/MarkdownPageUnitTest.php +++ b/packages/framework/tests/Unit/Pages/MarkdownPageUnitTest.php @@ -50,11 +50,11 @@ public function testSourceExtension() ); } - public function testOutputFileExtension() + public function testOutputExtension() { $this->assertSame( '.html', - MarkdownPage::outputFileExtension() + MarkdownPage::outputExtension() ); } diff --git a/packages/framework/tests/Unit/Pages/MarkdownPostUnitTest.php b/packages/framework/tests/Unit/Pages/MarkdownPostUnitTest.php index 51744fe3e72..a548c358163 100644 --- a/packages/framework/tests/Unit/Pages/MarkdownPostUnitTest.php +++ b/packages/framework/tests/Unit/Pages/MarkdownPostUnitTest.php @@ -50,11 +50,11 @@ public function testSourceExtension() ); } - public function testOutputFileExtension() + public function testOutputExtension() { $this->assertSame( '.html', - MarkdownPost::outputFileExtension() + MarkdownPost::outputExtension() ); } diff --git a/packages/framework/tests/Unit/RouteKeyTest.php b/packages/framework/tests/Unit/RouteKeyTest.php index 7540c533271..532916224a9 100644 --- a/packages/framework/tests/Unit/RouteKeyTest.php +++ b/packages/framework/tests/Unit/RouteKeyTest.php @@ -82,19 +82,19 @@ public function testFromPageWithInMemoryPage() $this->assertEquals(new RouteKey('foo/bar'), RouteKey::fromPage(InMemoryPage::class, 'foo/bar')); } - public function testFromPageWithInMemoryPageIdentifierDeclaringOutputFileExtension() + public function testFromPageWithInMemoryPageIdentifierDeclaringOutputExtension() { $this->assertEquals(new RouteKey('robots.txt'), RouteKey::fromPage(InMemoryPage::class, 'robots.txt')); $this->assertEquals(new RouteKey('docs/search.json'), RouteKey::fromPage(InMemoryPage::class, 'docs/search.json')); } - public function testFromPageWithNonHtmlOutputFileExtensionIncludesExtensionInRouteKey() + public function testFromPageWithNonHtmlOutputExtensionIncludesExtensionInRouteKey() { $this->assertEquals(new RouteKey('foo.txt'), RouteKey::fromPage(NonHtmlOutputPageStub::class, 'foo')); $this->assertEquals(new RouteKey('foo/bar.txt'), RouteKey::fromPage(NonHtmlOutputPageStub::class, 'foo/bar')); } - public function testFromPageWithNonHtmlOutputFileExtensionDoesNotDuplicateExtensionAlreadyInIdentifier() + public function testFromPageWithNonHtmlOutputExtensionDoesNotDuplicateExtensionAlreadyInIdentifier() { $this->assertEquals(new RouteKey('foo.txt'), RouteKey::fromPage(NonHtmlOutputPageStub::class, 'foo.txt')); } @@ -157,7 +157,7 @@ public function testItDoesNotExtractNonNumericalFilenamePrefixes() class NonHtmlOutputPageStub extends HydePage { - public static string $outputFileExtension = '.txt'; + public static string $outputExtension = '.txt'; public function compile(): string { diff --git a/packages/testing/src/Common/BaseHydePageUnitTest.php b/packages/testing/src/Common/BaseHydePageUnitTest.php index 7191e62e561..d443b092bca 100644 --- a/packages/testing/src/Common/BaseHydePageUnitTest.php +++ b/packages/testing/src/Common/BaseHydePageUnitTest.php @@ -104,7 +104,7 @@ abstract public function testToCoreDataObject(); abstract public function testSourceExtension(); - abstract public function testOutputFileExtension(); + abstract public function testOutputExtension(); abstract public function testSourceDirectory(); From fc167b2e76c2158f2125d4b79a04169c1e677d9f Mon Sep 17 00:00:00 2001 From: Emma De Silva Date: Mon, 13 Jul 2026 14:45:37 +0200 Subject: [PATCH 012/117] Expand the output path extension rule into an explicit conditional The ternary compressed the domain distinction (only the HTML extension is implicit in route keys) to the point of needing a narrating comment. The expanded conditional makes the rule visible on its own; the broader route-key rule is documented on the RouteKey class. Co-Authored-By: Claude Fable 5 --- packages/framework/src/Pages/Concerns/HydePage.php | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/packages/framework/src/Pages/Concerns/HydePage.php b/packages/framework/src/Pages/Concerns/HydePage.php index cd9275e6f11..134534a72fd 100644 --- a/packages/framework/src/Pages/Concerns/HydePage.php +++ b/packages/framework/src/Pages/Concerns/HydePage.php @@ -223,8 +223,11 @@ public static function outputPath(string $identifier): string { $routeKey = RouteKey::fromPage(static::class, $identifier); - // Since only the HTML extension is implicit in route keys, other extensions are already part of the key. - return static::outputExtension() === '.html' ? "$routeKey.html" : (string) $routeKey; + if (static::outputExtension() === '.html') { + return "$routeKey.html"; + } + + return (string) $routeKey; } /** From 002a55c578940eeab77411ef6c1926a2b61037ac Mon Sep 17 00:00:00 2001 From: Emma De Silva Date: Mon, 13 Jul 2026 14:46:38 +0200 Subject: [PATCH 013/117] Describe the explicit output extension allowlist by its behavior Renames InMemoryPage::identifierDeclaresOutputExtension() to identifierHasExplicitOutputExtension() and names the allowlist as the EXPLICIT_OUTPUT_EXTENSIONS constant, so subclasses can customize the recognized extensions by overriding data instead of replacing the algorithm. The docblocks now state the observable behavior (which extensions are recognized and what happens) instead of the vague "supported". Co-Authored-By: Claude Fable 5 --- packages/framework/src/Pages/InMemoryPage.php | 15 +++++++++------ .../tests/Unit/Pages/InMemoryPageTest.php | 2 +- 2 files changed, 10 insertions(+), 7 deletions(-) diff --git a/packages/framework/src/Pages/InMemoryPage.php b/packages/framework/src/Pages/InMemoryPage.php index 866c5319f6e..63a7c0c7f7b 100644 --- a/packages/framework/src/Pages/InMemoryPage.php +++ b/packages/framework/src/Pages/InMemoryPage.php @@ -36,6 +36,8 @@ class InMemoryPage extends HydePage public static string $outputDirectory; public static string $sourceExtension; + protected const EXPLICIT_OUTPUT_EXTENSIONS = ['.json', '.txt', '.xml']; + /** * The literal page contents, or a closure that generates them at compile time. * @@ -109,12 +111,13 @@ public function __construct( /** * Qualify a page identifier into a target output file path, relative to the _site output directory. * - * If the identifier declares a supported non-HTML output file extension, like "robots.txt", - * the page is saved to that path as-is, instead of having the HTML extension appended. + * If the identifier ends in a recognized non-HTML extension (`.json`, `.txt`, or `.xml` by default), + * it is treated as an explicit output path and no HTML extension is appended, so an identifier + * of "robots.txt" saves the page to "_site/robots.txt". */ public static function outputPath(string $identifier): string { - if (static::identifierDeclaresOutputExtension($identifier)) { + if (static::identifierHasExplicitOutputExtension($identifier)) { return (string) RouteKey::fromPage(static::class, $identifier); } @@ -122,11 +125,11 @@ public static function outputPath(string $identifier): string } /** - * Determine if the given page identifier declares a supported non-HTML output file extension. + * Determine whether the identifier ends with an explicit output extension. */ - protected static function identifierDeclaresOutputExtension(string $identifier): bool + protected static function identifierHasExplicitOutputExtension(string $identifier): bool { - foreach (['.json', '.txt', '.xml'] as $extension) { + foreach (static::EXPLICIT_OUTPUT_EXTENSIONS as $extension) { if (str_ends_with($identifier, $extension)) { return true; } diff --git a/packages/framework/tests/Unit/Pages/InMemoryPageTest.php b/packages/framework/tests/Unit/Pages/InMemoryPageTest.php index d6fc7aeac76..931cb9659d3 100644 --- a/packages/framework/tests/Unit/Pages/InMemoryPageTest.php +++ b/packages/framework/tests/Unit/Pages/InMemoryPageTest.php @@ -433,7 +433,7 @@ public function testIdentifierWithoutExtensionGetsHtmlOutputExtension() $this->assertSame('foo.html', InMemoryPage::outputPath('foo')); } - public function testIdentifierWithUnsupportedExtensionGetsHtmlOutputExtension() + public function testIdentifierWithUnrecognizedExtensionGetsHtmlOutputExtension() { $this->assertSame('foo.md.html', InMemoryPage::outputPath('foo.md')); } From 8f01ff8241139a5fe69fc078bb5326f0fedfc02b Mon Sep 17 00:00:00 2001 From: Emma De Silva Date: Mon, 13 Jul 2026 14:47:08 +0200 Subject: [PATCH 014/117] Inline the route key output extension handling The helper returned either an extension or an empty-string sentinel, forcing the caller to concatenate an opaque fragment. Stating the whole route-key construction in one place makes the "only HTML is implicit" rule readable without control-flow-by-empty-string. Co-Authored-By: Claude Fable 5 --- .../framework/src/Support/Models/RouteKey.php | 18 +++--------------- 1 file changed, 3 insertions(+), 15 deletions(-) diff --git a/packages/framework/src/Support/Models/RouteKey.php b/packages/framework/src/Support/Models/RouteKey.php index c5361c9f412..fa3c64ee9b3 100644 --- a/packages/framework/src/Support/Models/RouteKey.php +++ b/packages/framework/src/Support/Models/RouteKey.php @@ -56,25 +56,13 @@ public function get(): string public static function fromPage(string $pageClass, string $identifier): self { $identifier = self::stripPrefixIfNeeded($pageClass, $identifier); - - return new self(unslash("{$pageClass::baseRouteKey()}/$identifier".self::outputExtensionIfNeeded($pageClass, $identifier))); - } - - /** - * Since only the HTML extension is implicit in route keys, pages compiled to non-HTML - * files include the output file extension declared by their page class in the key. - * - * @param class-string<\Hyde\Pages\Concerns\HydePage> $pageClass - */ - protected static function outputExtensionIfNeeded(string $pageClass, string $identifier): string - { $extension = $pageClass::outputExtension(); - if ($extension === '.html' || str_ends_with($identifier, $extension)) { - return ''; + if ($extension !== '.html' && ! str_ends_with($identifier, $extension)) { + $identifier .= $extension; } - return $extension; + return new self(unslash("{$pageClass::baseRouteKey()}/$identifier")); } /** From 3f3fc334f8ded310550337ab863b4105e7a1b04a Mon Sep 17 00:00:00 2001 From: Emma De Silva Date: Mon, 13 Jul 2026 14:50:02 +0200 Subject: [PATCH 015/117] Build documentation for the renamed extension APIs Co-Authored-By: Claude Fable 5 --- .../hyde-pages-api/hyde-page-methods.md | 22 ++++++++++--------- .../hyde-pages-api/in-memory-page-methods.md | 2 +- docs/advanced-features/hyde-pages.md | 15 ++++++++----- docs/architecture-concepts/page-models.md | 4 ++-- 4 files changed, 25 insertions(+), 18 deletions(-) diff --git a/docs/_data/partials/hyde-pages-api/hyde-page-methods.md b/docs/_data/partials/hyde-pages-api/hyde-page-methods.md index f04bf3483dd..1ee15021fe9 100644 --- a/docs/_data/partials/hyde-pages-api/hyde-page-methods.md +++ b/docs/_data/partials/hyde-pages-api/hyde-page-methods.md @@ -1,7 +1,7 @@
- + #### `make()` @@ -74,20 +74,22 @@ Get the output subdirectory to store compiled HTML files for the page type. HydePage::outputDirectory(): string ``` -#### `fileExtension()` +#### `sourceExtension()` -Get the file extension of the source files for the page type. +Get the file extension of the source files for the page type, such as `.md` or `.blade.php`. ```php -HydePage::fileExtension(): string +HydePage::sourceExtension(): string ``` -#### `outputFileExtension()` +#### `outputExtension()` -Get the file extension of the compiled output files for the page type, including the leading dot. +Get the output file extension for the page type, such as `.html` or `.txt`. + +The value includes the leading dot, so it can be used directly as a file name suffix. ```php -HydePage::outputFileExtension(): string +HydePage::outputExtension(): string ``` #### `setSourceDirectory()` @@ -106,12 +108,12 @@ Set the source directory for the page type. HydePage::setOutputDirectory(string $outputDirectory): void ``` -#### `setFileExtension()` +#### `setSourceExtension()` -Set the file extension for the page type. +Set the source file extension for the page type. ```php -HydePage::setFileExtension(string $fileExtension): void +HydePage::setSourceExtension(string $sourceExtension): void ``` #### `sourcePath()` diff --git a/docs/_data/partials/hyde-pages-api/in-memory-page-methods.md b/docs/_data/partials/hyde-pages-api/in-memory-page-methods.md index bfe84ac1fd0..d8851ddffdf 100644 --- a/docs/_data/partials/hyde-pages-api/in-memory-page-methods.md +++ b/docs/_data/partials/hyde-pages-api/in-memory-page-methods.md @@ -16,7 +16,7 @@ InMemoryPage::make(string|(Closure(): string)|(Closure(static):, Hyde\Markdown\M Qualify a page identifier into a target output file path, relative to the _site output directory. -If the identifier declares a supported non-HTML output file extension, like "robots.txt", the page is saved to that path as-is, instead of having the HTML extension appended. +If the identifier ends in a recognized non-HTML extension (`.json`, `.txt`, or `.xml` by default), it is treated as an explicit output path and no HTML extension is appended, so an identifier of "robots.txt" saves the page to "_site/robots.txt". ```php InMemoryPage::outputPath(string $identifier): string diff --git a/docs/advanced-features/hyde-pages.md b/docs/advanced-features/hyde-pages.md index 09e01bdeb7b..fb87713184a 100644 --- a/docs/advanced-features/hyde-pages.md +++ b/docs/advanced-features/hyde-pages.md @@ -77,7 +77,12 @@ abstract class HydePage /** * The file extension of the source files. */ - public static string $fileExtension; + public static string $sourceExtension; + + /** + * The file extension of the compiled output files. + */ + public static string $outputExtension = '.html'; /** * The default template to use for rendering the page. @@ -139,7 +144,7 @@ abstract class BaseMarkdownPage extends HydePage { public Markdown $markdown; - public static string $fileExtension = '.md'; + public static string $sourceExtension = '.md'; } ``` @@ -177,7 +182,7 @@ class InMemoryPage extends HydePage { public static string $sourceDirectory; public static string $outputDirectory; - public static string $fileExtension; + public static string $sourceExtension; protected string|\Closure $contents; protected string $view; @@ -208,7 +213,7 @@ class BladePage extends HydePage { public static string $sourceDirectory = '_pages'; public static string $outputDirectory = ''; - public static string $fileExtension = '.blade.php'; + public static string $sourceExtension = '.blade.php'; } ``` @@ -326,7 +331,7 @@ class HtmlPage extends HydePage { public static string $sourceDirectory = '_pages'; public static string $outputDirectory = ''; - public static string $fileExtension = '.html'; + public static string $sourceExtension = '.html'; } ``` diff --git a/docs/architecture-concepts/page-models.md b/docs/architecture-concepts/page-models.md index 9debc77d0fb..e5eedbab6ab 100644 --- a/docs/architecture-concepts/page-models.md +++ b/docs/architecture-concepts/page-models.md @@ -36,7 +36,7 @@ class MarkdownPost extends BaseMarkdownPage { public static string $sourceDirectory = '_posts'; public static string $outputDirectory = 'posts'; - public static string $fileExtension = '.md'; + public static string $sourceExtension = '.md'; public static string $template = 'post'; public string $identifier; @@ -63,7 +63,7 @@ class MarkdownPost extends BaseMarkdownPage { public static string $sourceDirectory = '_posts'; public static string $outputDirectory = 'posts'; - public static string $fileExtension = '.md'; + public static string $sourceExtension = '.md'; public static string $template = 'post'; } ``` From a7be01bc5f93e70cf9717ad6f63e8509dd104542 Mon Sep 17 00:00:00 2001 From: Emma De Silva Date: Mon, 13 Jul 2026 14:50:02 +0200 Subject: [PATCH 016/117] Document the extension API rename and seed the upgrade script rules Adds the breaking-change release note and upgrade step for the $fileExtension to $sourceExtension rename, and starts an upgrade script rules section in the planning document so the mechanical migrations the release-time upgrade script must perform are collected as we make them. Co-Authored-By: Claude Fable 5 --- HYDEPHP_V3_PLANNING.md | 22 +++++++++++++++++++++- UPGRADE.md | 37 +++++++++++++++++++++++++++++++++++++ 2 files changed, 58 insertions(+), 1 deletion(-) diff --git a/HYDEPHP_V3_PLANNING.md b/HYDEPHP_V3_PLANNING.md index ce24cc3f5c2..9582d7db659 100644 --- a/HYDEPHP_V3_PLANNING.md +++ b/HYDEPHP_V3_PLANNING.md @@ -14,6 +14,24 @@ Having this document in code lets us know the devlopment state at any given poin ## Changes required to the v2 branch +## Upgrade script rules + +We will provide an automated upgrade script (likely Rector-based) when we finalize the release. +Until then, this section collects the rules that script needs to implement, so we don't lose +track of them. Add an entry here whenever a change requires mechanical migration of user code. + +- Rename the static page class property `$fileExtension` to `$sourceExtension`, and the + `fileExtension()` and `setFileExtension()` methods to `sourceExtension()` and + `setSourceExtension()`. This covers property declarations in page classes + (`public static string $fileExtension = '.md';`), direct static property access + (`MarkdownPage::$fileExtension`, `$pageClass::$fileExtension`), and static method calls + (`MarkdownPage::fileExtension()`). The rule must be scoped to + `Hyde\Pages\Concerns\HydePage` subclasses (or known Hyde symbols) — it must not rename + unrelated properties or methods that happen to share the name. +- Dynamic references cannot be migrated automatically and should be called out as manual + upgrade cases: variable method/property names (`$method = 'fileExtension'; + $pageClass::$method()`), reflection, and string-based access. + --- ## Release Notes @@ -24,7 +42,7 @@ Having this document in code lets us know the devlopment state at any given poin - 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)) - Added built-in terminal code blocks using the `terminal` fence language. Command prompts are styled for selection-free copying, and `terminal xml` supports four Symfony-style Console formatter tags. ([#2188](https://github.com/hydephp/develop/issues/2188), [#2485](https://github.com/hydephp/develop/issues/2485)) -- Pages can now compile to non-HTML output files. Page classes declare their output file extension through the new static `$outputFileExtension` property (defaulting to `.html`), and in-memory page identifiers can declare a `.json`, `.txt`, or `.xml` extension directly, so `InMemoryPage::make('robots.txt', contents: ...)` compiles to `_site/robots.txt` through the standard site build. Only the HTML extension is implicit in route keys: pages compiled to non-HTML files keep their extension in the route key, formalizing the convention already used by the documentation search index. +- Pages can now compile to non-HTML output files. Page classes declare their output file extension through the new static `$outputExtension` property (defaulting to `.html`), and in-memory page identifiers can declare a `.json`, `.txt`, or `.xml` extension directly, so `InMemoryPage::make('robots.txt', contents: ...)` compiles to `_site/robots.txt` through the standard site build. Only the HTML extension is implicit in route keys: pages compiled to non-HTML files keep their extension in the route key, formalizing the convention already used by the documentation search index. ### Feature Changes @@ -43,6 +61,7 @@ Having this document in code lets us know the devlopment state at any given poin ### Breaking Changes +- Renamed the static page class property `$fileExtension` to `$sourceExtension`, and the `fileExtension()` and `setFileExtension()` methods to `sourceExtension()` and `setSourceExtension()`. The old name was ambiguous now that page classes also declare an output extension through the new `$outputExtension` property, and the renamed pair makes the source/output distinction explicit. 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 above). - In-memory page identifiers ending in `.json`, `.txt`, or `.xml` (including redirect paths declared in `hyde.redirects`) now compile to that path as-is instead of gaining a second `.html` extension. The old double-extension outputs (like `data.json.html`) were almost certainly never intended, so no real sites are expected to be affected. - Removed `Redirect::create()`, `Redirect::store()`, and the `Redirect` constructor's `showText` argument. Redirects must now be declared in `hyde.redirects`, keeping all generated output inside the kernel-owned build graph. Redirect routes are intrinsically excluded from navigation menus and sitemaps, and always include an accessible fallback link. - Removed the `InMemoryPage` instance macro API. Dynamic contents should now be supplied with a closure, while custom methods and other behavior belong in an `InMemoryPage` subclass. @@ -60,6 +79,7 @@ Please fill in UPGRADE.md as you make changes. - Move any calls to `Redirect::create()` or `Redirect::store()` into the `redirects` array in `config/hyde.php`, using the old path as the key and the destination as the value. - Move `InMemoryPage` `compile` macro callbacks into the contents argument, and replace other instance macros with methods on an `InMemoryPage` subclass. - Update `InMemoryPage` calls to supply only `contents` or `view`. Replace an empty-string positional contents placeholder with `null`, or use the named `view` argument. +- Rename `$fileExtension` to `$sourceExtension` in custom page classes, and update any calls to `fileExtension()` or `setFileExtension()` to `sourceExtension()` and `setSourceExtension()`. ## `InMemoryPage` content-source motivation diff --git a/UPGRADE.md b/UPGRADE.md index 656186d9920..bf4914f8611 100644 --- a/UPGRADE.md +++ b/UPGRADE.md @@ -302,6 +302,42 @@ new InMemoryPage('example', view: ''); new InMemoryPage('example', view: null); ``` +## Step 6: Rename Page File Extension References + +The static page class property `$fileExtension` has been renamed to `$sourceExtension`, along with the +`fileExtension()` and `setFileExtension()` methods, which are now `sourceExtension()` and `setSourceExtension()`. +The rename pairs the source extension with the new `$outputExtension` property (defaulting to `.html`), which +page classes can override to compile to non-HTML output files. + +This only affects projects with custom page classes or code calling these APIs. Update property declarations +and call sites: + +**Before:** + +```php +class CustomPage extends HydePage +{ + public static string $fileExtension = '.md'; +} + +$extension = MarkdownPage::fileExtension(); +``` + +**After:** + +```php +class CustomPage extends HydePage +{ + public static string $sourceExtension = '.md'; +} + +$extension = MarkdownPage::sourceExtension(); +``` + +The automated upgrade script will handle this rename for ordinary declarations, property accesses, and method +calls. Dynamic references — variable method or property names, reflection, and string-based access — must be +updated manually. + ## Migration Checklist Use this checklist to track your upgrade progress: @@ -311,6 +347,7 @@ Use this checklist to track your upgrade progress: - [ ] Moved calls to `Redirect::create()` or `Redirect::store()` into the `hyde.redirects` configuration array - [ ] Moved `InMemoryPage` `compile` macro callbacks into the contents argument and replaced other macros with subclass methods - [ ] Updated `InMemoryPage` calls to supply only one of `contents` and `view` +- [ ] Renamed `$fileExtension`, `fileExtension()`, and `setFileExtension()` to `$sourceExtension`, `sourceExtension()`, and `setSourceExtension()` in custom page classes and call sites ## Troubleshooting From 717ebcbd4669111f4169f5497bcd02945a9758d5 Mon Sep 17 00:00:00 2001 From: Emma De Silva Date: Mon, 13 Jul 2026 14:50:02 +0200 Subject: [PATCH 017/117] Record the PR 1 review outcomes in the epic Co-Authored-By: Claude Fable 5 --- EPIC_NON_HTML_PAGES.md | 31 +++++++++++++++++++++---------- 1 file changed, 21 insertions(+), 10 deletions(-) diff --git a/EPIC_NON_HTML_PAGES.md b/EPIC_NON_HTML_PAGES.md index 4d412c57e56..e77422577f1 100644 --- a/EPIC_NON_HTML_PAGES.md +++ b/EPIC_NON_HTML_PAGES.md @@ -80,17 +80,19 @@ versioned docs route keys like `docs/1.x/index` would false-positive (`pathinfo('docs/1.x')['extension'] === 'x'`). Instead the extension is declared: - File-discovered classes declare it statically, mirroring the existing - `HtmlPage::$fileExtension` pattern: `TextPage::$outputFileExtension = '.txt'`. -- `HydePage` gets `public static string $outputFileExtension = '.html'` (or an - instance-level `getOutputFileExtension()` hook), and `outputPath()` uses it instead - of the hardcoded `'.html'`. This removes the `getOutputPath()` override dance. + `HtmlPage::$sourceExtension` pattern: `TextPage::$outputExtension = '.txt'`. +- `HydePage` gets `public static string $outputExtension = '.html'` (or an + instance-level hook), and `outputPath()` uses it instead of the hardcoded + `'.html'`. This removes the `getOutputPath()` override dance. - For `InMemoryPage`, decide in the PR whether to (a) accept a small allowlist of trailing extensions in the identifier (`.txt`, `.json`, `.xml`), or (b) add an explicit constructor/`make()` parameter. Leaning (a) with allowlist since `InMemoryPage::make('robots.txt', contents: $txt)` is the DX we actually want. -> **Decided (PR 1):** option (a). The allowlist (`.json`, `.txt`, `.xml`) lives in -> `InMemoryPage::identifierDeclaresOutputFileExtension()`, overridable by subclasses. +> **Decided (PR 1):** option (a). The allowlist (`.json`, `.txt`, `.xml`) is the +> `InMemoryPage::EXPLICIT_OUTPUT_EXTENSIONS` constant, checked by +> `identifierHasExplicitOutputExtension()`; subclasses customize the recognized +> extensions by overriding the constant rather than the algorithm. > `Redirect` deliberately inherits it (no special case): a redirect declared for a > non-HTML path now emits its file at that exact path instead of an unreachable > double-extension path. Neither behavior can make a meta-refresh work when the @@ -128,7 +130,7 @@ feature default → config tweaks → file on disk → fully dynamic page in cod Goal: any page class can emit a non-`.html` file without overriding `getOutputPath()`. -- Add `$outputFileExtension` (default `'.html'`) to `HydePage`; use it in +- Add `$outputExtension` (default `'.html'`) to `HydePage`; use it in `outputPath()` (`HydePage.php:211-214`). - Route keys follow D1; audit `RouteKey` and `Route` for assumptions. - Let `InMemoryPage` respect a declared/allowlisted extension per D2, so @@ -138,10 +140,19 @@ Goal: any page class can emit a non-`.html` file without overriding `getOutputPa Implementation notes (branch `v3/non-html-pages-foundation`): -- An `outputFileExtension()` accessor accompanies the property, matching the other +- An `outputExtension()` accessor accompanies the property, matching the other static accessors, and is part of the `BaseHydePageUnitTest` contract. No setter was added — the existing setters exist for config-driven source customization, which does not apply here; subclasses redeclare the property. +- Review outcome: the existing `$fileExtension` API was renamed to `$sourceExtension` + (with `fileExtension()`/`setFileExtension()` becoming `sourceExtension()`/ + `setSourceExtension()`) so the source/output pair reads symmetrically — the old + name really meant the source extension, and fixing the vocabulary before later + page types (`TextPage`, sitemap, RSS, robots, llms) build on it avoids much larger + churn. Clean break, no compatibility aliases: independently redeclared static + properties cannot alias each other without precedence/synchronization hacks. + The mechanical migration is recorded in `HYDEPHP_V3_PLANNING.md` under + "Upgrade script rules" for the release-time Rector script. - Non-HTML extension handling was placed in `RouteKey::fromPage()` (see D1 note) rather than only in `outputPath()`, so route keys and output paths cannot drift. - One qualification to "no compiled-output changes": in-memory page identifiers @@ -168,8 +179,8 @@ filename special cases. Goal: drop `_pages/robots.txt` in, get `_site/robots.txt` out. - New `TextPage extends HydePage`: `$sourceDirectory = '_pages'`, - `$outputDirectory = ''`, `$fileExtension = '.txt'`, - `$outputFileExtension = '.txt'`; `compile()` returns file contents verbatim + `$outputDirectory = ''`, `$sourceExtension = '.txt'`, + `$outputExtension = '.txt'`; `compile()` returns file contents verbatim (mirror `HtmlPage`). - Register in `HydeCoreExtension::getPageClasses()`. No `Feature::TextPages` enum case — the feature is always on, since it is inert without source files. From 5571e15f0c6617a95ccb849ff83c583e715a11df Mon Sep 17 00:00:00 2001 From: Emma De Silva Date: Mon, 13 Jul 2026 14:50:23 +0200 Subject: [PATCH 018/117] Fix the swapped source and output directory setter docblocks Co-Authored-By: Claude Fable 5 --- docs/_data/partials/hyde-pages-api/hyde-page-methods.md | 6 +++--- packages/framework/src/Pages/Concerns/HydePage.php | 4 ++-- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/docs/_data/partials/hyde-pages-api/hyde-page-methods.md b/docs/_data/partials/hyde-pages-api/hyde-page-methods.md index 1ee15021fe9..c0c17218673 100644 --- a/docs/_data/partials/hyde-pages-api/hyde-page-methods.md +++ b/docs/_data/partials/hyde-pages-api/hyde-page-methods.md @@ -1,7 +1,7 @@
- + #### `make()` @@ -94,7 +94,7 @@ HydePage::outputExtension(): string #### `setSourceDirectory()` -Set the output directory for the page type. +Set the source directory for the page type. ```php HydePage::setSourceDirectory(string $sourceDirectory): void @@ -102,7 +102,7 @@ HydePage::setSourceDirectory(string $sourceDirectory): void #### `setOutputDirectory()` -Set the source directory for the page type. +Set the output directory for the page type. ```php HydePage::setOutputDirectory(string $outputDirectory): void diff --git a/packages/framework/src/Pages/Concerns/HydePage.php b/packages/framework/src/Pages/Concerns/HydePage.php index 134534a72fd..1eeaa178aec 100644 --- a/packages/framework/src/Pages/Concerns/HydePage.php +++ b/packages/framework/src/Pages/Concerns/HydePage.php @@ -185,7 +185,7 @@ public static function outputExtension(): string } /** - * Set the output directory for the page type. + * Set the source directory for the page type. */ public static function setSourceDirectory(string $sourceDirectory): void { @@ -193,7 +193,7 @@ public static function setSourceDirectory(string $sourceDirectory): void } /** - * Set the source directory for the page type. + * Set the output directory for the page type. */ public static function setOutputDirectory(string $outputDirectory): void { From 79ecd504af70a4abadfb2caa572b5b780211b4ce Mon Sep 17 00:00:00 2001 From: Emma De Silva Date: Mon, 13 Jul 2026 15:02:01 +0200 Subject: [PATCH 019/117] Test subclass customization of the explicit output extensions The EXPLICIT_OUTPUT_EXTENSIONS constant is the advertised extension point for subclasses, so cover that overriding it both recognizes the custom extension and replaces the default list. Co-Authored-By: Claude Fable 5 --- .../framework/tests/Unit/Pages/InMemoryPageTest.php | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/packages/framework/tests/Unit/Pages/InMemoryPageTest.php b/packages/framework/tests/Unit/Pages/InMemoryPageTest.php index 931cb9659d3..24502bb217b 100644 --- a/packages/framework/tests/Unit/Pages/InMemoryPageTest.php +++ b/packages/framework/tests/Unit/Pages/InMemoryPageTest.php @@ -448,6 +448,12 @@ public function testDottedIdentifierIsNotMistakenForDeclaredOutputExtension() $this->assertSame('docs/1.x.html', InMemoryPage::outputPath('docs/1.x')); } + public function testSubclassCanCustomizeExplicitOutputExtensions() + { + $this->assertSame('data.csv', CsvInMemoryPage::outputPath('data.csv')); + $this->assertSame('robots.txt.html', CsvInMemoryPage::outputPath('robots.txt')); + } + public function testGetOutputPathForIdentifierWithDeclaredOutputExtension() { $this->assertSame('robots.txt', (new InMemoryPage('robots.txt'))->getOutputPath()); @@ -482,3 +488,8 @@ public function testCompiledContentsAreNotAffectedByDeclaredOutputExtension() $this->assertSame('User-agent: *', (new InMemoryPage('robots.txt', contents: 'User-agent: *'))->compile()); } } + +class CsvInMemoryPage extends InMemoryPage +{ + protected const EXPLICIT_OUTPUT_EXTENSIONS = ['.csv']; +} From 33231671e4c8659456352508b4c00c68ec47dfe2 Mon Sep 17 00:00:00 2001 From: Emma De Silva Date: Mon, 13 Jul 2026 15:02:15 +0200 Subject: [PATCH 020/117] Generalize the output directory docblock beyond HTML output Page classes can now compile to non-HTML files, so the accessor should not describe the output as HTML. Co-Authored-By: Claude Fable 5 --- docs/_data/partials/hyde-pages-api/hyde-page-methods.md | 4 ++-- docs/advanced-features/hyde-pages.md | 2 +- packages/framework/src/Pages/Concerns/HydePage.php | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/_data/partials/hyde-pages-api/hyde-page-methods.md b/docs/_data/partials/hyde-pages-api/hyde-page-methods.md index c0c17218673..a43789a4115 100644 --- a/docs/_data/partials/hyde-pages-api/hyde-page-methods.md +++ b/docs/_data/partials/hyde-pages-api/hyde-page-methods.md @@ -1,7 +1,7 @@
- + #### `make()` @@ -68,7 +68,7 @@ HydePage::sourceDirectory(): string #### `outputDirectory()` -Get the output subdirectory to store compiled HTML files for the page type. +Get the output subdirectory where compiled files are stored for the page type. ```php HydePage::outputDirectory(): string diff --git a/docs/advanced-features/hyde-pages.md b/docs/advanced-features/hyde-pages.md index fb87713184a..ea9db593c9a 100644 --- a/docs/advanced-features/hyde-pages.md +++ b/docs/advanced-features/hyde-pages.md @@ -70,7 +70,7 @@ abstract class HydePage public static string $sourceDirectory; /** - * The output subdirectory to store compiled HTML. Relative to the _site output directory. + * The output subdirectory to store compiled files. Relative to the _site output directory. */ public static string $outputDirectory; diff --git a/packages/framework/src/Pages/Concerns/HydePage.php b/packages/framework/src/Pages/Concerns/HydePage.php index 1eeaa178aec..f0e85574621 100644 --- a/packages/framework/src/Pages/Concerns/HydePage.php +++ b/packages/framework/src/Pages/Concerns/HydePage.php @@ -159,7 +159,7 @@ public static function sourceDirectory(): string } /** - * Get the output subdirectory to store compiled HTML files for the page type. + * Get the output subdirectory where compiled files are stored for the page type. */ public static function outputDirectory(): string { From c89989fb818c7f88a6fde530c10e78ef6b6317bc Mon Sep 17 00:00:00 2001 From: Emma De Silva Date: Mon, 13 Jul 2026 15:03:03 +0200 Subject: [PATCH 021/117] Cover overridden method declarations in the extension rename migration The renamed methods are public and non-final, so a subclass override of fileExtension() or setFileExtension() would silently stop being called once the framework calls sourceExtension(). The upgrade script rule and the upgrade guide now include renaming such declarations. Also moves the upgrade script rules to their own section at the end of the planning document instead of sitting under the empty v2-branch heading. Co-Authored-By: Claude Fable 5 --- HYDEPHP_V3_PLANNING.md | 43 +++++++++++++++++++++++------------------- UPGRADE.md | 12 +++++++----- 2 files changed, 31 insertions(+), 24 deletions(-) diff --git a/HYDEPHP_V3_PLANNING.md b/HYDEPHP_V3_PLANNING.md index 9582d7db659..b70a9ec6b04 100644 --- a/HYDEPHP_V3_PLANNING.md +++ b/HYDEPHP_V3_PLANNING.md @@ -14,24 +14,6 @@ Having this document in code lets us know the devlopment state at any given poin ## Changes required to the v2 branch -## Upgrade script rules - -We will provide an automated upgrade script (likely Rector-based) when we finalize the release. -Until then, this section collects the rules that script needs to implement, so we don't lose -track of them. Add an entry here whenever a change requires mechanical migration of user code. - -- Rename the static page class property `$fileExtension` to `$sourceExtension`, and the - `fileExtension()` and `setFileExtension()` methods to `sourceExtension()` and - `setSourceExtension()`. This covers property declarations in page classes - (`public static string $fileExtension = '.md';`), direct static property access - (`MarkdownPage::$fileExtension`, `$pageClass::$fileExtension`), and static method calls - (`MarkdownPage::fileExtension()`). The rule must be scoped to - `Hyde\Pages\Concerns\HydePage` subclasses (or known Hyde symbols) — it must not rename - unrelated properties or methods that happen to share the name. -- Dynamic references cannot be migrated automatically and should be called out as manual - upgrade cases: variable method/property names (`$method = 'fileExtension'; - $pageClass::$method()`), reflection, and string-based access. - --- ## Release Notes @@ -61,7 +43,7 @@ track of them. Add an entry here whenever a change requires mechanical migration ### Breaking Changes -- Renamed the static page class property `$fileExtension` to `$sourceExtension`, and the `fileExtension()` and `setFileExtension()` methods to `sourceExtension()` and `setSourceExtension()`. The old name was ambiguous now that page classes also declare an output extension through the new `$outputExtension` property, and the renamed pair makes the source/output distinction explicit. 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 above). +- Renamed the static page class property `$fileExtension` to `$sourceExtension`, and the `fileExtension()` and `setFileExtension()` methods to `sourceExtension()` and `setSourceExtension()`. The old name was ambiguous now that page classes also declare an output extension through the new `$outputExtension` property, and the renamed pair makes the source/output distinction explicit. 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). - In-memory page identifiers ending in `.json`, `.txt`, or `.xml` (including redirect paths declared in `hyde.redirects`) now compile to that path as-is instead of gaining a second `.html` extension. The old double-extension outputs (like `data.json.html`) were almost certainly never intended, so no real sites are expected to be affected. - Removed `Redirect::create()`, `Redirect::store()`, and the `Redirect` constructor's `showText` argument. Redirects must now be declared in `hyde.redirects`, keeping all generated output inside the kernel-owned build graph. Redirect routes are intrinsically excluded from navigation menus and sitemaps, and always include an accessible fallback link. - Removed the `InMemoryPage` instance macro API. Dynamic contents should now be supplied with a closure, while custom methods and other behavior belong in an `InMemoryPage` subclass. @@ -95,3 +77,26 @@ mutually exclusive alternatives and ambiguous construction fails immediately. Both arguments use `null` as the omission sentinel so an explicitly empty literal remains distinguishable from an omitted contents argument. Their names and order remain unchanged to keep the upgrade mechanical: existing named calls are unaffected, while positional view calls replace their old `''` placeholder with `null`. + +--- + +## Upgrade script rules + +We will provide an automated upgrade script (likely Rector-based) when we finalize the release. +Until then, this section collects the rules that script needs to implement, so we don't lose +track of them. Add an entry here whenever a change requires mechanical migration of user code. + +- Rename the static page class property `$fileExtension` to `$sourceExtension`, and the + `fileExtension()` and `setFileExtension()` methods to `sourceExtension()` and + `setSourceExtension()`. This covers property declarations in page classes + (`public static string $fileExtension = '.md';`), direct static property access + (`MarkdownPage::$fileExtension`, `$pageClass::$fileExtension`), static method calls + (`MarkdownPage::fileExtension()`), and method declarations that override these methods + in page classes (`public static function fileExtension(): string`) — the methods are + public and non-final, and an un-renamed override would silently stop being called once + the framework calls `sourceExtension()`. The rule must be scoped to + `Hyde\Pages\Concerns\HydePage` subclasses (or known Hyde symbols) — it must not rename + unrelated properties or methods that happen to share the name. +- Dynamic references cannot be migrated automatically and should be called out as manual + upgrade cases: variable method/property names (`$method = 'fileExtension'; + $pageClass::$method()`), reflection, and string-based access. diff --git a/UPGRADE.md b/UPGRADE.md index bf4914f8611..759d901c048 100644 --- a/UPGRADE.md +++ b/UPGRADE.md @@ -309,8 +309,10 @@ The static page class property `$fileExtension` has been renamed to `$sourceExte The rename pairs the source extension with the new `$outputExtension` property (defaulting to `.html`), which page classes can override to compile to non-HTML output files. -This only affects projects with custom page classes or code calling these APIs. Update property declarations -and call sites: +This only affects projects with custom page classes or code calling these APIs. Update property declarations, +call sites, and any methods that override `fileExtension()` or `setFileExtension()` — the methods are public +and non-final, and an un-renamed override silently stops being called now that the framework calls +`sourceExtension()`: **Before:** @@ -334,9 +336,9 @@ class CustomPage extends HydePage $extension = MarkdownPage::sourceExtension(); ``` -The automated upgrade script will handle this rename for ordinary declarations, property accesses, and method -calls. Dynamic references — variable method or property names, reflection, and string-based access — must be -updated manually. +The automated upgrade script will handle this rename for ordinary property declarations, property accesses, +method calls, and overridden method declarations. Dynamic references — variable method or property names, +reflection, and string-based access — must be updated manually. ## Migration Checklist From 3f8b372fa65997698a3015c15b983dadd35b0a3d Mon Sep 17 00:00:00 2001 From: Emma De Silva Date: Mon, 13 Jul 2026 16:13:28 +0200 Subject: [PATCH 022/117] Append the output extension to the completed route key Appending to the identifier before joining the base route key produced keys like feed/.xml instead of feed.xml for non-HTML pages with an empty identifier and an output directory. Co-Authored-By: Claude Fable 5 --- .../framework/src/Support/Models/RouteKey.php | 7 ++++--- packages/framework/tests/Unit/RouteKeyTest.php | 17 +++++++++++++++++ 2 files changed, 21 insertions(+), 3 deletions(-) diff --git a/packages/framework/src/Support/Models/RouteKey.php b/packages/framework/src/Support/Models/RouteKey.php index fa3c64ee9b3..a371b327af9 100644 --- a/packages/framework/src/Support/Models/RouteKey.php +++ b/packages/framework/src/Support/Models/RouteKey.php @@ -56,13 +56,14 @@ public function get(): string public static function fromPage(string $pageClass, string $identifier): self { $identifier = self::stripPrefixIfNeeded($pageClass, $identifier); + $key = unslash("{$pageClass::baseRouteKey()}/$identifier"); $extension = $pageClass::outputExtension(); - if ($extension !== '.html' && ! str_ends_with($identifier, $extension)) { - $identifier .= $extension; + if ($extension !== '.html' && ! str_ends_with($key, $extension)) { + $key .= $extension; } - return new self(unslash("{$pageClass::baseRouteKey()}/$identifier")); + return new self($key); } /** diff --git a/packages/framework/tests/Unit/RouteKeyTest.php b/packages/framework/tests/Unit/RouteKeyTest.php index 532916224a9..d5d7a2252ae 100644 --- a/packages/framework/tests/Unit/RouteKeyTest.php +++ b/packages/framework/tests/Unit/RouteKeyTest.php @@ -99,6 +99,12 @@ public function testFromPageWithNonHtmlOutputExtensionDoesNotDuplicateExtensionA $this->assertEquals(new RouteKey('foo.txt'), RouteKey::fromPage(NonHtmlOutputPageStub::class, 'foo.txt')); } + public function testFromPageWithNonHtmlOutputExtensionAndEmptyIdentifierAppendsExtensionToOutputDirectory() + { + $this->assertEquals(new RouteKey('feed.xml'), RouteKey::fromPage(NonHtmlOutputDirectoryPageStub::class, '')); + $this->assertEquals(new RouteKey('feed/episode.xml'), RouteKey::fromPage(NonHtmlOutputDirectoryPageStub::class, 'episode')); + } + public function testFromPageWithCustomOutputDirectory() { MarkdownPage::setOutputDirectory('foo'); @@ -164,3 +170,14 @@ public function compile(): string return ''; } } + +class NonHtmlOutputDirectoryPageStub extends HydePage +{ + public static string $outputDirectory = 'feed'; + public static string $outputExtension = '.xml'; + + public function compile(): string + { + return ''; + } +} From a0f566c356ae4f47e98e304896b02d448d75ad11 Mon Sep 17 00:00:00 2001 From: Emma De Silva Date: Mon, 13 Jul 2026 16:14:18 +0200 Subject: [PATCH 023/117] Validate declared output extensions outputExtension() returned the raw static property, so a typo like 'txt' (missing the dot) silently produced output paths like documenttxt. The accessor now throws an InvalidArgumentException for values that do not start with a dot or that contain path separators, matching the normalization the source extension setter already applies. Co-Authored-By: Claude Fable 5 --- .../hyde-pages-api/hyde-page-methods.md | 4 ++- .../framework/src/Pages/Concerns/HydePage.php | 17 ++++++++++- .../framework/tests/Feature/HydePageTest.php | 30 +++++++++++++++++++ 3 files changed, 49 insertions(+), 2 deletions(-) diff --git a/docs/_data/partials/hyde-pages-api/hyde-page-methods.md b/docs/_data/partials/hyde-pages-api/hyde-page-methods.md index a43789a4115..9eb0a3c66ea 100644 --- a/docs/_data/partials/hyde-pages-api/hyde-page-methods.md +++ b/docs/_data/partials/hyde-pages-api/hyde-page-methods.md @@ -1,7 +1,7 @@
- + #### `make()` @@ -92,6 +92,8 @@ The value includes the leading dot, so it can be used directly as a file name su HydePage::outputExtension(): string ``` +- **Throws:** \InvalidArgumentException If the declared extension does not start with a dot or contains a path separator. + #### `setSourceDirectory()` Set the source directory for the page type. diff --git a/packages/framework/src/Pages/Concerns/HydePage.php b/packages/framework/src/Pages/Concerns/HydePage.php index f0e85574621..dab4f6a5ac3 100644 --- a/packages/framework/src/Pages/Concerns/HydePage.php +++ b/packages/framework/src/Pages/Concerns/HydePage.php @@ -24,11 +24,15 @@ use Hyde\Support\Models\Route; use Hyde\Support\Models\RouteKey; use Illuminate\Support\Str; +use InvalidArgumentException; use function Hyde\unslash; use function filled; use function ltrim; use function rtrim; +use function sprintf; +use function str_contains; +use function str_starts_with; /** * The base class for all Hyde pages. @@ -178,10 +182,21 @@ public static function sourceExtension(): string * Get the output file extension for the page type, such as `.html` or `.txt`. * * The value includes the leading dot, so it can be used directly as a file name suffix. + * + * @throws \InvalidArgumentException If the declared extension does not start with a dot or contains a path separator. */ public static function outputExtension(): string { - return static::$outputExtension; + $extension = static::$outputExtension; + + if (! str_starts_with($extension, '.') || str_contains($extension, '/') || str_contains($extension, '\\')) { + throw new InvalidArgumentException(sprintf( + "Invalid output extension '%s' declared by %s: extensions must start with a dot and cannot contain path separators.", + $extension, static::class + )); + } + + return $extension; } /** diff --git a/packages/framework/tests/Feature/HydePageTest.php b/packages/framework/tests/Feature/HydePageTest.php index 01debb8b107..a752e0e7417 100644 --- a/packages/framework/tests/Feature/HydePageTest.php +++ b/packages/framework/tests/Feature/HydePageTest.php @@ -20,6 +20,7 @@ use Hyde\Pages\MarkdownPost; use Hyde\Support\Models\Route; use Hyde\Testing\TestCase; +use InvalidArgumentException; /** * Test the base HydePage class. @@ -128,6 +129,21 @@ public function testOutputPathUsesTheOutputExtensionOfThePageClass() $this->assertSame('output/hello-world.txt', NonHtmlOutputTestPage::outputPath('hello-world')); } + public function testOutputExtensionWithoutLeadingDotThrows() + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage("Invalid output extension 'txt' declared by"); + + MissingDotOutputExtensionTestPage::outputExtension(); + } + + public function testOutputExtensionWithPathSeparatorThrows() + { + $this->expectException(InvalidArgumentException::class); + + PathSeparatorOutputExtensionTestPage::outputExtension(); + } + public function testGetRouteKeyForPageWithNonHtmlOutputExtensionIncludesExtension() { $this->assertSame('output/hello-world.txt', (new NonHtmlOutputTestPage('hello-world'))->getRouteKey()); @@ -1320,6 +1336,20 @@ class NonHtmlOutputTestPage extends HydePage public static string $template = 'template'; } +class MissingDotOutputExtensionTestPage extends HydePage +{ + use VoidCompiler; + + public static string $outputExtension = 'txt'; +} + +class PathSeparatorOutputExtensionTestPage extends HydePage +{ + use VoidCompiler; + + public static string $outputExtension = '.txt/../evil'; +} + class ConfigurableSourcesTestPage extends HydePage { use VoidCompiler; From b2fc7c80b233e0950dd8fed83718e23b2e649f66 Mon Sep 17 00:00:00 2001 From: Emma De Silva Date: Mon, 13 Jul 2026 16:15:32 +0200 Subject: [PATCH 024/117] Fail fast for page classes using the renamed v2 extension API MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A page class still declaring $fileExtension (or overriding the renamed methods) is not discoverable under the new API, so a build would succeed while silently omitting the entire page type — including unmodified vendor packages the upgrade script never touches. Discovery now throws an actionable exception naming the class and the rename. This is a temporary upgrade aid that can be removed in a future release. Co-Authored-By: Claude Fable 5 --- HYDEPHP_V3_PLANNING.md | 2 +- UPGRADE.md | 3 ++ .../src/Foundation/Kernel/FileCollection.php | 20 +++++++++++ .../Feature/HydeExtensionFeatureTest.php | 33 +++++++++++++++++++ 4 files changed, 57 insertions(+), 1 deletion(-) diff --git a/HYDEPHP_V3_PLANNING.md b/HYDEPHP_V3_PLANNING.md index b70a9ec6b04..1a7d72608c1 100644 --- a/HYDEPHP_V3_PLANNING.md +++ b/HYDEPHP_V3_PLANNING.md @@ -43,7 +43,7 @@ Having this document in code lets us know the devlopment state at any given poin ### Breaking Changes -- Renamed the static page class property `$fileExtension` to `$sourceExtension`, and the `fileExtension()` and `setFileExtension()` methods to `sourceExtension()` and `setSourceExtension()`. The old name was ambiguous now that page classes also declare an output extension through the new `$outputExtension` property, and the renamed pair makes the source/output distinction explicit. 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). +- Renamed the static page class property `$fileExtension` to `$sourceExtension`, and the `fileExtension()` and `setFileExtension()` methods to `sourceExtension()` and `setSourceExtension()`. The old name was ambiguous now that page classes also declare an output extension through the new `$outputExtension` property, and the renamed pair makes the source/output distinction explicit. 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). Page discovery fails fast with an actionable exception for registered page classes that still use the old API, instead of silently skipping them during builds. - In-memory page identifiers ending in `.json`, `.txt`, or `.xml` (including redirect paths declared in `hyde.redirects`) now compile to that path as-is instead of gaining a second `.html` extension. The old double-extension outputs (like `data.json.html`) were almost certainly never intended, so no real sites are expected to be affected. - Removed `Redirect::create()`, `Redirect::store()`, and the `Redirect` constructor's `showText` argument. Redirects must now be declared in `hyde.redirects`, keeping all generated output inside the kernel-owned build graph. Redirect routes are intrinsically excluded from navigation menus and sitemaps, and always include an accessible fallback link. - Removed the `InMemoryPage` instance macro API. Dynamic contents should now be supplied with a closure, while custom methods and other behavior belong in an `InMemoryPage` subclass. diff --git a/UPGRADE.md b/UPGRADE.md index 759d901c048..6c0cb0194d9 100644 --- a/UPGRADE.md +++ b/UPGRADE.md @@ -340,6 +340,9 @@ 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. +You do not need to hunt for affected classes: page discovery fails fast with an exception naming any +registered page class that still uses the old API, instead of silently skipping the class during builds. + ## Migration Checklist Use this checklist to track your upgrade progress: diff --git a/packages/framework/src/Foundation/Kernel/FileCollection.php b/packages/framework/src/Foundation/Kernel/FileCollection.php index 7659cbbd9df..75a4cd4fab3 100644 --- a/packages/framework/src/Foundation/Kernel/FileCollection.php +++ b/packages/framework/src/Foundation/Kernel/FileCollection.php @@ -9,8 +9,11 @@ use Hyde\Framework\Exceptions\FileNotFoundException; use Hyde\Pages\Concerns\HydePage; use Hyde\Support\Filesystem\SourceFile; +use RuntimeException; use function basename; +use function method_exists; +use function property_exists; use function str_starts_with; /** @@ -41,12 +44,29 @@ protected function runDiscovery(): void { /** @var class-string<\Hyde\Pages\Concerns\HydePage> $pageClass */ foreach ($this->kernel->getRegisteredPageClasses() as $pageClass) { + self::guardAgainstLegacyFileExtensionApi($pageClass); + if ($pageClass::isDiscoverable()) { $this->discoverFilesFor($pageClass); } } } + /** + * Fail fast for page classes still using the file extension API renamed in HydePHP v3. + * Without this guard such classes are simply not discoverable, so a build would + * succeed while silently omitting the entire page type. Temporary upgrade + * aid that can be removed in a future release. + * + * @param class-string $pageClass + */ + protected static function guardAgainstLegacyFileExtensionApi(string $pageClass): void + { + if (property_exists($pageClass, 'fileExtension') || method_exists($pageClass, 'fileExtension') || method_exists($pageClass, 'setFileExtension')) { + throw new RuntimeException("The page class [$pageClass] uses the \$fileExtension API which was renamed in HydePHP v3. Rename \$fileExtension, fileExtension(), and setFileExtension() to \$sourceExtension, sourceExtension(), and setSourceExtension()."); + } + } + protected function runExtensionHandlers(): void { /** @var class-string<\Hyde\Foundation\Concerns\HydeExtension> $extension */ diff --git a/packages/framework/tests/Feature/HydeExtensionFeatureTest.php b/packages/framework/tests/Feature/HydeExtensionFeatureTest.php index 4fe121218cf..f074acc165e 100644 --- a/packages/framework/tests/Feature/HydeExtensionFeatureTest.php +++ b/packages/framework/tests/Feature/HydeExtensionFeatureTest.php @@ -18,6 +18,7 @@ use Hyde\Support\Models\Route; use Hyde\Testing\TestCase; use InvalidArgumentException; +use RuntimeException; use stdClass; /** @@ -43,6 +44,16 @@ protected function setUp(): void $this->kernel = HydeKernel::getInstance(); } + public function testDiscoveryFailsFastForPageClassUsingLegacyFileExtensionApi() + { + $this->kernel->registerExtension(LegacyPageExtension::class); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('uses the $fileExtension API which was renamed in HydePHP v3'); + + $this->kernel->boot(); + } + public function testHandlerMethodsAreCalledByDiscovery() { $this->kernel->registerExtension(HydeTestExtension::class); @@ -219,6 +230,28 @@ public function compile(): string } } +class LegacyFileExtensionPageClass extends HydePage +{ + public static string $sourceDirectory = 'foo'; + public static string $outputDirectory = 'foo'; + public static string $fileExtension = '.txt'; + + public function compile(): string + { + return ''; + } +} + +class LegacyPageExtension extends HydeExtension +{ + public static function getPageClasses(): array + { + return [ + LegacyFileExtensionPageClass::class, + ]; + } +} + class TestPageExtension extends HydeExtension { public static function getPageClasses(): array From d1241d8077ebe1aeb18f5bb896a16739496734b8 Mon Sep 17 00:00:00 2001 From: Emma De Silva Date: Mon, 13 Jul 2026 16:17:11 +0200 Subject: [PATCH 025/117] Reject redirect source paths that cannot produce a working redirect A redirect declared for a path like legacy.json appears valid and produces a file, but the meta refresh markup cannot redirect when the file is served as non-HTML content. The configuration now fails fast with an InvalidConfigurationException instead of silently emitting a broken page. Revises the earlier document-only decision in the epic. Co-Authored-By: Claude Fable 5 --- EPIC_NON_HTML_PAGES.md | 10 +++++---- HYDEPHP_V3_PLANNING.md | 3 ++- .../framework/src/Support/Models/Redirect.php | 9 ++++++++ .../framework/tests/Feature/RedirectTest.php | 21 +++++++++++++++++++ 4 files changed, 38 insertions(+), 5 deletions(-) diff --git a/EPIC_NON_HTML_PAGES.md b/EPIC_NON_HTML_PAGES.md index e77422577f1..59a161499eb 100644 --- a/EPIC_NON_HTML_PAGES.md +++ b/EPIC_NON_HTML_PAGES.md @@ -93,10 +93,12 @@ versioned docs route keys like `docs/1.x/index` would false-positive > `InMemoryPage::EXPLICIT_OUTPUT_EXTENSIONS` constant, checked by > `identifierHasExplicitOutputExtension()`; subclasses customize the recognized > extensions by overriding the constant rather than the algorithm. -> `Redirect` deliberately inherits it (no special case): a redirect declared for a -> non-HTML path now emits its file at that exact path instead of an unreachable -> double-extension path. Neither behavior can make a meta-refresh work when the -> server serves the file as non-HTML — document that limitation in PR 8. +> `Redirect` rejects source paths ending in a recognized non-HTML extension with +> an `InvalidConfigurationException` (revised during PR 1 review from the earlier +> "inherit with documented limitation" decision): a meta-refresh redirect cannot +> work when the file is served as non-HTML, and neither the old unreachable +> double-extension output nor an as-is file delivers the advertised redirect, so +> the configuration fails fast instead of silently producing a broken page. ### D3: Sitemap inclusion becomes a page-level concern diff --git a/HYDEPHP_V3_PLANNING.md b/HYDEPHP_V3_PLANNING.md index 1a7d72608c1..be50d9dc1af 100644 --- a/HYDEPHP_V3_PLANNING.md +++ b/HYDEPHP_V3_PLANNING.md @@ -44,7 +44,8 @@ Having this document in code lets us know the devlopment state at any given poin ### Breaking Changes - Renamed the static page class property `$fileExtension` to `$sourceExtension`, and the `fileExtension()` and `setFileExtension()` methods to `sourceExtension()` and `setSourceExtension()`. The old name was ambiguous now that page classes also declare an output extension through the new `$outputExtension` property, and the renamed pair makes the source/output distinction explicit. 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). Page discovery fails fast with an actionable exception for registered page classes that still use the old API, instead of silently skipping them during builds. -- In-memory page identifiers ending in `.json`, `.txt`, or `.xml` (including redirect paths declared in `hyde.redirects`) now compile to that path as-is instead of gaining a second `.html` extension. The old double-extension outputs (like `data.json.html`) were almost certainly never intended, so no real sites are expected to be affected. +- In-memory page identifiers ending in `.json`, `.txt`, or `.xml` now compile to that path as-is instead of gaining a second `.html` extension. The old double-extension outputs (like `data.json.html`) were almost certainly never intended, so no real sites are expected to be affected. +- Redirect source paths declared in `hyde.redirects` ending in `.json`, `.txt`, or `.xml` are now rejected with an exception, since a meta refresh redirect cannot work for files served as non-HTML content. Previously such entries silently produced an unreachable `legacy.json.html` file, so no working configuration is affected. - Removed `Redirect::create()`, `Redirect::store()`, and the `Redirect` constructor's `showText` argument. Redirects must now be declared in `hyde.redirects`, keeping all generated output inside the kernel-owned build graph. Redirect routes are intrinsically excluded from navigation menus and sitemaps, and always include an accessible fallback link. - Removed the `InMemoryPage` instance macro API. Dynamic contents should now be supplied with a closure, while custom methods and other behavior belong in an `InMemoryPage` subclass. - Removed `InMemoryPage` content-source precedence. Calls that previously supplied both `contents` and `view` must retain only the intended source; positional view calls that used an empty-string contents placeholder must use `null` instead. diff --git a/packages/framework/src/Support/Models/Redirect.php b/packages/framework/src/Support/Models/Redirect.php index 8a9cfbe95d3..cce1543ed35 100644 --- a/packages/framework/src/Support/Models/Redirect.php +++ b/packages/framework/src/Support/Models/Redirect.php @@ -6,6 +6,7 @@ use Hyde\Pages\InMemoryPage; use Hyde\Markdown\Models\FrontMatter; +use Hyde\Framework\Exceptions\InvalidConfigurationException; use Illuminate\Support\Facades\View; use function str_ends_with; @@ -44,12 +45,20 @@ public function showInNavigation(): bool return false; } + /** + * @throws \Hyde\Framework\Exceptions\InvalidConfigurationException If the path ends in a non-HTML output extension, + * as meta refresh redirects only work for HTML pages. + */ protected function normalizePath(string $path): string { if (str_ends_with($path, '.html')) { return substr($path, 0, -5); } + if (static::identifierHasExplicitOutputExtension($path)) { + throw new InvalidConfigurationException("Invalid redirect source path [$path]: redirects use HTML meta refresh tags, which cannot work for files served as non-HTML content."); + } + return $path; } } diff --git a/packages/framework/tests/Feature/RedirectTest.php b/packages/framework/tests/Feature/RedirectTest.php index 1ce30f25c27..ac6d8e70427 100644 --- a/packages/framework/tests/Feature/RedirectTest.php +++ b/packages/framework/tests/Feature/RedirectTest.php @@ -8,8 +8,10 @@ use Hyde\Foundation\HydeKernel; use Hyde\Framework\Actions\StaticPageBuilder; use Hyde\Hyde; +use Hyde\Framework\Exceptions\InvalidConfigurationException; use Hyde\Support\Models\Redirect; use Hyde\Testing\TestCase; +use RuntimeException; #[\PHPUnit\Framework\Attributes\CoversClass(\Hyde\Support\Models\Redirect::class)] class RedirectTest extends TestCase @@ -64,6 +66,25 @@ public function testConfiguredRedirectsAreRegisteredWithTheKernelAndBuiltWithThe Filesystem::unlink('_site/foo.html'); } + public function testRedirectWithNonHtmlSourcePathIsRejected() + { + $this->expectException(InvalidConfigurationException::class); + $this->expectExceptionMessage('Invalid redirect source path [legacy.json]: redirects use HTML meta refresh tags'); + + new Redirect('legacy.json', 'new-location'); + } + + public function testConfiguredRedirectWithNonHtmlSourcePathIsRejected() + { + config(['hyde.redirects' => ['legacy.txt' => 'new-location']]); + HydeKernel::setInstance(new HydeKernel(Hyde::path())); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Invalid redirect source path [legacy.txt]'); + + Hyde::pages(); + } + public function testRedirectsCannotWriteOutsideTheBuildPipeline() { $this->assertFalse(method_exists(Redirect::class, 'create')); From daeb1693b2aa63299f074b61da8d36c3a73d3e43 Mon Sep 17 00:00:00 2001 From: Emma De Silva Date: Mon, 13 Jul 2026 16:18:01 +0200 Subject: [PATCH 026/117] Cover named arguments in the extension rename migration rule Co-Authored-By: Claude Fable 5 --- HYDEPHP_V3_PLANNING.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/HYDEPHP_V3_PLANNING.md b/HYDEPHP_V3_PLANNING.md index be50d9dc1af..32a822d0724 100644 --- a/HYDEPHP_V3_PLANNING.md +++ b/HYDEPHP_V3_PLANNING.md @@ -98,6 +98,9 @@ track of them. Add an entry here whenever a change requires mechanical migration the framework calls `sourceExtension()`. The rule must be scoped to `Hyde\Pages\Concerns\HydePage` subclasses (or known Hyde symbols) — it must not rename unrelated properties or methods that happen to share the name. +- The rename must also cover named arguments: `Page::setFileExtension(fileExtension: '.md')` + becomes `Page::setSourceExtension(sourceExtension: '.md')`, since the parameter was renamed + along with the method. Include a dedicated Rector fixture for this case. - Dynamic references cannot be migrated automatically and should be called out as manual upgrade cases: variable method/property names (`$method = 'fileExtension'; $pageClass::$method()`), reflection, and string-based access. From 5f0e46013ad2da9363818920c9d797d2a183efe4 Mon Sep 17 00:00:00 2001 From: Emma De Silva Date: Mon, 13 Jul 2026 16:18:01 +0200 Subject: [PATCH 027/117] Test discoverable page class with distinct source and output extensions Ends the coverage gap where only InMemoryPage was exercised through the full build: a custom page class with a .md source extension and a .txt output extension is now tested through source discovery, route registration, the build command, and the output file name. Co-Authored-By: Claude Fable 5 --- .../tests/Feature/NonHtmlPageOutputTest.php | 42 +++++++++++++++++++ 1 file changed, 42 insertions(+) diff --git a/packages/framework/tests/Feature/NonHtmlPageOutputTest.php b/packages/framework/tests/Feature/NonHtmlPageOutputTest.php index 6de6afcd95c..e9d2c6cf6f6 100644 --- a/packages/framework/tests/Feature/NonHtmlPageOutputTest.php +++ b/packages/framework/tests/Feature/NonHtmlPageOutputTest.php @@ -9,6 +9,8 @@ use Hyde\Pages\InMemoryPage; use Hyde\Foundation\HydeKernel; use Hyde\Foundation\Facades\Routes; +use Hyde\Foundation\Concerns\HydeExtension; +use Hyde\Pages\Concerns\HydePage; use Illuminate\Support\Facades\File; use Hyde\Framework\Actions\StaticPageBuilder; @@ -106,4 +108,44 @@ public function testInMemoryPageWithDeclaredExtensionCanCompileUsingView() $this->assertFileExists(Hyde::path('_site/robots.txt')); $this->assertSame('User-agent: *', file_get_contents(Hyde::path('_site/robots.txt'))); } + + public function testBuildCommandCompilesDiscoverableCustomPageClassWithNonHtmlOutputExtension() + { + $this->directory('_leaves'); + $this->file('_leaves/hello.md', 'Hello World'); + + Hyde::kernel()->registerExtension(NonHtmlPageTestExtension::class); + + $this->assertSame(['hello'], DiscoverableNonHtmlTestPage::files()); + $this->assertTrue(Routes::exists('hello.txt')); + + $this->artisan('build')->assertExitCode(0); + + $this->assertFileExists(Hyde::path('_site/hello.txt')); + $this->assertSame('Hello World', file_get_contents(Hyde::path('_site/hello.txt'))); + $this->assertFileDoesNotExist(Hyde::path('_site/hello.txt.html')); + $this->assertFileDoesNotExist(Hyde::path('_site/hello.html')); + $this->assertFileDoesNotExist(Hyde::path('_site/hello.md.html')); + } +} + +class DiscoverableNonHtmlTestPage extends HydePage +{ + public static string $sourceDirectory = '_leaves'; + public static string $outputDirectory = ''; + public static string $sourceExtension = '.md'; + public static string $outputExtension = '.txt'; + + public function compile(): string + { + return file_get_contents(Hyde::path($this->getSourcePath())); + } +} + +class NonHtmlPageTestExtension extends HydeExtension +{ + public static function getPageClasses(): array + { + return [DiscoverableNonHtmlTestPage::class]; + } } From 7f7ea988385e196dda2b4b30bb6cdec91d144658 Mon Sep 17 00:00:00 2001 From: Emma De Silva Date: Mon, 13 Jul 2026 16:48:49 +0200 Subject: [PATCH 028/117] Update stale epic note on redirect paths with non-HTML extensions Co-Authored-By: Claude Fable 5 --- EPIC_NON_HTML_PAGES.md | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/EPIC_NON_HTML_PAGES.md b/EPIC_NON_HTML_PAGES.md index 59a161499eb..d39252545f4 100644 --- a/EPIC_NON_HTML_PAGES.md +++ b/EPIC_NON_HTML_PAGES.md @@ -157,11 +157,13 @@ Implementation notes (branch `v3/non-html-pages-foundation`): "Upgrade script rules" for the release-time Rector script. - Non-HTML extension handling was placed in `RouteKey::fromPage()` (see D1 note) rather than only in `outputPath()`, so route keys and output paths cannot drift. -- One qualification to "no compiled-output changes": in-memory page identifiers - (including `hyde.redirects` paths) that already end in an allowlisted extension - previously produced double-extension output (`data.json.html`); they now compile - to the declared path as-is. Recorded in the v3 release notes as a breaking change, - though the old output was almost certainly never intended or relied upon. +- One qualification to "no compiled-output changes": ordinary in-memory page + identifiers that already end in an allowlisted extension previously produced + double-extension output (`data.json.html`); they now compile to the declared + path as-is. `hyde.redirects` paths using those extensions are instead rejected + (see the D2 note), since their HTML meta-refresh content cannot work when served + as non-HTML. Both recorded in the v3 release notes as breaking changes, though + the old outputs were almost certainly never intended or relied upon. ### PR 2 — Realtime compiler: route-first resolution for non-HTML paths From 4a0d11fd0dbc3a801543fe4fbae0a7f8ac99e478 Mon Sep 17 00:00:00 2001 From: Emma De Silva Date: Mon, 13 Jul 2026 16:59:28 +0200 Subject: [PATCH 029/117] Resolve registered routes before proxying static assets in the dev server Replaces the hardcoded search.json suffix exemption in the realtime compiler with a generic route lookup: any path with a registered page route is compiled, and only routeless asset-like paths are proxied. Since the route check needs the booted application, the shouldProxy() predicate is dissolved into handle(), leaving the /media/ prefix as the only boot-free fast path. Co-Authored-By: Claude Fable 5 --- .../realtime-compiler/src/Routing/Router.php | 52 +++---------------- 1 file changed, 8 insertions(+), 44 deletions(-) diff --git a/packages/realtime-compiler/src/Routing/Router.php b/packages/realtime-compiler/src/Routing/Router.php index 315e6bf3eaf..4638194660b 100644 --- a/packages/realtime-compiler/src/Routing/Router.php +++ b/packages/realtime-compiler/src/Routing/Router.php @@ -19,9 +19,6 @@ class Router protected Request $request; - protected bool $assetPathResolved = false; - protected ?string $resolvedAssetPath = null; - public function __construct(Request $request) { $this->request = $request; @@ -29,7 +26,9 @@ public function __construct(Request $request) public function handle(): Response { - if ($this->shouldProxy()) { + // Media files are always static assets, so we proxy them + // directly without paying for booting the application. + if (str_starts_with($this->request->path, '/media/')) { return $this->proxyStatic(); } @@ -43,41 +42,16 @@ public function handle(): Response return $virtualRoutes[$this->request->path]($this->request); } - // A path with a file extension that matches neither a static file nor a page (like a - // missing stylesheet or source map) is a missing asset, and not a missing web page, - // so we send a normal 404 response instead of the pretty page not found error. + // A path with a file extension that isn't a web page is a static asset request, + // unless a page route is registered for the path (like `docs/search.json`), + // as pages take precedence over the on-disk files the proxy serves. if ($this->hasAssetLikeExtension() && ! PageRouter::hasRoute($this->request)) { - return $this->notFound(); + return $this->proxyStatic(); } return PageRouter::handle($this->request); } - /** - * If the request is not for a web page, we assume it's - * a static asset, which we instead want to proxy. - */ - protected function shouldProxy(): bool - { - // Always proxy media files. This condition is just to improve performance - // without having to check the file extension. - if (str_starts_with($this->request->path, '/media/')) { - return true; - } - - if (! $this->hasAssetLikeExtension()) { - return false; - } - - // Don't proxy the search.json file, as it's generated on the fly. - if (str_ends_with($this->request->path, 'search.json')) { - return false; - } - - // Dotted page routes (like documentation version folders) are proxied only when a matching asset exists. - return $this->resolveAssetPath() !== null; - } - /** * Does the request path have an extension that isn't a web page? * @@ -90,16 +64,6 @@ protected function hasAssetLikeExtension(): bool return $extension !== null && $extension !== 'html'; } - protected function resolveAssetPath(): ?string - { - if (! $this->assetPathResolved) { - $this->resolvedAssetPath = AssetFileLocator::find($this->request->path); - $this->assetPathResolved = true; - } - - return $this->resolvedAssetPath; - } - /** * Override the configured site URL so compiled pages reference the local * server instead of the production URL. Without this, assets such as @@ -182,7 +146,7 @@ protected function getConfiguredServerHost(string $scheme): string */ protected function proxyStatic(): Response { - $path = $this->resolveAssetPath(); + $path = AssetFileLocator::find($this->request->path); if ($path === null) { return $this->notFound(); From 48510e55b8cb153c06febb624e15d3803099081e Mon Sep 17 00:00:00 2001 From: Emma De Silva Date: Mon, 13 Jul 2026 17:00:30 +0200 Subject: [PATCH 030/117] Test route-first resolution against matching and missing static assets Co-Authored-By: Claude Fable 5 --- .../tests/RealtimeCompilerTest.php | 59 +++++++++++++++++++ 1 file changed, 59 insertions(+) diff --git a/packages/realtime-compiler/tests/RealtimeCompilerTest.php b/packages/realtime-compiler/tests/RealtimeCompilerTest.php index 20822b305cd..afee273b289 100644 --- a/packages/realtime-compiler/tests/RealtimeCompilerTest.php +++ b/packages/realtime-compiler/tests/RealtimeCompilerTest.php @@ -216,6 +216,65 @@ public function testErrorThrownWhileCompilingExistingDottedPageIsNotSentAsA404() } } + public function testServesRegisteredPageRouteEvenWhenMatchingAssetExists() + { + $this->mockCompilerRoute('9.x'); + + Filesystem::ensureDirectoryExists('_pages/9.x'); + Filesystem::put('_pages/9.x/index.md', '# Hello World!'); + Filesystem::put('_media/9.x', 'static decoy'); + + try { + $kernel = new HttpKernel(); + $response = $kernel->handle(new Request()); + + $this->assertSame(200, $response->statusCode); + $this->assertStringContainsString('Hello World!', $response->body); + $this->assertStringNotContainsString('static decoy', $response->body); + } finally { + Filesystem::deleteDirectory('_pages/9.x'); + Filesystem::unlink('_media/9.x'); + } + } + + public function testDocsSearchJsonRouteWinsOverMatchingAssetFile() + { + $this->mockCompilerRoute('docs/search.json'); + + Filesystem::put('_docs/index.md', '# Hello World!'); + Filesystem::ensureDirectoryExists('_media/docs'); + Filesystem::put('_media/docs/search.json', '"static decoy"'); + + try { + $kernel = new HttpKernel(); + $response = $kernel->handle(new Request()); + + $this->assertSame(200, $response->statusCode); + $this->assertNotSame('"static decoy"', $response->body); + $this->assertIsArray(json_decode($response->body, true)); + } finally { + Filesystem::unlink('_docs/index.md'); + Filesystem::deleteDirectory('_media/docs'); + } + } + + public function testProxiesRootLevelAssetWhenNoRouteMatchesThePath() + { + $this->mockCompilerRoute('data.json'); + + Filesystem::put('_media/data.json', '{"static": true}'); + + try { + $kernel = new HttpKernel(); + $response = $kernel->handle(new Request()); + + $this->assertSame(200, $response->statusCode); + $this->assertSame('{"static": true}', $response->body); + } finally { + Filesystem::unlink('_media/data.json'); + } + } + public function testTrailingSlashesAreNormalizedFromRoute() { $this->mockCompilerRoute('foo/'); From 5603b42f817b9b8605f68345c69b20b491da7f47 Mon Sep 17 00:00:00 2001 From: Emma De Silva Date: Mon, 13 Jul 2026 17:01:41 +0200 Subject: [PATCH 031/117] Document realtime compiler route-first resolution in epic and release notes Co-Authored-By: Claude Fable 5 --- EPIC_NON_HTML_PAGES.md | 22 +++++++++++++++++++++- HYDEPHP_V3_PLANNING.md | 1 + 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/EPIC_NON_HTML_PAGES.md b/EPIC_NON_HTML_PAGES.md index d39252545f4..62abaae5a54 100644 --- a/EPIC_NON_HTML_PAGES.md +++ b/EPIC_NON_HTML_PAGES.md @@ -165,7 +165,7 @@ Implementation notes (branch `v3/non-html-pages-foundation`): as non-HTML. Both recorded in the v3 release notes as breaking changes, though the old outputs were almost certainly never intended or relied upon. -### PR 2 — Realtime compiler: route-first resolution for non-HTML paths +### PR 2 — Realtime compiler: route-first resolution for non-HTML paths ✅ Implemented Goal: `hyde serve` serves any registered route regardless of extension; no filename special cases. @@ -178,6 +178,26 @@ filename special cases. - `PageRouter::getContentType()` already handles txt/xml/json; extend the map only if new types come up. +Implementation notes (branch `v3/non-html-pages-realtime-compiler`): + +- The route lookup needs the booted application, but `shouldProxy()` ran before + booting, so the predicate was dissolved into `Router::handle()` instead of + booting inside it: the `/media/` prefix remains the only boot-free fast path, + and any other asset-like path is proxied only when no registered route matches. + Missing assets fall through to the 404 in `proxyStatic()`, which absorbed the + previous separate missing-asset branch (same response either way). +- Perf consequence: requests for existing static files outside `media/` now boot + the app before being proxied, since routes must be consulted first. Such files + are rare (Hyde assets live under `media/`), and every non-proxied request + already booted. +- Behavior fix beyond the search.json generalization: a static file whose path + shadowed a registered dotted route (like a `_media/9.x` file next to a + `9.x/index` page) was previously served instead of the page; the page now wins. + Conversely, a routeless file like `_media/search.json` requested as + `/search.json` was previously 404'd by the suffix special case and is now + proxied like any other asset. +- `getContentType()` untouched — no new content types came up. + ### PR 3 — `TextPage` class (the headline feature) Goal: drop `_pages/robots.txt` in, get `_site/robots.txt` out. diff --git a/HYDEPHP_V3_PLANNING.md b/HYDEPHP_V3_PLANNING.md index 32a822d0724..76766c27312 100644 --- a/HYDEPHP_V3_PLANNING.md +++ b/HYDEPHP_V3_PLANNING.md @@ -36,6 +36,7 @@ Having this document in code lets us know the devlopment state at any given poin ### Minor Changes and Cleanup - The `Redirect` page class constructor now accepts an optional `$matter` parameter, used by the framework to hide the generated documentation root redirect from navigation menus. Existing usages are unaffected. +- The realtime compiler now resolves registered page routes before proxying static assets, replacing the hardcoded `search.json` exemption, so `hyde serve` serves any registered route regardless of its output extension. Registered pages now always win over a static file at the same path; the previous behavior of serving such a shadowing file only affected the dev server and no real setups are expected to be affected. - Removed the legacy `checkForDeprecatedRunMixCommandUsage` check and the placeholder `--run-dev`/`--run-prod` options from the `build` command, which were kept in v2 only to surface a helpful error message. ([#2461](https://github.com/hydephp/develop/pull/2461)) - Removed the deprecated `hyde.server.dashboard` boolean config check from `DashboardController::enabled()`, which was kept in v2 for backwards compatibility but had since then been replaced with `hyde.server.dashboard.enabled`. ([#2461](https://github.com/hydephp/develop/pull/2462)) From 17558079ec44b574eea81ee3349169de8c361691 Mon Sep 17 00:00:00 2001 From: Emma De Silva Date: Mon, 13 Jul 2026 17:41:53 +0200 Subject: [PATCH 032/117] Revise non-HTML pages epic scope --- EPIC_NON_HTML_PAGES.md | 92 +++++++++++++++++++++++++----------------- 1 file changed, 56 insertions(+), 36 deletions(-) diff --git a/EPIC_NON_HTML_PAGES.md b/EPIC_NON_HTML_PAGES.md index 62abaae5a54..da18ae59988 100644 --- a/EPIC_NON_HTML_PAGES.md +++ b/EPIC_NON_HTML_PAGES.md @@ -23,10 +23,12 @@ three different mechanisms for emitting non-HTML files, each with its own tradeo needs a hardcoded exemption to serve it (`packages/realtime-compiler/src/Routing/Router.php:72-75`). 3. **Verbatim source files** (`HtmlPage`) are autodiscovered from `_pages` and copied - as-is — but only for `.html`. + as-is, but this is a source-file convenience specific to HTML rather than a + requirement for supporting non-HTML output. -Unifying these under one model gives us `robots.txt`/`llms.txt` support nearly for -free, fixes real bugs, and simplifies the framework. +Making the output format part of the page model gives us `robots.txt`/`llms.txt` +support nearly for free, fixes real bugs, and simplifies the framework. It does not +require every output format to have a matching filesystem-discovered page class. ### Bugs and gaps this epic fixes @@ -70,8 +72,9 @@ and `docs/search.json` (index) coexist as distinct routes, which they already do > **Implemented (PR 1):** `RouteKey::fromPage()` appends the page class's declared > non-HTML output extension to the key (skipping it when the identifier already ends -> with it), so classes like PR 3's `TextPage` are D1-compliant out of the box and -> PR 2 can rely on "route key == request path with only `.html` stripped" universally. +> with it), so custom page classes declaring a non-HTML output extension are +> D1-compliant out of the box and PR 2 can rely on "route key == request path with +> only `.html` stripped" universally. ### D2: Output extension is declared, not inferred @@ -79,8 +82,8 @@ Do **not** infer "this identifier has an extension" from a dot in the identifier versioned docs route keys like `docs/1.x/index` would false-positive (`pathinfo('docs/1.x')['extension'] === 'x'`). Instead the extension is declared: -- File-discovered classes declare it statically, mirroring the existing - `HtmlPage::$sourceExtension` pattern: `TextPage::$outputExtension = '.txt'`. +- File-discovered custom classes declare it statically, mirroring the existing + `HtmlPage::$sourceExtension` pattern. - `HydePage` gets `public static string $outputExtension = '.html'` (or an instance-level hook), and `outputPath()` uses it instead of the hardcoded `'.html'`. This removes the `getOutputPath()` override dance. @@ -119,14 +122,35 @@ Registration happens in `HydeCoreExtension::discoverPages()` behind the existing `Features::hasSitemap()` / `Features::hasRss()` conditions, replacing the registrations in `BuildTaskService::registerFrameworkTasks()`. -### D5: Source files beat generators +### D5: User-defined pages beat generators -If a user provides `_pages/robots.txt`, the framework does not register its generated -`RobotsTxtPage`. Same pattern as `discoverDocumentationRootRedirect()`, which skips -when a user-defined route exists. This gives a smooth escalation path: -feature default → config tweaks → file on disk → fully dynamic page in code. +If the page collection already contains a user-defined page with a route key such +as `robots.txt`, the framework does not register its generated `RobotsTxtPage`. +This follows the pattern of `discoverDocumentationRootRedirect()`, which skips when +a user-defined route exists. +Users can register an `InMemoryPage` from a service provider or provide a custom page +class through an extension. This gives a smooth escalation path: +feature default → config tweaks → fully custom page in code. -## Work breakdown (one PR each, in dependency order) +### D6: No built-in `TextPage` or `.txt` autodiscovery + +First-class non-HTML support is about a page's output path and participation in the +route/build/serve lifecycle; it does not require a dedicated source-backed page class +for each file extension. `InMemoryPage::make('robots.txt', contents: ...)` already +provides the full lifecycle integration and is a better fit for the dynamic content +advanced users commonly need, while the planned generated robots and llms pages cover +the common cases without any source file at all. + +A core `TextPage` would add only the convenience of autodiscovering `_pages/*.txt`, +while creating pressure for parallel `XmlPage`, `JsonPage`, and similar classes. +Plain-text files also cannot carry front matter, requiring page-type defaults or +special handling for navigation and sitemap behavior. That framework surface is not +justified by the narrow drop-a-file use case. If demand emerges for filesystem-backed +verbatim files, it should be designed as a generic raw/public-file mechanism instead +of one page class per extension. Custom discoverable page classes remain supported as +an extension point. + +## Work breakdown (planned PR sequence, in dependency order) ### PR 1 — Foundation: declared output extensions on `HydePage` ✅ Implemented @@ -150,7 +174,7 @@ Implementation notes (branch `v3/non-html-pages-foundation`): (with `fileExtension()`/`setFileExtension()` becoming `sourceExtension()`/ `setSourceExtension()`) so the source/output pair reads symmetrically — the old name really meant the source extension, and fixing the vocabulary before later - page types (`TextPage`, sitemap, RSS, robots, llms) build on it avoids much larger + non-HTML page types (sitemap, RSS, robots, llms) build on it avoids much larger churn. Clean break, no compatibility aliases: independently redeclared static properties cannot alias each other without precedence/synchronization hacks. The mechanical migration is recorded in `HYDEPHP_V3_PLANNING.md` under @@ -198,21 +222,15 @@ Implementation notes (branch `v3/non-html-pages-realtime-compiler`): proxied like any other asset. - `getContentType()` untouched — no new content types came up. -### PR 3 — `TextPage` class (the headline feature) - -Goal: drop `_pages/robots.txt` in, get `_site/robots.txt` out. +### PR 3 — `TextPage` autodiscovery ❌ Removed from scope -- New `TextPage extends HydePage`: `$sourceDirectory = '_pages'`, - `$outputDirectory = ''`, `$sourceExtension = '.txt'`, - `$outputExtension = '.txt'`; `compile()` returns file contents verbatim - (mirror `HtmlPage`). -- Register in `HydeCoreExtension::getPageClasses()`. No `Feature::TextPages` enum - case — the feature is always on, since it is inert without source files. -- Hidden from navigation menus by default; excluded from sitemap via PR 4's - mechanism (or an interim guard if PR 4 lands later). -- Docs: static-pages page gains a "Text pages" section, including the in-code - variant (service provider / `booting()` callback assembling contents dynamically, - e.g. looping over routes to build a robots.txt — the `InMemoryPage` example). +The proposed `TextPage` class was evaluated after the non-HTML foundation landed and +removed from the epic per D6. The foundation already makes a `.txt` `InMemoryPage` +first-class, the common robots/llms cases will have generated pages, and advanced +content is often dynamic. Adding a core class solely for `_pages/*.txt` discovery +would introduce extension-specific framework surface without solving the broader +verbatim-file problem. Documentation will instead show the service provider / +`booting()` registration pattern for custom text output. ### PR 4 — Sitemap inclusion policy @@ -247,8 +265,8 @@ Goal: sensible robots.txt out of the box, zero config. - `RobotsTxtPage extends InMemoryPage`, route key `robots.txt`; default output `User-agent: * / Allow: /` plus a `Sitemap:` line when `Features::hasSitemap()`. -- Config (e.g. `hyde.robots`) for disallow rules / disabling; source-file precedence - per D5 (a `_pages/robots.txt` `TextPage` wins). +- Config (e.g. `hyde.robots`) for disallow rules / disabling; user-defined page + precedence per D5 (an explicitly registered `robots.txt` page wins). - Depends on PRs 1, 2, 5 patterns. ### PR 7 — Generated `llms.txt` @@ -264,16 +282,18 @@ Goal: best-in-class llms.txt support — no other SSG generates this well out of ### PR 8 — Documentation & release notes -- Document `TextPage`, in-code virtual pages, `sitemap: false` front matter, - robots/llms config, and the "source file beats generator" rule. -- Update `HYDEPHP_V3_PLANNING.md` release notes: new features (TextPage, robots.txt, - llms.txt, serve support for sitemap/RSS), breaking changes (build task classes +- Document in-code virtual pages, `sitemap: false` front matter, robots/llms config, + and the "user-defined page beats generator" rule. +- Update `HYDEPHP_V3_PLANNING.md` release notes: new features (robots.txt, llms.txt, + serve support for sitemap/RSS), breaking changes (build task classes removed/relocated, search.json removed from sitemaps). ## Out of scope (noted for later) -- Blade-processed text files (`robots.blade.txt`) analogous to `BladePage` — wait for - demand; the in-code `InMemoryPage` path covers dynamic cases. +- Filesystem autodiscovery for verbatim or Blade-processed text files + (`robots.txt`, `robots.blade.txt`) — wait for demand; the in-code `InMemoryPage` + path covers custom and dynamic cases. If added later, prefer a generic mechanism + for raw/public files over extension-specific page classes. - `llms-full.txt` / per-page markdown exports. - Generalizing `GenerateBuildManifest` or search-index generation commands beyond what PR 5 requires. From c2c02b653940e2553677cd0d1ece9c19a2675247 Mon Sep 17 00:00:00 2001 From: Emma De Silva Date: Mon, 13 Jul 2026 18:17:28 +0200 Subject: [PATCH 033/117] Revise the epic for changed scope Co-Authored-By: Claude Fable 5 --- EPIC_NON_HTML_PAGES.md | 101 +++++++++++++++++++++++++++++++++++------ 1 file changed, 86 insertions(+), 15 deletions(-) diff --git a/EPIC_NON_HTML_PAGES.md b/EPIC_NON_HTML_PAGES.md index da18ae59988..e06ed00a988 100644 --- a/EPIC_NON_HTML_PAGES.md +++ b/EPIC_NON_HTML_PAGES.md @@ -103,6 +103,18 @@ versioned docs route keys like `docs/1.x/index` would false-positive > double-extension output nor an as-is file delivers the advertised redirect, so > the configuration fails fast instead of silently producing a broken page. +> **Two output mechanisms, both intended (PR 1):** the output extension is *per-class +> static* (`$outputExtension`) for file-discovered custom page classes, and +> *per-instance identifier-encoded* for `InMemoryPage` (detected from the identifier +> via the allowlist). The latter is deliberate: output is not needed at discovery +> time the way source paths are, so it does not have to be static. This is what lets +> a single `InMemoryPage` class emit different extensions per instance — +> `new InMemoryPage('robots.txt')` and `make('data.json')` — without a subclass, which +> is exactly what the generated pages (D4) and the user `make()` escape hatch need. +> Consequence recorded for D3: an `InMemoryPage`'s *static* `outputExtension()` stays +> `.html` even when the instance compiles to `robots.txt`; the real output extension +> lives in the resolved output path. + ### D3: Sitemap inclusion becomes a page-level concern Replace the `instanceof Redirect` filter in `SitemapGenerator` with a @@ -112,16 +124,49 @@ with defaults: `false` for redirects and for pages whose output is not `.html`, sitemap/feed/robots pages from self-listing, and gives users per-page opt-out — a standalone feature in its own right. -### D4: Generators become pages; generator actions stay +> **Implementation constraint (from PR 1) — read before writing `showInSitemap()`:** +> the "output is not `.html`" default MUST be derived from the page's *resolved +> output path* (e.g. the extension of `getOutputPath()`), not from the static +> `outputExtension()` accessor. For file-discovered custom pages the two agree, but +> per D2 an `InMemoryPage` encodes its extension in the identifier while its static +> `outputExtension()` stays `.html`. So a `robots.txt` / `sitemap.xml` / `llms.txt` +> `InMemoryPage` reports `.html` statically despite compiling to a non-HTML file. +> Keying the non-HTML default off `getOutputPath()` makes all four generated pages +> self-exclude correctly; keying it off `outputExtension()` would silently +> re-introduce the exact `search.json` leak this epic exists to fix. This is the +> kind of "rule implemented only for the cases the current PR exercised" trap the +> agent workflow warns about — the discovered-page tests would pass while the +> InMemoryPage-backed generated pages regress. + +### D4: Generators become container-resolved pages; generator actions stay + +Each generated file is registered as an `InMemoryPage` whose compiled contents +resolve its generator **from the container at build time**, e.g. a `sitemap.xml` +page whose compile step returns `app(SitemapGenerator::class)->generate()`. The XML +generator actions (`SitemapGenerator`, `RssFeedGenerator`) are untouched and remain +the default implementations — this is still the `DocumentationSearchIndex` → +`GeneratesDocumentationSearchIndex` split, but the generator is *resolved*, not +`new`'d. + +Resolving through the container is the point: a user can rebind `SitemapGenerator` +(or the page's content closure) to swap the output without replacing the page — a +lighter-weight customization tier than D5's full page override. Contents must be +produced **lazily** (a `compile` macro / closure or a thin subclass `compile()`), +never eager string content, since generation must run at build time against the +final route set. -`SitemapPage`, `RssFeedPage` (and later `RobotsTxtPage`, `LlmsTxtPage`) extend -`InMemoryPage` with a `compile()` that delegates to the existing generator classes — -exactly the `DocumentationSearchIndex` → `GeneratesDocumentationSearchIndex` split. -The XML generator actions (`SitemapGenerator`, `RssFeedGenerator`) are untouched. Registration happens in `HydeCoreExtension::discoverPages()` behind the existing `Features::hasSitemap()` / `Features::hasRss()` conditions, replacing the registrations in `BuildTaskService::registerFrameworkTasks()`. +**Plain page vs. thin subclass is deferred to PR 5.** D3 already defaults non-HTML +pages out of the sitemap, so a subclass is *not* needed merely to stop a generated +page self-listing. The only remaining reasons to introduce `SitemapPage extends +InMemoryPage` (mirroring `DocumentationSearchIndex`) are type identity (`instanceof`) +and giving the `build:sitemap` / `build:rss` commands a concrete class to +instantiate. Lean toward a plain `InMemoryPage` registered with a container-bound +`compile` closure unless PR 5 surfaces a concrete need for the subclass. + ### D5: User-defined pages beat generators If the page collection already contains a user-defined page with a route key such @@ -129,8 +174,10 @@ as `robots.txt`, the framework does not register its generated `RobotsTxtPage`. This follows the pattern of `discoverDocumentationRootRedirect()`, which skips when a user-defined route exists. Users can register an `InMemoryPage` from a service provider or provide a custom page -class through an extension. This gives a smooth escalation path: -feature default → config tweaks → fully custom page in code. +class through an extension. Combined with D4's container-resolved generators, this +gives a smooth escalation path: +feature default → config tweaks → rebind the generator (or content closure) in the +container → fully custom page in code. ### D6: No built-in `TextPage` or `.txt` autodiscovery @@ -188,6 +235,13 @@ Implementation notes (branch `v3/non-html-pages-foundation`): (see the D2 note), since their HTML meta-refresh content cannot work when served as non-HTML. Both recorded in the v3 release notes as breaking changes, though the old outputs were almost certainly never intended or relied upon. +- **Post-implementation review: no changes required.** The two output-extension + mechanisms now coexist and both are intended (see the D2 "two output mechanisms" + note) — per-class static for discovered classes, per-instance identifier-encoded + for `InMemoryPage`, so instance-based non-HTML output works without a subclass. + The one constraint this places downstream is recorded in the D3 implementation + note: sitemap / non-HTML detection must read the resolved output path, not the + static `outputExtension()` accessor. ### PR 2 — Realtime compiler: route-first resolution for non-HTML paths ✅ Implemented @@ -221,6 +275,11 @@ Implementation notes (branch `v3/non-html-pages-realtime-compiler`): `/search.json` was previously 404'd by the suffix special case and is now proxied like any other asset. - `getContentType()` untouched — no new content types came up. +- **Post-implementation review: no changes required.** Route-first resolution is + clean and the shadowing/`search.json` regression tests cover the behavior changes. + Worth adding (if not already present elsewhere) an explicit versioned + `docs/1.x/search.json` serve test alongside the un-versioned one, since the + versioned dotted path is the case most likely to regress silently. ### PR 3 — `TextPage` autodiscovery ❌ Removed from scope @@ -237,6 +296,9 @@ verbatim-file problem. Documentation will instead show the service provider / Goal: pages control their own sitemap presence; fix the production `search.json` leak. - `HydePage::showInSitemap()` per D3 + `sitemap: false` front matter support. +- **Derive the non-HTML default from `getOutputPath()`, not `outputExtension()`** + (see the D3 implementation constraint) so InMemoryPage-backed generated pages + self-exclude correctly. - `SitemapGenerator::generate()` filters on it instead of `instanceof Redirect`. - Changelog note: search indexes no longer appear in sitemaps (bugfix). - Independent of PRs 1-3; must land before or with PR 5. @@ -246,14 +308,21 @@ Goal: pages control their own sitemap presence; fix the production `search.json` Goal: `sitemap.xml` and `feed.xml` are routes — served by `hyde serve`, listed in `route:list`, included in the build manifest, overridable in user land. -- `SitemapPage` / `RssFeedPage` extend `InMemoryPage`, `compile()` delegates to the - existing generators; RSS route key comes from `RssFeedGenerator::getFilename()` - (config `hyde.rss.filename`). +- Register `sitemap.xml` / `feed.xml` as `InMemoryPage`s per D4, with a lazy + `compile` that resolves the generator from the container + (`app(SitemapGenerator::class)->generate()` / `app(RssFeedGenerator::class)->generate()`), + so the implementation is swappable via container rebind. RSS route key comes from + `RssFeedGenerator::getFilename()` (config `hyde.rss.filename`). +- **Decide plain `InMemoryPage` (compile macro) vs. thin subclass here** (D4). Default + to plain + container binding unless type identity or command wiring forces a + subclass; note that D3 already handles sitemap self-exclusion either way. - Register in `HydeCoreExtension::discoverPages()` behind `Features::hasSitemap()` / `Features::hasRss()`; remove `GenerateSitemap`/`GenerateRssFeed` from `BuildTaskService::registerFrameworkTasks()` (evaluate deprecation vs. removal — v3 allows breaking changes, but third-party code may reference the task classes). -- Rewire `build:sitemap` / `build:rss` commands to `StaticPageBuilder::handle(new …Page())`. +- Rewire `build:sitemap` / `build:rss` commands to build the same registered page + via `StaticPageBuilder::handle(...)` (a shared factory if the pages are plain + `InMemoryPage`s, or `new …Page()` if subclassed). - Verify `GlobalMetadataBag` head links and the `hyde.url` requirements still hold (`Features::hasSitemap()` already requires a site URL). - Nice side effect: build output shows them under "Dynamic Pages" with the standard @@ -263,7 +332,7 @@ Goal: `sitemap.xml` and `feed.xml` are routes — served by `hyde serve`, listed Goal: sensible robots.txt out of the box, zero config. -- `RobotsTxtPage extends InMemoryPage`, route key `robots.txt`; default output +- `robots.txt` registered as an `InMemoryPage` per D4; default output `User-agent: * / Allow: /` plus a `Sitemap:` line when `Features::hasSitemap()`. - Config (e.g. `hyde.robots`) for disallow rules / disabling; user-defined page precedence per D5 (an explicitly registered `robots.txt` page wins). @@ -276,14 +345,16 @@ Goal: best-in-class llms.txt support — no other SSG generates this well out of - `GeneratesLlmsTxt` action per the llms.txt spec: site name as H1, `hyde.description` ("about" blockquote), sections of route links using page titles and the new documentation page abstracts (#2523) as link descriptions. -- `LlmsTxtPage extends InMemoryPage` wired like robots.txt (feature-gated, config - for section grouping/exclusions, source-file precedence). +- `llms.txt` registered as an `InMemoryPage` wired like robots.txt (feature-gated, + config for section grouping/exclusions, container-resolved generator, user-defined + page precedence). - Consider `llms-full.txt` (full page contents) as a follow-up, not in scope. ### PR 8 — Documentation & release notes - Document in-code virtual pages, `sitemap: false` front matter, robots/llms config, - and the "user-defined page beats generator" rule. + the container-rebind customization tier for generated pages, and the "user-defined + page beats generator" rule. - Update `HYDEPHP_V3_PLANNING.md` release notes: new features (robots.txt, llms.txt, serve support for sitemap/RSS), breaking changes (build task classes removed/relocated, search.json removed from sitemaps). From 9393ac6752388f263b8fd912031d4fc571e06b8b Mon Sep 17 00:00:00 2001 From: Emma De Silva Date: Mon, 13 Jul 2026 19:33:46 +0200 Subject: [PATCH 034/117] Update EPIC_NON_HTML_PAGES.md Co-Authored-By: Claude Fable 5 --- EPIC_NON_HTML_PAGES.md | 47 +++++++++++++++++++++++++++++++++++++++++- 1 file changed, 46 insertions(+), 1 deletion(-) diff --git a/EPIC_NON_HTML_PAGES.md b/EPIC_NON_HTML_PAGES.md index e06ed00a988..c36a45a48a8 100644 --- a/EPIC_NON_HTML_PAGES.md +++ b/EPIC_NON_HTML_PAGES.md @@ -115,6 +115,26 @@ versioned docs route keys like `docs/1.x/index` would false-positive > `.html` even when the instance compiles to `robots.txt`; the real output extension > lives in the resolved output path. +> **Open decision — the allowlist relocates the trap, it does not eliminate it.** +> The allowlist correctly avoids the `docs/1.x` false-positive that killed dot-inference, +> but it is conservative in the false-*negative* direction: an identifier the allowlist +> does not recognize still gets the `.html` suffix. So `make('site.webmanifest')` → +> `site.webmanifest.html` and `make('sitemap.xsl')` → `sitemap.xsl.html` — the exact +> `robots.txt.html` bug this decision fixed, one extension over, and both are plausible +> power-user cases (PWA manifest, sitemap stylesheet). The allowlist stays as the +> zero-config default, but it should not be the *only* path. Two mitigations, at least +> one required before PR 8: +> 1. **(Preferred) Keep D2 option (b) available alongside (a):** an explicit + > `make(..., outputExtension: '.xsl')` / constructor parameter that bypasses the + > allowlist for any extension. Allowlist for ergonomics, explicit param for + > everything else — this actually closes the trap instead of moving it. +> 2. **(Minimum) Document the `EXPLICIT_OUTPUT_EXTENSIONS` override clearly** so a user + > can subclass and extend the recognized list, and ensure the appended-`.html` + > output is at least discoverable/greppable rather than silently wrong. + > Decide in PR 5/6 whether the first-party generated files need any extension outside + > the allowlist (none currently do — all are `.txt`/`.xml`), which determines whether + > (1) is needed for the framework itself or only for the secondary audience. + ### D3: Sitemap inclusion becomes a page-level concern Replace the `instanceof Redirect` filter in `SitemapGenerator` with a @@ -179,6 +199,17 @@ gives a smooth escalation path: feature default → config tweaks → rebind the generator (or content closure) in the container → fully custom page in code. +> **Timing caveat — the skip check is ordering-sensitive.** "Is a `robots.txt` route +> already registered" is evaluated at `discoverPages()` time, so whether a user's page +> wins depends on it being visible at that moment. A page registered via a late +> `booting()` callback may or may not be present depending on boot ordering. The +> `discoverDocumentationRootRedirect()` precedent suggests this is fine, but "fine and +> ordering-dependent" is exactly what passes in our tests and breaks for the one user +> who registers late. PR 5/6 MUST include an end-to-end test asserting that a +> user-registered `robots.txt` (via both a `HydeExtension` page class and a `booting()` +> `addPage()` callback) suppresses the generated one — this is the D5 contract and the +> most likely silent failure for the power-user audience. + ### D6: No built-in `TextPage` or `.txt` autodiscovery First-class non-HTML support is about a page's output path and participation in the @@ -316,6 +347,15 @@ Goal: `sitemap.xml` and `feed.xml` are routes — served by `hyde serve`, listed - **Decide plain `InMemoryPage` (compile macro) vs. thin subclass here** (D4). Default to plain + container binding unless type identity or command wiring forces a subclass; note that D3 already handles sitemap self-exclusion either way. +- **Verify the generators are actually container-resolvable** before advertising the + rebind: no unresolvable constructor dependencies, not `final` (or the swap can't be + bound). D4's whole swappability tier is a lie if `app(SitemapGenerator::class)` + can't be rebound. Add a test that rebinds the generator and asserts the page's + compiled output changes. +- Confirm none of the first-party generated files (`sitemap.xml`, `feed.xml`, + `robots.txt`, `llms.txt`) need an extension outside the D2 allowlist — they don't + today, which settles the D2 "open decision" in favor of the framework not needing + option (b) for its own use (the power-user escape hatch is a separate call). - Register in `HydeCoreExtension::discoverPages()` behind `Features::hasSitemap()` / `Features::hasRss()`; remove `GenerateSitemap`/`GenerateRssFeed` from `BuildTaskService::registerFrameworkTasks()` (evaluate deprecation vs. removal — @@ -348,6 +388,11 @@ Goal: best-in-class llms.txt support — no other SSG generates this well out of - `llms.txt` registered as an `InMemoryPage` wired like robots.txt (feature-gated, config for section grouping/exclusions, container-resolved generator, user-defined page precedence). +- **Make the default state (on vs. off) a deliberate decision with a clean opt-out**, + not an afterthought. Some of our audience is privacy/OPSEC-minded and will have + opinions about surfacing content to AI crawlers; the feature flag (and its default) + should be a first-class, documented choice, mirroring how `robots.txt` disabling + works, rather than something a user has to discover. - Consider `llms-full.txt` (full page contents) as a follow-up, not in scope. ### PR 8 — Documentation & release notes @@ -370,4 +415,4 @@ Goal: best-in-class llms.txt support — no other SSG generates this well out of what PR 5 requires. - Reconsidering the page-type `Feature` enum cases (`HtmlPages`, `BladePages`, etc.) altogether — arguably redundant since not creating source files has the same - effect. Worth a separate v3 discussion; this epic simply doesn't add new ones. + effect. Worth a separate v3 discussion; this epic simply doesn't add new ones. \ No newline at end of file From d05cec289857e56c314642485599daa565903dce Mon Sep 17 00:00:00 2001 From: Emma De Silva Date: Mon, 13 Jul 2026 19:40:16 +0200 Subject: [PATCH 035/117] Add page-level sitemap inclusion control via showInSitemap() Replaces the instanceof Redirect filter in SitemapGenerator with a HydePage::showInSitemap() method backed by the sitemap front matter key, defaulting to false for pages whose resolved output path is not HTML. Co-Authored-By: Claude Fable 5 --- .../Features/XmlGenerators/SitemapGenerator.php | 3 +-- packages/framework/src/Pages/Concerns/HydePage.php | 13 +++++++++++++ packages/framework/src/Support/Models/Redirect.php | 5 +++++ 3 files changed, 19 insertions(+), 2 deletions(-) diff --git a/packages/framework/src/Framework/Features/XmlGenerators/SitemapGenerator.php b/packages/framework/src/Framework/Features/XmlGenerators/SitemapGenerator.php index f21f18404de..abb862fb216 100644 --- a/packages/framework/src/Framework/Features/XmlGenerators/SitemapGenerator.php +++ b/packages/framework/src/Framework/Features/XmlGenerators/SitemapGenerator.php @@ -15,7 +15,6 @@ use Hyde\Facades\Filesystem; use Hyde\Pages\InMemoryPage; use Hyde\Support\Models\Route; -use Hyde\Support\Models\Redirect; use Illuminate\Support\Carbon; use Hyde\Pages\DocumentationPage; use Hyde\Foundation\Facades\Routes; @@ -31,7 +30,7 @@ class SitemapGenerator extends BaseXmlGenerator public function generate(): static { Routes::all()->each(function (Route $route): void { - if (! $route->getPage() instanceof Redirect) { + if ($route->getPage()->showInSitemap()) { $this->addRoute($route); } }); diff --git a/packages/framework/src/Pages/Concerns/HydePage.php b/packages/framework/src/Pages/Concerns/HydePage.php index dab4f6a5ac3..f4d90680334 100644 --- a/packages/framework/src/Pages/Concerns/HydePage.php +++ b/packages/framework/src/Pages/Concerns/HydePage.php @@ -32,6 +32,7 @@ use function rtrim; use function sprintf; use function str_contains; +use function str_ends_with; use function str_starts_with; /** @@ -408,6 +409,18 @@ public function showInNavigation(): bool return ! $this->navigation->hidden; } + /** + * Can the page be shown in the sitemap? + * + * It can be explicitly set in the front matter using the `sitemap` key, + * otherwise it defaults to true for pages compiled to HTML files, and + * false for pages compiled to non-HTML files like `robots.txt`. + */ + public function showInSitemap(): bool + { + return (bool) $this->matter('sitemap', str_ends_with($this->getOutputPath(), '.html')); + } + /** * Get the priority of the page in the navigation menu. */ diff --git a/packages/framework/src/Support/Models/Redirect.php b/packages/framework/src/Support/Models/Redirect.php index cce1543ed35..d85baf1fa82 100644 --- a/packages/framework/src/Support/Models/Redirect.php +++ b/packages/framework/src/Support/Models/Redirect.php @@ -45,6 +45,11 @@ public function showInNavigation(): bool return false; } + public function showInSitemap(): bool + { + return false; + } + /** * @throws \Hyde\Framework\Exceptions\InvalidConfigurationException If the path ends in a non-HTML output extension, * as meta refresh redirects only work for HTML pages. From 02961fa79e63d29db751cd3500f5cab941342ca5 Mon Sep 17 00:00:00 2001 From: Emma De Silva Date: Mon, 13 Jul 2026 19:41:51 +0200 Subject: [PATCH 036/117] Add showInSitemap to the page unit test contract Co-Authored-By: Claude Fable 5 --- packages/framework/tests/Feature/RedirectTest.php | 5 +++++ .../tests/Unit/Pages/BladePageUnitTest.php | 6 ++++++ .../Unit/Pages/DocumentationPageUnitTest.php | 6 ++++++ .../tests/Unit/Pages/HtmlPageUnitTest.php | 6 ++++++ .../tests/Unit/Pages/InMemoryPageUnitTest.php | 15 +++++++++++++++ .../tests/Unit/Pages/MarkdownPageUnitTest.php | 6 ++++++ .../tests/Unit/Pages/MarkdownPostUnitTest.php | 6 ++++++ .../testing/src/Common/BaseHydePageUnitTest.php | 2 ++ 8 files changed, 52 insertions(+) diff --git a/packages/framework/tests/Feature/RedirectTest.php b/packages/framework/tests/Feature/RedirectTest.php index ac6d8e70427..9e4fcf35715 100644 --- a/packages/framework/tests/Feature/RedirectTest.php +++ b/packages/framework/tests/Feature/RedirectTest.php @@ -95,4 +95,9 @@ public function testRedirectsAreHiddenFromNavigation() { $this->assertFalse((new Redirect('foo', 'bar'))->showInNavigation()); } + + public function testRedirectsAreHiddenFromSitemaps() + { + $this->assertFalse((new Redirect('foo', 'bar'))->showInSitemap()); + } } diff --git a/packages/framework/tests/Unit/Pages/BladePageUnitTest.php b/packages/framework/tests/Unit/Pages/BladePageUnitTest.php index 9885229d110..742b9d763dd 100644 --- a/packages/framework/tests/Unit/Pages/BladePageUnitTest.php +++ b/packages/framework/tests/Unit/Pages/BladePageUnitTest.php @@ -86,6 +86,12 @@ public function testShowInNavigation() $this->assertTrue((new BladePage())->showInNavigation()); } + public function testShowInSitemap() + { + $this->assertTrue((new BladePage())->showInSitemap()); + $this->assertFalse((new BladePage('foo', ['sitemap' => false]))->showInSitemap()); + } + public function testNavigationMenuPriority() { $this->assertSame(999, (new BladePage())->navigationMenuPriority()); diff --git a/packages/framework/tests/Unit/Pages/DocumentationPageUnitTest.php b/packages/framework/tests/Unit/Pages/DocumentationPageUnitTest.php index 45c0eac8347..791ef32b70d 100644 --- a/packages/framework/tests/Unit/Pages/DocumentationPageUnitTest.php +++ b/packages/framework/tests/Unit/Pages/DocumentationPageUnitTest.php @@ -88,6 +88,12 @@ public function testShowInNavigation() $this->assertTrue((new DocumentationPage())->showInNavigation()); } + public function testShowInSitemap() + { + $this->assertTrue((new DocumentationPage())->showInSitemap()); + $this->assertFalse((new DocumentationPage('foo', ['sitemap' => false]))->showInSitemap()); + } + public function testNavigationMenuPriority() { $this->assertSame(999, (new DocumentationPage())->navigationMenuPriority()); diff --git a/packages/framework/tests/Unit/Pages/HtmlPageUnitTest.php b/packages/framework/tests/Unit/Pages/HtmlPageUnitTest.php index dde8733a710..d8bab24b266 100644 --- a/packages/framework/tests/Unit/Pages/HtmlPageUnitTest.php +++ b/packages/framework/tests/Unit/Pages/HtmlPageUnitTest.php @@ -122,6 +122,12 @@ public function testShowInNavigation() $this->assertTrue((new HtmlPage())->showInNavigation()); } + public function testShowInSitemap() + { + $this->assertTrue((new HtmlPage())->showInSitemap()); + $this->assertFalse((new HtmlPage('foo', ['sitemap' => false]))->showInSitemap()); + } + public function testNavigationMenuPriority() { $this->assertSame(999, (new HtmlPage())->navigationMenuPriority()); diff --git a/packages/framework/tests/Unit/Pages/InMemoryPageUnitTest.php b/packages/framework/tests/Unit/Pages/InMemoryPageUnitTest.php index d30cfadc0d1..17b77ae2524 100644 --- a/packages/framework/tests/Unit/Pages/InMemoryPageUnitTest.php +++ b/packages/framework/tests/Unit/Pages/InMemoryPageUnitTest.php @@ -126,6 +126,21 @@ public function testShowInNavigation() $this->assertTrue((new InMemoryPage('foo'))->showInNavigation()); } + public function testShowInSitemap() + { + $this->assertTrue((new InMemoryPage('foo'))->showInSitemap()); + $this->assertFalse((new InMemoryPage('foo', ['sitemap' => false]))->showInSitemap()); + } + + public function testShowInSitemapIsFalseForPagesWithNonHtmlOutputPaths() + { + $this->assertFalse((new InMemoryPage('robots.txt'))->showInSitemap()); + $this->assertFalse((new InMemoryPage('data.json'))->showInSitemap()); + $this->assertFalse((new InMemoryPage('custom.xml'))->showInSitemap()); + + $this->assertTrue((new InMemoryPage('robots.txt', ['sitemap' => true]))->showInSitemap()); + } + public function testNavigationMenuPriority() { $this->assertSame(999, (new InMemoryPage('foo'))->navigationMenuPriority()); diff --git a/packages/framework/tests/Unit/Pages/MarkdownPageUnitTest.php b/packages/framework/tests/Unit/Pages/MarkdownPageUnitTest.php index b0442fe5815..8b335b43845 100644 --- a/packages/framework/tests/Unit/Pages/MarkdownPageUnitTest.php +++ b/packages/framework/tests/Unit/Pages/MarkdownPageUnitTest.php @@ -124,6 +124,12 @@ public function testShowInNavigation() $this->assertTrue((new MarkdownPage())->showInNavigation()); } + public function testShowInSitemap() + { + $this->assertTrue((new MarkdownPage())->showInSitemap()); + $this->assertFalse((new MarkdownPage('foo', ['sitemap' => false]))->showInSitemap()); + } + public function testNavigationMenuPriority() { $this->assertSame(999, (new MarkdownPage())->navigationMenuPriority()); diff --git a/packages/framework/tests/Unit/Pages/MarkdownPostUnitTest.php b/packages/framework/tests/Unit/Pages/MarkdownPostUnitTest.php index a548c358163..c8c30195dbe 100644 --- a/packages/framework/tests/Unit/Pages/MarkdownPostUnitTest.php +++ b/packages/framework/tests/Unit/Pages/MarkdownPostUnitTest.php @@ -124,6 +124,12 @@ public function testShowInNavigation() $this->assertFalse((new MarkdownPost())->showInNavigation()); } + public function testShowInSitemap() + { + $this->assertTrue((new MarkdownPost())->showInSitemap()); + $this->assertFalse((new MarkdownPost('foo', ['sitemap' => false]))->showInSitemap()); + } + public function testNavigationMenuPriority() { $this->assertSame(10, (new MarkdownPost())->navigationMenuPriority()); diff --git a/packages/testing/src/Common/BaseHydePageUnitTest.php b/packages/testing/src/Common/BaseHydePageUnitTest.php index d443b092bca..47b3791e329 100644 --- a/packages/testing/src/Common/BaseHydePageUnitTest.php +++ b/packages/testing/src/Common/BaseHydePageUnitTest.php @@ -92,6 +92,8 @@ abstract public function testGetRoute(); abstract public function testShowInNavigation(); + abstract public function testShowInSitemap(); + abstract public function testGetSourcePath(); abstract public function testGetLink(); From 8c1ee9c6bfd1c1b89c0c0657d2ddd178aead1fe5 Mon Sep 17 00:00:00 2001 From: Emma De Silva Date: Mon, 13 Jul 2026 19:43:24 +0200 Subject: [PATCH 037/117] Test sitemap exclusions for front matter, non-HTML pages, and search indexes Co-Authored-By: Claude Fable 5 --- .../tests/Feature/NonHtmlPageOutputTest.php | 15 +++++++ .../Feature/Services/SitemapServiceTest.php | 44 ++++++++++++++++++- .../tests/Feature/SitemapFeatureTest.php | 7 +-- 3 files changed, 58 insertions(+), 8 deletions(-) diff --git a/packages/framework/tests/Feature/NonHtmlPageOutputTest.php b/packages/framework/tests/Feature/NonHtmlPageOutputTest.php index e9d2c6cf6f6..7cde07439ea 100644 --- a/packages/framework/tests/Feature/NonHtmlPageOutputTest.php +++ b/packages/framework/tests/Feature/NonHtmlPageOutputTest.php @@ -109,6 +109,21 @@ public function testInMemoryPageWithDeclaredExtensionCanCompileUsingView() $this->assertSame('User-agent: *', file_get_contents(Hyde::path('_site/robots.txt'))); } + public function testBuildCommandExcludesNonHtmlPagesFromTheSitemap() + { + $this->withSiteUrl(); + + Hyde::kernel()->booting(function (HydeKernel $kernel): void { + $kernel->pages()->addPage(new InMemoryPage('robots.txt', contents: 'User-agent: *')); + }); + + $this->artisan('build')->assertExitCode(0); + + $this->assertFileExists(Hyde::path('_site/robots.txt')); + $this->assertFileExists(Hyde::path('_site/sitemap.xml')); + $this->assertStringNotContainsString('robots.txt', file_get_contents(Hyde::path('_site/sitemap.xml'))); + } + public function testBuildCommandCompilesDiscoverableCustomPageClassWithNonHtmlOutputExtension() { $this->directory('_leaves'); diff --git a/packages/framework/tests/Feature/Services/SitemapServiceTest.php b/packages/framework/tests/Feature/Services/SitemapServiceTest.php index 276cfee5d22..c6dddc01cbb 100644 --- a/packages/framework/tests/Feature/Services/SitemapServiceTest.php +++ b/packages/framework/tests/Feature/Services/SitemapServiceTest.php @@ -10,8 +10,11 @@ use Hyde\Facades\Filesystem; use Hyde\Framework\Features\XmlGenerators\SitemapGenerator; use Hyde\Hyde; +use Hyde\Pages\InMemoryPage; +use Hyde\Support\Models\Route; use Hyde\Testing\TestCase; use Hyde\Foundation\HydeKernel; +use Hyde\Foundation\Facades\Routes; use Illuminate\Support\Facades\File; #[\PHPUnit\Framework\Attributes\CoversClass(\Hyde\Framework\Features\XmlGenerators\SitemapGenerator::class)] @@ -94,18 +97,55 @@ public function testGenerateAddsDocumentationPagesToXml() $this->restoreDocumentationSearch(); } - public function test_generate_adds_documentation_search_pages_to_xml() + public function testGenerateAddsDocumentationSearchPageButNotSearchIndexToXml() { Filesystem::touch('_docs/foo.md'); $service = new SitemapGenerator(); $service->generate(); - $this->assertCount(5, $service->getXmlElement()->url); + $this->assertCount(4, $service->getXmlElement()->url); + $this->assertStringContainsString('docs/search.html', $service->getXml()); + $this->assertStringNotContainsString('search.json', $service->getXml()); Filesystem::unlink('_docs/foo.md'); } + public function testGenerateDoesNotAddPagesWithSitemapFrontMatterSetToFalse() + { + file_put_contents(Hyde::path('_pages/foo.md'), "---\nsitemap: false\n---\n\n# Foo"); + + $service = new SitemapGenerator(); + $service->generate(); + + $this->assertCount(2, $service->getXmlElement()->url); + $this->assertStringNotContainsString('foo', $service->getXml()); + + Filesystem::unlink('_pages/foo.md'); + } + + public function testGenerateDoesNotAddPagesWithNonHtmlOutputPaths() + { + Routes::addRoute(new Route(new InMemoryPage('robots.txt'))); + + $service = new SitemapGenerator(); + $service->generate(); + + $this->assertCount(2, $service->getXmlElement()->url); + $this->assertStringNotContainsString('robots.txt', $service->getXml()); + } + + public function testGenerateAddsNonHtmlPagesWithSitemapFrontMatterSetToTrue() + { + Routes::addRoute(new Route(new InMemoryPage('robots.txt', ['sitemap' => true]))); + + $service = new SitemapGenerator(); + $service->generate(); + + $this->assertCount(3, $service->getXmlElement()->url); + $this->assertStringContainsString('robots.txt', $service->getXml()); + } + public function testGetXmlReturnsXmlString() { $service = new SitemapGenerator(); diff --git a/packages/framework/tests/Feature/SitemapFeatureTest.php b/packages/framework/tests/Feature/SitemapFeatureTest.php index f2a55e04aca..9544ffb39bc 100644 --- a/packages/framework/tests/Feature/SitemapFeatureTest.php +++ b/packages/framework/tests/Feature/SitemapFeatureTest.php @@ -50,6 +50,7 @@ public function testTheSitemapFeature() protected function setUpBroadSiteStructure(): void { $this->file('_pages/about.md', "# About\n\nThis is the about page."); + $this->file('_pages/secret.md', "---\nsitemap: false\n---\n\n# Secret\n\nThis page is excluded from the sitemap."); $this->file('_pages/contact.html', '

Contact

This is the contact page.

'); $this->file('_posts/hello-world.md', "# Hello, World!\n\nThis is the first post."); $this->file('_posts/second-post.md', "# Second Post\n\nThis is the second post."); @@ -123,12 +124,6 @@ protected function expected(string $version): string daily 0.9 - - https://example.com/docs/search.json - 2024-01-01T12:00:00+00:00 - weekly - 0.5 - https://example.com/docs/search.html 2024-01-01T12:00:00+00:00 From 2b14693bf52a002fd0534ed28e2e5b25aea19e38 Mon Sep 17 00:00:00 2001 From: Emma De Silva Date: Mon, 13 Jul 2026 19:47:10 +0200 Subject: [PATCH 038/117] Document the sitemap inclusion policy in the epic and release notes Co-Authored-By: Claude Fable 5 --- EPIC_NON_HTML_PAGES.md | 31 ++++++++++++++++++++++++++++++- HYDEPHP_V3_PLANNING.md | 2 ++ 2 files changed, 32 insertions(+), 1 deletion(-) diff --git a/EPIC_NON_HTML_PAGES.md b/EPIC_NON_HTML_PAGES.md index c36a45a48a8..a78b086e852 100644 --- a/EPIC_NON_HTML_PAGES.md +++ b/EPIC_NON_HTML_PAGES.md @@ -158,6 +158,17 @@ a standalone feature in its own right. > agent workflow warns about — the discovered-page tests would pass while the > InMemoryPage-backed generated pages regress. +> **Implemented (PR 4):** `HydePage::showInSitemap()` reads the `sitemap` front +> matter key, defaulting to whether the resolved output path (`getOutputPath()`) +> ends in `.html`, per the constraint above. Front matter wins in both directions, +> so `sitemap: true` opts a non-HTML page back in. One refinement to "defaults +> `false` for redirects": `Redirect` overrides `showInSitemap()` to return `false` +> unconditionally, mirroring its `showInNavigation()` and the already-recorded +> release note that redirect routes are intrinsically excluded from navigation and +> sitemaps — a redirect page has no front matter channel in `hyde.redirects`, and +> listing redirects in a sitemap is an SEO anti-pattern, so an opt-in would only +> be a trap. + ### D4: Generators become container-resolved pages; generator actions stay Each generated file is registered as an `InMemoryPage` whose compiled contents @@ -322,7 +333,7 @@ would introduce extension-specific framework surface without solving the broader verbatim-file problem. Documentation will instead show the service provider / `booting()` registration pattern for custom text output. -### PR 4 — Sitemap inclusion policy +### PR 4 — Sitemap inclusion policy ✅ Implemented Goal: pages control their own sitemap presence; fix the production `search.json` leak. @@ -334,6 +345,24 @@ Goal: pages control their own sitemap presence; fix the production `search.json` - Changelog note: search indexes no longer appear in sitemaps (bugfix). - Independent of PRs 1-3; must land before or with PR 5. +Implementation notes (branch `v3/non-html-pages-sitemap-inclusion-policy`): + +- Implemented exactly per D3 (see the D3 "Implemented" note for the front matter + semantics and the `Redirect` refinement). `showInSitemap()` joined the + `BaseHydePageUnitTest` contract; the InMemoryPage unit test covers the + identifier-encoded non-HTML default that the static-extension tests cannot. +- The non-HTML self-exclusion is verified end-to-end: a registered `robots.txt` + `InMemoryPage` is built by the real `build` command and asserted absent from + the built `sitemap.xml`, guarding the D3 resolved-output-path constraint + against regression by construction rather than only at the unit level. +- Two existing tests asserted the leak as expected behavior and were flipped: + `SitemapServiceTest` now asserts the docs search *page* stays while the search + *index* is excluded, and the `SitemapFeatureTest` expected XML dropped its + `docs/search.json` entry (it also gained a `sitemap: false` page proving the + front matter opt-out through the `build:sitemap` command). +- No UPGRADE.md entry: the fix requires no user action, and nothing realistic + depended on search indexes appearing in sitemaps. + ### PR 5 — Convert sitemap and RSS from build tasks to pages Goal: `sitemap.xml` and `feed.xml` are routes — served by `hyde serve`, listed in diff --git a/HYDEPHP_V3_PLANNING.md b/HYDEPHP_V3_PLANNING.md index 76766c27312..9d184aa5e92 100644 --- a/HYDEPHP_V3_PLANNING.md +++ b/HYDEPHP_V3_PLANNING.md @@ -25,6 +25,7 @@ Having this document in code lets us know the devlopment state at any given poin - 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)) - Added built-in terminal code blocks using the `terminal` fence language. Command prompts are styled for selection-free copying, and `terminal xml` supports four Symfony-style Console formatter tags. ([#2188](https://github.com/hydephp/develop/issues/2188), [#2485](https://github.com/hydephp/develop/issues/2485)) - Pages can now compile to non-HTML output files. Page classes declare their output file extension through the new static `$outputExtension` property (defaulting to `.html`), and in-memory page identifiers can declare a `.json`, `.txt`, or `.xml` extension directly, so `InMemoryPage::make('robots.txt', contents: ...)` compiles to `_site/robots.txt` through the standard site build. Only the HTML extension is implicit in route keys: pages compiled to non-HTML files keep their extension in the route key, formalizing the convention already used by the documentation search index. +- Pages can now control their own sitemap inclusion. Set `sitemap: false` in a page's front matter to exclude it from the generated `sitemap.xml`, or override the new `HydePage::showInSitemap()` method in custom page classes. Pages compiled to non-HTML output files (like `robots.txt`) are excluded by default, and `sitemap: true` front matter opts such a page back in. ### Feature Changes @@ -35,6 +36,7 @@ Having this document in code lets us know the devlopment state at any given poin ### Minor Changes and Cleanup +- Fixed documentation search index files leaking into the generated sitemap: `search.json` (and any other page compiled to a non-HTML output file) no longer appears in `sitemap.xml`. The sitemap generator now asks each page through `HydePage::showInSitemap()` instead of only filtering out redirect pages. - The `Redirect` page class constructor now accepts an optional `$matter` parameter, used by the framework to hide the generated documentation root redirect from navigation menus. Existing usages are unaffected. - The realtime compiler now resolves registered page routes before proxying static assets, replacing the hardcoded `search.json` exemption, so `hyde serve` serves any registered route regardless of its output extension. Registered pages now always win over a static file at the same path; the previous behavior of serving such a shadowing file only affected the dev server and no real setups are expected to be affected. From a7d4cccfa67f6f600b7dc37eb4c6ba712542c4bf Mon Sep 17 00:00:00 2001 From: Emma De Silva Date: Mon, 13 Jul 2026 20:05:30 +0200 Subject: [PATCH 039/117] Use filter_var to parse sitemap front matter boolean (bool) cast treated quoted string values like "false" as truthy, silently including pages meant to be excluded from the sitemap. Co-Authored-By: Claude Sonnet 5 --- packages/framework/src/Pages/Concerns/HydePage.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/framework/src/Pages/Concerns/HydePage.php b/packages/framework/src/Pages/Concerns/HydePage.php index f4d90680334..d03d88e02a6 100644 --- a/packages/framework/src/Pages/Concerns/HydePage.php +++ b/packages/framework/src/Pages/Concerns/HydePage.php @@ -418,7 +418,7 @@ public function showInNavigation(): bool */ public function showInSitemap(): bool { - return (bool) $this->matter('sitemap', str_ends_with($this->getOutputPath(), '.html')); + return filter_var($this->matter('sitemap', str_ends_with($this->getOutputPath(), '.html')), FILTER_VALIDATE_BOOLEAN); } /** From 3c89b81e2a5eee99521c19ef39937aeff0496ed5 Mon Sep 17 00:00:00 2001 From: Emma De Silva Date: Mon, 13 Jul 2026 20:05:33 +0200 Subject: [PATCH 040/117] Test that quoted false front matter excludes pages from the sitemap Co-Authored-By: Claude Sonnet 5 --- .../tests/Feature/Services/SitemapServiceTest.php | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/packages/framework/tests/Feature/Services/SitemapServiceTest.php b/packages/framework/tests/Feature/Services/SitemapServiceTest.php index c6dddc01cbb..49f9965f9cd 100644 --- a/packages/framework/tests/Feature/Services/SitemapServiceTest.php +++ b/packages/framework/tests/Feature/Services/SitemapServiceTest.php @@ -124,6 +124,17 @@ public function testGenerateDoesNotAddPagesWithSitemapFrontMatterSetToFalse() Filesystem::unlink('_pages/foo.md'); } + public function testGenerateDoesNotAddPagesWithSitemapFrontMatterSetToQuotedFalseString() + { + $this->file('_pages/foo.md', "---\nsitemap: \"false\"\n---\n\n# Foo"); + + $service = new SitemapGenerator(); + $service->generate(); + + $this->assertCount(2, $service->getXmlElement()->url); + $this->assertStringNotContainsString('foo', $service->getXml()); + } + public function testGenerateDoesNotAddPagesWithNonHtmlOutputPaths() { Routes::addRoute(new Route(new InMemoryPage('robots.txt'))); From f2e93ac1f812ee818ab87383e3ea8f1c7140119d Mon Sep 17 00:00:00 2001 From: Emma De Silva Date: Mon, 13 Jul 2026 19:59:27 +0200 Subject: [PATCH 041/117] Fix test setup --- .../framework/tests/Feature/Services/SitemapServiceTest.php | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/packages/framework/tests/Feature/Services/SitemapServiceTest.php b/packages/framework/tests/Feature/Services/SitemapServiceTest.php index 49f9965f9cd..9d9e867f2b0 100644 --- a/packages/framework/tests/Feature/Services/SitemapServiceTest.php +++ b/packages/framework/tests/Feature/Services/SitemapServiceTest.php @@ -113,15 +113,13 @@ public function testGenerateAddsDocumentationSearchPageButNotSearchIndexToXml() public function testGenerateDoesNotAddPagesWithSitemapFrontMatterSetToFalse() { - file_put_contents(Hyde::path('_pages/foo.md'), "---\nsitemap: false\n---\n\n# Foo"); + $this->file('_pages/foo.md', "---\nsitemap: false\n---\n\n# Foo"); $service = new SitemapGenerator(); $service->generate(); $this->assertCount(2, $service->getXmlElement()->url); $this->assertStringNotContainsString('foo', $service->getXml()); - - Filesystem::unlink('_pages/foo.md'); } public function testGenerateDoesNotAddPagesWithSitemapFrontMatterSetToQuotedFalseString() From 76e8be471463f73a306602e88564afc2bd3a3fc0 Mon Sep 17 00:00:00 2001 From: Emma De Silva Date: Mon, 13 Jul 2026 20:09:06 +0200 Subject: [PATCH 042/117] Generate documentation --- .../partials/hyde-pages-api/hyde-page-methods.md | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/docs/_data/partials/hyde-pages-api/hyde-page-methods.md b/docs/_data/partials/hyde-pages-api/hyde-page-methods.md index 9eb0a3c66ea..aa84faf6e3c 100644 --- a/docs/_data/partials/hyde-pages-api/hyde-page-methods.md +++ b/docs/_data/partials/hyde-pages-api/hyde-page-methods.md @@ -1,7 +1,7 @@
- + #### `make()` @@ -275,6 +275,16 @@ Can the page be shown in the navigation menu? $page->showInNavigation(): bool ``` +#### `showInSitemap()` + +Can the page be shown in the sitemap? + +It can be explicitly set in the front matter using the `sitemap` key, otherwise it defaults to true for pages compiled to HTML files, and false for pages compiled to non-HTML files like `robots.txt`. + +```php +$page->showInSitemap(): bool +``` + #### `navigationMenuPriority()` Get the priority of the page in the navigation menu. From cd7401173edeee61ea38e3923b85cb398c1721de Mon Sep 17 00:00:00 2001 From: Emma De Silva Date: Mon, 13 Jul 2026 20:27:03 +0200 Subject: [PATCH 043/117] Add SitemapPage registered through core extension page discovery The sitemap is now a route: registered as an in-memory page behind Features::hasSitemap(), compiled through the standard build, and skipped when a user-defined page already claims the sitemap.xml route key. The contents resolve the SitemapGenerator from the service container at compile time so the implementation can be swapped with a container rebind. Co-Authored-By: Claude Fable 5 --- .../src/Foundation/HydeCoreExtension.php | 13 +++++++ .../Features/XmlGenerators/SitemapPage.php | 39 +++++++++++++++++++ 2 files changed, 52 insertions(+) create mode 100644 packages/framework/src/Framework/Features/XmlGenerators/SitemapPage.php diff --git a/packages/framework/src/Foundation/HydeCoreExtension.php b/packages/framework/src/Foundation/HydeCoreExtension.php index 01ffab65695..4e5737a2085 100644 --- a/packages/framework/src/Foundation/HydeCoreExtension.php +++ b/packages/framework/src/Foundation/HydeCoreExtension.php @@ -21,6 +21,7 @@ use Hyde\Facades\Config; use Hyde\Framework\Features\Documentation\DocumentationSearchPage; use Hyde\Framework\Features\Documentation\DocumentationSearchIndex; +use Hyde\Framework\Features\XmlGenerators\SitemapPage; use Hyde\Framework\Features\Documentation\Versioning\DocumentationVersion; use Hyde\Framework\Features\Documentation\Versioning\DocumentationVersions; @@ -80,6 +81,18 @@ public function discoverPages(PageCollection $collection): void } } } + + if (Features::hasSitemap()) { + $this->discoverSitemapPage($collection); + } + } + + /** Add the generated sitemap page unless the route is user-defined. */ + protected function discoverSitemapPage(PageCollection $collection): void + { + if (! $this->hasPageWithRouteKey($collection, SitemapPage::routeKey())) { + $collection->addPage(new SitemapPage()); + } } /** Discard documentation source files stored outside the version directories. */ diff --git a/packages/framework/src/Framework/Features/XmlGenerators/SitemapPage.php b/packages/framework/src/Framework/Features/XmlGenerators/SitemapPage.php new file mode 100644 index 00000000000..075e79cac23 --- /dev/null +++ b/packages/framework/src/Framework/Features/XmlGenerators/SitemapPage.php @@ -0,0 +1,39 @@ + ['hidden' => true], + ]); + } + + public function compile(): string + { + return app(SitemapGenerator::class)->generate()->getXml(); + } + + /** + * Get the route key of the sitemap, which for this page is also its output path. + */ + public static function routeKey(): string + { + return 'sitemap.xml'; + } +} From 93d95a1a0d9fe3d96d65d48fc95660d7845f3506 Mon Sep 17 00:00:00 2001 From: Emma De Silva Date: Mon, 13 Jul 2026 20:32:19 +0200 Subject: [PATCH 044/117] Convert the sitemap from a post-build task to the registered page Removes the GenerateSitemap post-build task now that the sitemap page is compiled through the standard build, and rewires the build:sitemap command to build the registered page (or a new instance when the route is not registered, preserving the ability to explicitly generate the sitemap when it is disabled in the configuration) through the StaticPageBuilder. Co-Authored-By: Claude Fable 5 --- .../Console/Commands/BuildSitemapCommand.php | 28 +++++++++++-- .../PostBuildTasks/GenerateSitemap.php | 39 ------------------- .../Framework/Services/BuildTaskService.php | 7 ---- 3 files changed, 25 insertions(+), 49 deletions(-) delete mode 100644 packages/framework/src/Framework/Actions/PostBuildTasks/GenerateSitemap.php diff --git a/packages/framework/src/Console/Commands/BuildSitemapCommand.php b/packages/framework/src/Console/Commands/BuildSitemapCommand.php index e6eaacf1212..fba3ef4cfcc 100644 --- a/packages/framework/src/Console/Commands/BuildSitemapCommand.php +++ b/packages/framework/src/Console/Commands/BuildSitemapCommand.php @@ -4,8 +4,14 @@ namespace Hyde\Console\Commands; -use Hyde\Framework\Actions\PostBuildTasks\GenerateSitemap; -use LaravelZero\Framework\Commands\Command; +use Hyde\Hyde; +use Hyde\Console\Concerns\Command; +use Hyde\Foundation\Facades\Routes; +use Hyde\Framework\Actions\StaticPageBuilder; +use Hyde\Framework\Features\XmlGenerators\SitemapPage; +use Hyde\Pages\Concerns\HydePage; + +use function sprintf; /** * Run the build process for the sitemap. @@ -20,6 +26,22 @@ class BuildSitemapCommand extends Command public function handle(): int { - return (new GenerateSitemap())->run($this->output); + if (! Hyde::hasSiteUrl()) { + $this->error('Cannot generate sitemap without a valid base URL'); + + return Command::FAILURE; + } + + $path = StaticPageBuilder::handle($this->getSitemapPage()); + + $this->infoComment(sprintf('Created [%s]', Hyde::pathToRelative($path))); + + return Command::SUCCESS; + } + + /** Get the registered sitemap page, falling back to a new instance when the route is not registered. */ + protected function getSitemapPage(): HydePage + { + return Routes::find(SitemapPage::routeKey())?->getPage() ?? new SitemapPage(); } } diff --git a/packages/framework/src/Framework/Actions/PostBuildTasks/GenerateSitemap.php b/packages/framework/src/Framework/Actions/PostBuildTasks/GenerateSitemap.php deleted file mode 100644 index 4409ede2934..00000000000 --- a/packages/framework/src/Framework/Actions/PostBuildTasks/GenerateSitemap.php +++ /dev/null @@ -1,39 +0,0 @@ -skip('Cannot generate sitemap without a valid base URL'); - } - - $this->path = Hyde::sitePath('sitemap.xml'); - - $this->needsParentDirectory($this->path); - - file_put_contents($this->path, SitemapGenerator::make()); - } - - public function printFinishMessage(): void - { - $this->createdSiteFile($this->path)->withExecutionTime(); - } -} diff --git a/packages/framework/src/Framework/Services/BuildTaskService.php b/packages/framework/src/Framework/Services/BuildTaskService.php index d71d646f7a0..8ab3cb26afc 100644 --- a/packages/framework/src/Framework/Services/BuildTaskService.php +++ b/packages/framework/src/Framework/Services/BuildTaskService.php @@ -12,7 +12,6 @@ use Hyde\Framework\Features\BuildTasks\PostBuildTask; use Hyde\Framework\Actions\PreBuildTasks\CleanSiteDirectory; use Hyde\Framework\Actions\PostBuildTasks\GenerateRssFeed; -use Hyde\Framework\Actions\PostBuildTasks\GenerateSitemap; use Hyde\Framework\Actions\PreBuildTasks\TransferMediaAssets; use Hyde\Framework\Actions\PostBuildTasks\GenerateBuildManifest; use Illuminate\Console\OutputStyle; @@ -135,7 +134,6 @@ private function registerFrameworkTasks(): void $this->registerIf(CleanSiteDirectory::class, $this->canCleanSiteDirectory()); $this->registerIf(TransferMediaAssets::class, $this->canTransferMediaAssets()); $this->registerIf(GenerateBuildManifest::class, $this->canGenerateManifest()); - $this->registerIf(GenerateSitemap::class, $this->canGenerateSitemap()); $this->registerIf(GenerateRssFeed::class, $this->canGenerateFeed()); } @@ -154,11 +152,6 @@ private function canGenerateManifest(): bool return Config::getBool('hyde.generate_build_manifest', true); } - private function canGenerateSitemap(): bool - { - return Features::hasSitemap(); - } - private function canGenerateFeed(): bool { return Features::hasRss(); From 923a5c651f969ab699ca6399842ff8fd83e67534 Mon Sep 17 00:00:00 2001 From: Emma De Silva Date: Mon, 13 Jul 2026 20:32:19 +0200 Subject: [PATCH 045/117] Update tests for the sitemap build task to page conversion Co-Authored-By: Claude Fable 5 --- .../Commands/BuildSitemapCommandTest.php | 41 +++++++++++++++---- .../Feature/Services/BuildTaskServiceTest.php | 5 +-- .../tests/Feature/SitemapFeatureTest.php | 4 +- .../tests/Feature/StaticSiteServiceTest.php | 15 ++++--- .../tests/Unit/BuildTaskServiceUnitTest.php | 24 ++++------- 5 files changed, 55 insertions(+), 34 deletions(-) diff --git a/packages/framework/tests/Feature/Commands/BuildSitemapCommandTest.php b/packages/framework/tests/Feature/Commands/BuildSitemapCommandTest.php index 193517fca59..978ecd0463c 100644 --- a/packages/framework/tests/Feature/Commands/BuildSitemapCommandTest.php +++ b/packages/framework/tests/Feature/Commands/BuildSitemapCommandTest.php @@ -5,10 +5,12 @@ namespace Hyde\Framework\Testing\Feature\Commands; use Hyde\Hyde; +use Hyde\Pages\InMemoryPage; +use Hyde\Foundation\HydeKernel; use Hyde\Testing\TestCase; #[\PHPUnit\Framework\Attributes\CoversClass(\Hyde\Console\Commands\BuildSitemapCommand::class)] -#[\PHPUnit\Framework\Attributes\CoversClass(\Hyde\Framework\Actions\PostBuildTasks\GenerateSitemap::class)] +#[\PHPUnit\Framework\Attributes\CoversClass(\Hyde\Framework\Features\XmlGenerators\SitemapPage::class)] class BuildSitemapCommandTest extends TestCase { public function testSitemapIsGeneratedWhenConditionsAreMet() @@ -20,9 +22,7 @@ public function testSitemapIsGeneratedWhenConditionsAreMet() $this->assertFileDoesNotExist(Hyde::path('_site/sitemap.xml')); $this->artisan('build:sitemap') - ->expectsOutputToContain('Generating sitemap...') - ->doesntExpectOutputToContain('Skipped') - ->expectsOutputToContain(' > Created _site/sitemap.xml') + ->expectsOutputToContain('Created [_site/sitemap.xml]') ->assertExitCode(0); $this->assertFileExists(Hyde::path('_site/sitemap.xml')); @@ -35,11 +35,36 @@ public function testSitemapIsNotGeneratedWhenConditionsAreNotMet() $this->assertFileDoesNotExist(Hyde::path('_site/sitemap.xml')); $this->artisan('build:sitemap') - ->expectsOutputToContain('Generating sitemap...') - ->expectsOutputToContain('Skipped') - ->expectsOutput(' > Cannot generate sitemap without a valid base URL') - ->assertExitCode(3); + ->expectsOutput('Cannot generate sitemap without a valid base URL') + ->assertExitCode(1); $this->assertFileDoesNotExist(Hyde::path('_site/sitemap.xml')); } + + public function testSitemapIsGeneratedWhenSitemapGenerationIsDisabledInConfig() + { + config(['hyde.url' => 'https://example.com']); + config(['hyde.generate_sitemap' => false]); + + $this->cleanUpWhenDone('_site/sitemap.xml'); + + $this->artisan('build:sitemap')->assertExitCode(0); + + $this->assertFileExists(Hyde::path('_site/sitemap.xml')); + } + + public function testCommandBuildsUserDefinedSitemapPageWhenOneIsRegistered() + { + config(['hyde.url' => 'https://example.com']); + + $this->cleanUpWhenDone('_site/sitemap.xml'); + + Hyde::kernel()->booting(function (HydeKernel $kernel): void { + $kernel->pages()->addPage(new InMemoryPage('sitemap.xml', contents: '')); + }); + + $this->artisan('build:sitemap')->assertExitCode(0); + + $this->assertSame('', file_get_contents(Hyde::path('_site/sitemap.xml'))); + } } diff --git a/packages/framework/tests/Feature/Services/BuildTaskServiceTest.php b/packages/framework/tests/Feature/Services/BuildTaskServiceTest.php index 2026767dcbb..4fc39f3eb62 100644 --- a/packages/framework/tests/Feature/Services/BuildTaskServiceTest.php +++ b/packages/framework/tests/Feature/Services/BuildTaskServiceTest.php @@ -19,7 +19,6 @@ #[\PHPUnit\Framework\Attributes\CoversClass(\Hyde\Framework\Features\BuildTasks\BuildTask::class)] #[\PHPUnit\Framework\Attributes\CoversClass(\Hyde\Framework\Features\BuildTasks\PreBuildTask::class)] #[\PHPUnit\Framework\Attributes\CoversClass(\Hyde\Framework\Features\BuildTasks\PostBuildTask::class)] -#[\PHPUnit\Framework\Attributes\CoversClass(\Hyde\Framework\Actions\PostBuildTasks\GenerateSitemap::class)] #[\PHPUnit\Framework\Attributes\CoversClass(\Hyde\Framework\Actions\PostBuildTasks\GenerateRssFeed::class)] #[\PHPUnit\Framework\Attributes\CoversClass(\Hyde\Console\Commands\BuildSiteCommand::class)] class BuildTaskServiceTest extends TestCase @@ -30,10 +29,10 @@ public function testBuildCommandCanRunBuildTasks() $this->artisan('build') ->expectsOutputToContain('Removing all files from build directory') - ->expectsOutputToContain('Generating sitemap') - ->expectsOutputToContain('Created _site/sitemap.xml') ->assertExitCode(0); + $this->assertFileExists(Hyde::path('_site/sitemap.xml')); + File::cleanDirectory(Hyde::path('_site')); } diff --git a/packages/framework/tests/Feature/SitemapFeatureTest.php b/packages/framework/tests/Feature/SitemapFeatureTest.php index 9544ffb39bc..cf7a493bab9 100644 --- a/packages/framework/tests/Feature/SitemapFeatureTest.php +++ b/packages/framework/tests/Feature/SitemapFeatureTest.php @@ -20,7 +20,7 @@ * @see \Hyde\Framework\Testing\Feature\Commands\BuildSitemapCommandTest */ #[\PHPUnit\Framework\Attributes\CoversClass(\Hyde\Framework\Features\XmlGenerators\SitemapGenerator::class)] -#[\PHPUnit\Framework\Attributes\CoversClass(\Hyde\Framework\Actions\PostBuildTasks\GenerateSitemap::class)] +#[\PHPUnit\Framework\Attributes\CoversClass(\Hyde\Framework\Features\XmlGenerators\SitemapPage::class)] #[\PHPUnit\Framework\Attributes\CoversClass(\Hyde\Console\Commands\BuildSitemapCommand::class)] class SitemapFeatureTest extends TestCase { @@ -36,7 +36,7 @@ public function testTheSitemapFeature() $this->withSiteUrl(); $this->artisan('build:sitemap') - ->expectsOutputToContain('Created _site/sitemap.xml') + ->expectsOutputToContain('Created [_site/sitemap.xml]') ->assertExitCode(0); $this->assertFileExists('_site/sitemap.xml'); diff --git a/packages/framework/tests/Feature/StaticSiteServiceTest.php b/packages/framework/tests/Feature/StaticSiteServiceTest.php index b510423ddec..3e730a2a415 100644 --- a/packages/framework/tests/Feature/StaticSiteServiceTest.php +++ b/packages/framework/tests/Feature/StaticSiteServiceTest.php @@ -145,6 +145,8 @@ public function testAllPageTypesCanBeCompiled() public function testOnlyProgressBarsForTypesWithPagesAreShown() { + config(['hyde.generate_sitemap' => false]); + $this->file('_pages/blade.blade.php'); $this->file('_pages/markdown.md'); @@ -191,9 +193,9 @@ public function testSitemapIsNotGeneratedWhenConditionsAreNotMet() $this->withoutSiteUrl(); config(['hyde.generate_sitemap' => false]); - $this->artisan('build') - ->doesntExpectOutput('Generating sitemap...') - ->assertExitCode(0); + $this->artisan('build')->assertExitCode(0); + + $this->assertFileDoesNotExist(Hyde::path('_site/sitemap.xml')); } public function testSitemapIsGeneratedWhenConditionsAreMet() @@ -201,9 +203,10 @@ public function testSitemapIsGeneratedWhenConditionsAreMet() $this->withSiteUrl(); config(['hyde.generate_sitemap' => true]); - $this->artisan('build') - // ->expectsOutput('Generating sitemap...') - ->assertExitCode(0); + $this->artisan('build')->assertExitCode(0); + + $this->assertFileExists(Hyde::path('_site/sitemap.xml')); + Filesystem::unlink('_site/sitemap.xml'); } diff --git a/packages/framework/tests/Unit/BuildTaskServiceUnitTest.php b/packages/framework/tests/Unit/BuildTaskServiceUnitTest.php index a5fbfb79d7d..3264f3c6287 100644 --- a/packages/framework/tests/Unit/BuildTaskServiceUnitTest.php +++ b/packages/framework/tests/Unit/BuildTaskServiceUnitTest.php @@ -7,9 +7,8 @@ use Closure; use Hyde\Foundation\HydeKernel; use Hyde\Foundation\Kernel\Filesystem; -use Hyde\Framework\Actions\PostBuildTasks\GenerateBuildManifest; +use Hyde\Framework\Actions\PostBuildTasks\GenerateBuildManifest as FrameworkGenerateBuildManifest; use Hyde\Framework\Actions\PostBuildTasks\GenerateRssFeed; -use Hyde\Framework\Actions\PostBuildTasks\GenerateSitemap as FrameworkGenerateSitemap; use Hyde\Framework\Features\BuildTasks\BuildTask; use Hyde\Framework\Features\BuildTasks\PostBuildTask; use Hyde\Framework\Features\BuildTasks\PreBuildTask; @@ -136,16 +135,16 @@ public function testRegisterTaskWithTaskAlreadyRegisteredInConfig() public function testCanRegisterFrameworkTasks() { - $this->service->registerTask(FrameworkGenerateSitemap::class); - $this->assertSame([FrameworkGenerateSitemap::class], $this->service->getRegisteredTasks()); + $this->service->registerTask(FrameworkGenerateBuildManifest::class); + $this->assertSame([FrameworkGenerateBuildManifest::class], $this->service->getRegisteredTasks()); } public function testCanOverloadFrameworkTasks() { - $this->service->registerTask(FrameworkGenerateSitemap::class); - $this->service->registerTask(GenerateSitemap::class); + $this->service->registerTask(FrameworkGenerateBuildManifest::class); + $this->service->registerTask(GenerateBuildManifest::class); - $this->assertSame([GenerateSitemap::class], $this->service->getRegisteredTasks()); + $this->assertSame([GenerateBuildManifest::class], $this->service->getRegisteredTasks()); } public function testCanSetOutputWithNull() @@ -160,7 +159,7 @@ public function testCanSetOutputWithOutputStyle() public function testGenerateBuildManifestExtendsPostBuildTask() { - $this->assertInstanceOf(PostBuildTask::class, new GenerateBuildManifest()); + $this->assertInstanceOf(PostBuildTask::class, new FrameworkGenerateBuildManifest()); } public function testGenerateRssFeedExtendsPostBuildTask() @@ -168,11 +167,6 @@ public function testGenerateRssFeedExtendsPostBuildTask() $this->assertInstanceOf(PostBuildTask::class, new GenerateRssFeed()); } - public function testGenerateSitemapExtendsPostBuildTask() - { - $this->assertInstanceOf(PostBuildTask::class, new FrameworkGenerateSitemap()); - } - public function testCanRunPreBuildTasks() { $this->can(fn () => $this->service->runPreBuildTasks(...)); @@ -281,7 +275,7 @@ public function testServiceSearchesForTasksInAppDirectory() public function testServiceFindsTasksInAppDirectory() { $files = [ - 'app/Actions/GenerateBuildManifestBuildTask.php' => GenerateBuildManifest::class, + 'app/Actions/GenerateBuildManifestBuildTask.php' => FrameworkGenerateBuildManifest::class, 'app/Actions/GenerateRssFeedBuildTask.php' => GenerateRssFeed::class, ]; @@ -362,7 +356,7 @@ class TestBuildTaskNotExtendingChildren extends BuildTask } /** Test class to test overloading */ -class GenerateSitemap extends FrameworkGenerateSitemap +class GenerateBuildManifest extends FrameworkGenerateBuildManifest { use VoidHandleMethod; } From 9b0013bc632a47b07bc1f224fa2a73293bd72baa Mon Sep 17 00:00:00 2001 From: Emma De Silva Date: Mon, 13 Jul 2026 20:35:03 +0200 Subject: [PATCH 046/117] Test the sitemap page registration, customization, and serving paths Covers the feature-gated route registration, compilation through the build command including build manifest and route list presence, the container rebind customization tier, user-defined pages suppressing the generated page through both booting callbacks and extensions, and the realtime compiler serving sitemap.xml with the XML content type. Co-Authored-By: Claude Fable 5 --- .../tests/Feature/SitemapPageTest.php | 180 ++++++++++++++++++ .../tests/RealtimeCompilerTest.php | 21 ++ 2 files changed, 201 insertions(+) create mode 100644 packages/framework/tests/Feature/SitemapPageTest.php diff --git a/packages/framework/tests/Feature/SitemapPageTest.php b/packages/framework/tests/Feature/SitemapPageTest.php new file mode 100644 index 00000000000..5969a0a4d22 --- /dev/null +++ b/packages/framework/tests/Feature/SitemapPageTest.php @@ -0,0 +1,180 @@ +withSiteUrl(); + + $this->assertTrue(Routes::exists('sitemap.xml')); + + $page = Routes::get('sitemap.xml')->getPage(); + + $this->assertInstanceOf(SitemapPage::class, $page); + $this->assertSame('sitemap.xml', $page->getOutputPath()); + $this->assertSame('sitemap.xml', $page->getRouteKey()); + } + + public function testSitemapPageIsNotRegisteredWithoutSiteUrl() + { + $this->withoutSiteUrl(); + + $this->assertFalse(Routes::exists('sitemap.xml')); + } + + public function testSitemapPageIsNotRegisteredWhenSitemapIsDisabledInConfig() + { + $this->withSiteUrl(); + config(['hyde.generate_sitemap' => false]); + + $this->assertFalse(Routes::exists('sitemap.xml')); + } + + public function testSitemapPageIsHiddenFromNavigationAndExcludesItselfFromTheSitemap() + { + $page = new SitemapPage(); + + $this->assertFalse($page->showInNavigation()); + $this->assertFalse($page->showInSitemap()); + } + + public function testSitemapPageCompilesUsingTheSitemapGenerator() + { + $this->withSiteUrl(); + + $contents = (new SitemapPage())->compile(); + + $this->assertStringStartsWith('', $contents); + $this->assertStringContainsString('withSiteUrl(); + + app()->bind(SitemapGenerator::class, fn (): SitemapGenerator => new class extends SitemapGenerator + { + public function generate(): static + { + return $this; + } + + public function getXml(): string + { + return 'custom generator output'; + } + }); + + $this->assertSame('custom generator output', Routes::get('sitemap.xml')->getPage()->compile()); + } + + public function testBuildCommandCompilesSitemapPageAsDynamicPage() + { + $this->withSiteUrl(); + + $this->artisan('build') + ->expectsOutput('Creating Dynamic Pages...') + ->assertExitCode(0); + + $contents = file_get_contents(Hyde::path('_site/sitemap.xml')); + + $this->assertStringStartsWith('', $contents); + $this->assertStringContainsString('assertStringNotContainsString('sitemap.xml', $contents); + } + + public function testSitemapPageIsIncludedInTheBuildManifest() + { + $this->withSiteUrl(); + + $this->artisan('build')->assertExitCode(0); + + $manifest = json_decode(file_get_contents(Hyde::path('app/storage/framework/cache/build-manifest.json')), true); + + $this->assertArrayHasKey('sitemap.xml', $manifest['pages']); + $this->assertSame('sitemap.xml', $manifest['pages']['sitemap.xml']['output_path']); + } + + public function testSitemapRouteIsIncludedInTheRouteList() + { + $this->withSiteUrl(); + + $this->artisan('route:list') + ->expectsOutputToContain('sitemap.xml') + ->assertExitCode(0); + } + + public function testUserPageRegisteredInBootingCallbackSuppressesTheGeneratedSitemapPage() + { + $this->withSiteUrl(); + + Hyde::kernel()->booting(function (HydeKernel $kernel): void { + $kernel->pages()->addPage(new InMemoryPage('sitemap.xml', contents: 'user defined sitemap')); + }); + + $page = Routes::get('sitemap.xml')->getPage(); + + $this->assertNotInstanceOf(SitemapPage::class, $page); + $this->assertSame(1, Hyde::pages()->filter(fn ($page) => $page->getRouteKey() === 'sitemap.xml')->count()); + + $this->artisan('build')->assertExitCode(0); + + $this->assertSame('user defined sitemap', file_get_contents(Hyde::path('_site/sitemap.xml'))); + } + + public function testUserPageRegisteredThroughExtensionSuppressesTheGeneratedSitemapPage() + { + $this->withSiteUrl(); + + Hyde::kernel()->registerExtension(SitemapPageTestExtension::class); + + $page = Routes::get('sitemap.xml')->getPage(); + + $this->assertNotInstanceOf(SitemapPage::class, $page); + $this->assertSame(1, Hyde::pages()->filter(fn ($page) => $page->getRouteKey() === 'sitemap.xml')->count()); + + $this->artisan('build')->assertExitCode(0); + + $this->assertSame('extension defined sitemap', file_get_contents(Hyde::path('_site/sitemap.xml'))); + } +} + +class SitemapPageTestExtension extends HydeExtension +{ + public function discoverPages(PageCollection $collection): void + { + $collection->addPage(new InMemoryPage('sitemap.xml', contents: 'extension defined sitemap')); + } +} diff --git a/packages/realtime-compiler/tests/RealtimeCompilerTest.php b/packages/realtime-compiler/tests/RealtimeCompilerTest.php index afee273b289..54d031c3bd8 100644 --- a/packages/realtime-compiler/tests/RealtimeCompilerTest.php +++ b/packages/realtime-compiler/tests/RealtimeCompilerTest.php @@ -347,6 +347,27 @@ public function testDocsSearchJsonRendersSearchIndexWithJsonContentType() Filesystem::unlink('_docs/index.md'); } + public function testSitemapXmlRouteIsServedWithXmlContentType() + { + config(['hyde.url' => 'https://example.com']); + + $this->mockCompilerRoute('sitemap.xml'); + + $kernel = new HttpKernel(); + $response = $kernel->handle(new Request()); + + $this->assertInstanceOf(Response::class, $response); + $this->assertNotInstanceOf(HtmlResponse::class, $response); + $this->assertSame(200, $response->statusCode); + $this->assertSame('OK', $response->statusMessage); + + $headers = $this->getResponseHeaders($response); + $this->assertSame('application/xml', $headers['Content-Type']); + + $this->assertStringStartsWith('', $response->body); + $this->assertStringContainsString('body); + } + public function testGetContentTypeReturnsApplicationJsonForJsonOutputPath() { $page = $this->makePageWithOutputPath('foo.json'); From bc782485ba97fbea465365715a6a8684c5bfbfff Mon Sep 17 00:00:00 2001 From: Emma De Silva Date: Mon, 13 Jul 2026 20:36:12 +0200 Subject: [PATCH 047/117] Add release notes and upgrade guide for the sitemap page conversion Co-Authored-By: Claude Fable 5 --- HYDEPHP_V3_PLANNING.md | 3 +++ UPGRADE.md | 35 ++++++++++++++++++++++++++++++++++- 2 files changed, 37 insertions(+), 1 deletion(-) diff --git a/HYDEPHP_V3_PLANNING.md b/HYDEPHP_V3_PLANNING.md index 9d184aa5e92..cd60a983710 100644 --- a/HYDEPHP_V3_PLANNING.md +++ b/HYDEPHP_V3_PLANNING.md @@ -26,6 +26,7 @@ Having this document in code lets us know the devlopment state at any given poin - Added built-in terminal code blocks using the `terminal` fence language. Command prompts are styled for selection-free copying, and `terminal xml` supports four Symfony-style Console formatter tags. ([#2188](https://github.com/hydephp/develop/issues/2188), [#2485](https://github.com/hydephp/develop/issues/2485)) - Pages can now compile to non-HTML output files. Page classes declare their output file extension through the new static `$outputExtension` property (defaulting to `.html`), and in-memory page identifiers can declare a `.json`, `.txt`, or `.xml` extension directly, so `InMemoryPage::make('robots.txt', contents: ...)` compiles to `_site/robots.txt` through the standard site build. Only the HTML extension is implicit in route keys: pages compiled to non-HTML files keep their extension in the route key, formalizing the convention already used by the documentation search index. - Pages can now control their own sitemap inclusion. Set `sitemap: false` in a page's front matter to exclude it from the generated `sitemap.xml`, or override the new `HydePage::showInSitemap()` method in custom page classes. Pages compiled to non-HTML output files (like `robots.txt`) are excluded by default, and `sitemap: true` front matter opts such a page back in. +- The sitemap is now a first-class page instead of a post-build side effect: when sitemap generation is enabled, `sitemap.xml` is registered as a route, so it is served by `hyde serve`, listed in `route:list`, included in the build manifest, and compiled through the standard site build. The output can be customized by rebinding the `SitemapGenerator` class in the service container, and registering a user-defined page with the `sitemap.xml` route key (from a service provider, booting callback, or extension) replaces the generated page entirely. ### Feature Changes @@ -49,6 +50,7 @@ Having this document in code lets us know the devlopment state at any given poin - Renamed the static page class property `$fileExtension` to `$sourceExtension`, and the `fileExtension()` and `setFileExtension()` methods to `sourceExtension()` and `setSourceExtension()`. The old name was ambiguous now that page classes also declare an output extension through the new `$outputExtension` property, and the renamed pair makes the source/output distinction explicit. 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). Page discovery fails fast with an actionable exception for registered page classes that still use the old API, instead of silently skipping them during builds. - In-memory page identifiers ending in `.json`, `.txt`, or `.xml` now compile to that path as-is instead of gaining a second `.html` extension. The old double-extension outputs (like `data.json.html`) were almost certainly never intended, so no real sites are expected to be affected. - Redirect source paths declared in `hyde.redirects` ending in `.json`, `.txt`, or `.xml` are now rejected with an exception, since a meta refresh redirect cannot work for files served as non-HTML content. Previously such entries silently produced an unreachable `legacy.json.html` file, so no working configuration is affected. +- 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 register a custom `sitemap.xml` page or rebind `SitemapGenerator` in the container instead. The `build:sitemap` command now compiles the registered page, and reports failure with exit code 1 instead of 3 when no base URL is configured. - Removed `Redirect::create()`, `Redirect::store()`, and the `Redirect` constructor's `showText` argument. Redirects must now be declared in `hyde.redirects`, keeping all generated output inside the kernel-owned build graph. Redirect routes are intrinsically excluded from navigation menus and sitemaps, and always include an accessible fallback link. - Removed the `InMemoryPage` instance macro API. Dynamic contents should now be supplied with a closure, while custom methods and other behavior belong in an `InMemoryPage` subclass. - Removed `InMemoryPage` content-source precedence. Calls that previously supplied both `contents` and `view` must retain only the intended source; positional view calls that used an empty-string contents placeholder must use `null` instead. @@ -66,6 +68,7 @@ Please fill in UPGRADE.md as you make changes. - Move `InMemoryPage` `compile` macro callbacks into the contents argument, and replace other instance macros with methods on an `InMemoryPage` subclass. - Update `InMemoryPage` calls to supply only `contents` or `view`. Replace an empty-string positional contents placeholder with `null`, or use the named `view` argument. - Rename `$fileExtension` to `$sourceExtension` in custom page classes, and update any calls to `fileExtension()` or `setFileExtension()` to `sourceExtension()` and `setSourceExtension()`. +- If you referenced the removed `GenerateSitemap` build task class (for example to override it with a same-basename user-land task), customize the sitemap by rebinding `SitemapGenerator` in the service container or by registering your own `sitemap.xml` page instead. ## `InMemoryPage` content-source motivation diff --git a/UPGRADE.md b/UPGRADE.md index 6c0cb0194d9..9d94b4ef4f0 100644 --- a/UPGRADE.md +++ b/UPGRADE.md @@ -302,7 +302,39 @@ new InMemoryPage('example', view: ''); new InMemoryPage('example', view: null); ``` -## Step 6: Rename Page File Extension References +## Step 6: Review Sitemap Customizations + +The sitemap is now generated as a regular page instead of by a post-build task, so `sitemap.xml` is served by +`php hyde serve`, listed in `route:list`, and included in the build manifest. Sites that just enable or disable +the sitemap through `hyde.generate_sitemap` and `hyde.url` need no changes. + +The `GenerateSitemap` post-build task class has been removed. If you overrode it with a same-basename build task, +or referenced the class directly, customize the sitemap through one of its replacement tiers instead: + +- Rebind the generator in the service container to change the output while keeping the page registration: + +```php +use Hyde\Framework\Features\XmlGenerators\SitemapGenerator; + +app()->bind(SitemapGenerator::class, MyCustomSitemapGenerator::class); +``` + +- Or register your own page with the `sitemap.xml` route key (from a service provider, booting callback, or + extension), which replaces the generated page entirely: + +```php +use Hyde\Hyde; +use Hyde\Pages\InMemoryPage; + +Hyde::kernel()->booting(function ($kernel): void { + $kernel->pages()->addPage(new InMemoryPage('sitemap.xml', contents: $myXml)); +}); +``` + +The `build:sitemap` command still works and now compiles the registered page. When no base URL is configured it +reports failure with exit code 1 instead of 3. + +## Step 7: Rename Page File Extension References The static page class property `$fileExtension` has been renamed to `$sourceExtension`, along with the `fileExtension()` and `setFileExtension()` methods, which are now `sourceExtension()` and `setSourceExtension()`. @@ -352,6 +384,7 @@ Use this checklist to track your upgrade progress: - [ ] Moved calls to `Redirect::create()` or `Redirect::store()` into the `hyde.redirects` configuration array - [ ] Moved `InMemoryPage` `compile` macro callbacks into the contents argument and replaced other macros with subclass methods - [ ] Updated `InMemoryPage` calls to supply only one of `contents` and `view` +- [ ] Replaced any references to the removed `GenerateSitemap` build task with a `SitemapGenerator` container rebind or a user-defined `sitemap.xml` page - [ ] Renamed `$fileExtension`, `fileExtension()`, and `setFileExtension()` to `$sourceExtension`, `sourceExtension()`, and `setSourceExtension()` in custom page classes and call sites ## Troubleshooting From 56acac17bd78e21979eb7a77f40f28e1ec3de3f8 Mon Sep 17 00:00:00 2001 From: Emma De Silva Date: Mon, 13 Jul 2026 20:37:43 +0200 Subject: [PATCH 048/117] Record PR 5 part A decisions and implementation notes in the epic Co-Authored-By: Claude Fable 5 --- EPIC_NON_HTML_PAGES.md | 63 +++++++++++++++++++++++++++++++++++++++++- 1 file changed, 62 insertions(+), 1 deletion(-) diff --git a/EPIC_NON_HTML_PAGES.md b/EPIC_NON_HTML_PAGES.md index a78b086e852..0ae02f22ce2 100644 --- a/EPIC_NON_HTML_PAGES.md +++ b/EPIC_NON_HTML_PAGES.md @@ -134,6 +134,9 @@ versioned docs route keys like `docs/1.x/index` would false-positive > Decide in PR 5/6 whether the first-party generated files need any extension outside > the allowlist (none currently do — all are `.txt`/`.xml`), which determines whether > (1) is needed for the framework itself or only for the secondary audience. + > *(PR 5 part A: confirmed for `sitemap.xml` — within the allowlist. `feed.xml`, + > `robots.txt`, and `llms.txt` are too, so the framework itself will not need + > option (b); the remaining call in PR 8 is only for the power-user audience.)* ### D3: Sitemap inclusion becomes a page-level concern @@ -198,6 +201,18 @@ and giving the `build:sitemap` / `build:rss` commands a concrete class to instantiate. Lean toward a plain `InMemoryPage` registered with a container-bound `compile` closure unless PR 5 surfaces a concrete need for the subclass. +> **Decided (PR 5 part A): thin subclass.** The command wiring is the concrete need +> the deferral anticipated: `build:sitemap` builds the registered route's page and +> must fall back to a fresh instance when the route is not registered (mirroring how +> `BuildSearchCommand` falls back to `new DocumentationSearchIndex()`), and a plain +> page would need that construction logic exported from a shared factory anyway. +> `SitemapPage extends InMemoryPage` lives next to its generator in +> `Hyde\Framework\Features\XmlGenerators`, keeps the construction in one place, and +> stays out of `Hyde\Pages` so it is exempt from the discovered-page unit test +> contract, exactly like `DocumentationSearchIndex`. The D4 swappability tier is +> unaffected: `compile()` resolves `SitemapGenerator` from the container, verified +> by a rebind test. Part B should mirror this with `RssFeedPage`. + ### D5: User-defined pages beat generators If the page collection already contains a user-defined page with a route key such @@ -221,6 +236,16 @@ container → fully custom page in code. > `addPage()` callback) suppresses the generated one — this is the D5 contract and the > most likely silent failure for the power-user audience. +> **Verified for the sitemap (PR 5 part A):** both user paths win, through two +> different mechanisms that the tests pin down end-to-end. `booting()` callbacks run +> before the page collection boots (`BootsHydeKernel::boot()`), so a callback-registered +> `sitemap.xml` page is visible to the core extension's `hasPageWithRouteKey()` skip +> check. A user `HydeExtension` runs *after* the core extension (registration order), +> so the skip check cannot see its pages; instead the user page replaces the generated +> one under the same collection key (`addPage()` keys by source path). Both are +> asserted through the real `build` command output. The robots.txt equivalent remains +> mandatory for PR 6. + ### D6: No built-in `TextPage` or `.txt` autodiscovery First-class non-HTML support is about a page's output path and participation in the @@ -363,11 +388,15 @@ Implementation notes (branch `v3/non-html-pages-sitemap-inclusion-policy`): - No UPGRADE.md entry: the fix requires no user action, and nothing realistic depended on search indexes appearing in sitemaps. -### PR 5 — Convert sitemap and RSS from build tasks to pages +### PR 5 — Convert sitemap and RSS from build tasks to pages 🚧 Part A (sitemap) implemented; part B (RSS) remaining Goal: `sitemap.xml` and `feed.xml` are routes — served by `hyde serve`, listed in `route:list`, included in the build manifest, overridable in user land. +> **Split during implementation:** part A converts the sitemap, part B will convert +> the RSS feed the same way. The bullets below still describe both; the part A notes +> at the end of this section record what landed and what part B should mirror. + - Register `sitemap.xml` / `feed.xml` as `InMemoryPage`s per D4, with a lazy `compile` that resolves the generator from the container (`app(SitemapGenerator::class)->generate()` / `app(RssFeedGenerator::class)->generate()`), @@ -397,6 +426,38 @@ Goal: `sitemap.xml` and `feed.xml` are routes — served by `hyde serve`, listed - Nice side effect: build output shows them under "Dynamic Pages" with the standard progress display. +Implementation notes, part A (branch `v3/non-html-pages-convert-sitemap`): + +- `SitemapPage extends InMemoryPage` per the D4 "thin subclass" decision (see the D4 + note for the rationale), hiding itself from navigation like + `DocumentationSearchIndex` and self-excluding from the sitemap via the D3 non-HTML + default. Registered at the end of `HydeCoreExtension::discoverPages()` behind + `Features::hasSitemap()` with the D5 skip check (see the D5 note for the verified + override ordering semantics). +- `SitemapGenerator` verified container-resolvable and rebindable: not `final`, no + constructor dependencies, and a test rebinds it and asserts the page's compiled + output changes. +- `GenerateSitemap` was removed rather than deprecated: a kept-but-registered task + would generate the sitemap twice, and a kept-but-unregistered task is dead code + that still breaks anyone re-registering it (double generation) — a clean removal + with release-notes guidance to the rebind/override tiers is more honest. Recorded + as a v3 breaking change with the realistic impact being same-basename user-land + task overrides. +- `build:sitemap` builds the registered route's page via `StaticPageBuilder`, + falling back to `new SitemapPage()` when the route is not registered. The fallback + preserves the old task behavior where the explicit command generates the sitemap + even with `hyde.generate_sitemap` disabled (only the base URL gates it), and the + route-first lookup means a user-defined `sitemap.xml` page wins here too. Skip + exit code changed from 3 (task-runner semantics) to 1. +- `GlobalMetadataBag` verified: the sitemap head link is emitted under the same + `Features::hasSitemap()` condition that registers the page — no drift possible. +- Realtime compiler needed no changes (PR 2's route-first resolution); a serve test + asserts `sitemap.xml` returns the generated XML with `application/xml`. +- Heads-up for part B: `BuildTaskServiceUnitTest`'s framework-task fixtures were + migrated from `GenerateSitemap` to `GenerateBuildManifest` (not `GenerateRssFeed`) + so removing the RSS task won't churn them again. Part B should mirror everything + here with `RssFeedPage`, taking its route key from `RssFeedGenerator::getFilename()`. + ### PR 6 — Generated `robots.txt` Goal: sensible robots.txt out of the box, zero config. From dbe5575845a5a96472716c6660fa8ff89da3fe69 Mon Sep 17 00:00:00 2001 From: Emma De Silva Date: Mon, 13 Jul 2026 20:44:30 +0200 Subject: [PATCH 049/117] Disable sitemap generation in tests asserting exact default collections These tests assert the exact contents of a default project's page and route collections, which now include the sitemap page since the monorepo test environment configures a site URL. The sitemap route registration itself is covered by SitemapPageTest. Co-Authored-By: Claude Fable 5 --- .../tests/Feature/Commands/RouteListCommandTest.php | 7 +++++++ packages/framework/tests/Feature/PageCollectionTest.php | 7 +++++++ packages/framework/tests/Feature/RouteCollectionTest.php | 7 +++++++ 3 files changed, 21 insertions(+) diff --git a/packages/framework/tests/Feature/Commands/RouteListCommandTest.php b/packages/framework/tests/Feature/Commands/RouteListCommandTest.php index 021ceced56b..eb80fe6b812 100644 --- a/packages/framework/tests/Feature/Commands/RouteListCommandTest.php +++ b/packages/framework/tests/Feature/Commands/RouteListCommandTest.php @@ -16,6 +16,13 @@ #[\PHPUnit\Framework\Attributes\CoversClass(\Hyde\Support\Internal\RouteListItem::class)] class RouteListCommandTest extends TestCase { + protected function setUp(): void + { + parent::setUp(); + + config(['hyde.generate_sitemap' => false]); + } + public function testRouteListCommand() { $this->artisan('route:list') diff --git a/packages/framework/tests/Feature/PageCollectionTest.php b/packages/framework/tests/Feature/PageCollectionTest.php index 38ce661fd6f..d1dfdae3b97 100644 --- a/packages/framework/tests/Feature/PageCollectionTest.php +++ b/packages/framework/tests/Feature/PageCollectionTest.php @@ -22,6 +22,13 @@ #[\PHPUnit\Framework\Attributes\CoversClass(\Hyde\Foundation\Facades\Pages::class)] class PageCollectionTest extends TestCase { + protected function setUp(): void + { + parent::setUp(); + + config(['hyde.generate_sitemap' => false]); + } + public function testBootMethodCreatesNewPageCollectionAndDiscoversPagesAutomatically() { $collection = PageCollection::init(Hyde::getInstance())->boot(); diff --git a/packages/framework/tests/Feature/RouteCollectionTest.php b/packages/framework/tests/Feature/RouteCollectionTest.php index d36be1209b5..b3422edbf9b 100644 --- a/packages/framework/tests/Feature/RouteCollectionTest.php +++ b/packages/framework/tests/Feature/RouteCollectionTest.php @@ -22,6 +22,13 @@ #[\PHPUnit\Framework\Attributes\CoversClass(\Hyde\Foundation\Facades\Routes::class)] class RouteCollectionTest extends TestCase { + protected function setUp(): void + { + parent::setUp(); + + config(['hyde.generate_sitemap' => false]); + } + public function testBootMethodDiscoversAllPages() { $collection = RouteCollection::init(Hyde::getInstance())->boot(); From cc102407a6631a998a08deb09bad6b31b90670bc Mon Sep 17 00:00:00 2001 From: Emma De Silva Date: Mon, 13 Jul 2026 21:13:57 +0200 Subject: [PATCH 050/117] Add RssFeedPage registered through core extension page discovery The RSS feed is now a route: registered as an in-memory page behind Features::hasRss(), compiled through the standard build, and skipped when a user-defined page already claims the configured feed route key. The contents resolve the RssFeedGenerator from the service container at compile time so the implementation can be swapped with a container rebind. The configured hyde.rss.filename is used verbatim as the output path, preserving support for filenames outside the default recognized non-HTML extensions. Co-Authored-By: Claude Fable 5 --- .../src/Foundation/HydeCoreExtension.php | 13 +++++ .../Features/XmlGenerators/RssFeedPage.php | 49 +++++++++++++++++++ 2 files changed, 62 insertions(+) create mode 100644 packages/framework/src/Framework/Features/XmlGenerators/RssFeedPage.php diff --git a/packages/framework/src/Foundation/HydeCoreExtension.php b/packages/framework/src/Foundation/HydeCoreExtension.php index 4e5737a2085..7a1933580e9 100644 --- a/packages/framework/src/Foundation/HydeCoreExtension.php +++ b/packages/framework/src/Foundation/HydeCoreExtension.php @@ -21,6 +21,7 @@ use Hyde\Facades\Config; use Hyde\Framework\Features\Documentation\DocumentationSearchPage; use Hyde\Framework\Features\Documentation\DocumentationSearchIndex; +use Hyde\Framework\Features\XmlGenerators\RssFeedPage; use Hyde\Framework\Features\XmlGenerators\SitemapPage; use Hyde\Framework\Features\Documentation\Versioning\DocumentationVersion; use Hyde\Framework\Features\Documentation\Versioning\DocumentationVersions; @@ -85,6 +86,10 @@ public function discoverPages(PageCollection $collection): void if (Features::hasSitemap()) { $this->discoverSitemapPage($collection); } + + if (Features::hasRss()) { + $this->discoverRssFeedPage($collection); + } } /** Add the generated sitemap page unless the route is user-defined. */ @@ -95,6 +100,14 @@ protected function discoverSitemapPage(PageCollection $collection): void } } + /** Add the generated RSS feed page unless the route is user-defined. */ + protected function discoverRssFeedPage(PageCollection $collection): void + { + if (! $this->hasPageWithRouteKey($collection, RssFeedPage::routeKey())) { + $collection->addPage(new RssFeedPage()); + } + } + /** Discard documentation source files stored outside the version directories. */ protected function discardUnversionedDocumentationFiles(FileCollection $collection): void { diff --git a/packages/framework/src/Framework/Features/XmlGenerators/RssFeedPage.php b/packages/framework/src/Framework/Features/XmlGenerators/RssFeedPage.php new file mode 100644 index 00000000000..e61b8b131b8 --- /dev/null +++ b/packages/framework/src/Framework/Features/XmlGenerators/RssFeedPage.php @@ -0,0 +1,49 @@ + ['hidden' => true], + ]); + } + + public function compile(): string + { + return app(RssFeedGenerator::class)->generate()->getXml(); + } + + /** + * Get the route key of the RSS feed, which for this page is also its output path. + */ + public static function routeKey(): string + { + return RssFeedGenerator::getFilename(); + } + + /** + * The identifier is the user-configured `hyde.rss.filename` and is always used + * verbatim as the output path, regardless of its extension, so filenames like + * `feed.rss` outside the default recognized extensions keep working. + */ + protected static function identifierHasExplicitOutputExtension(string $identifier): bool + { + return true; + } +} From 1343c00f19d57b9a39e1379159e38cf3fcfc6721 Mon Sep 17 00:00:00 2001 From: Emma De Silva Date: Mon, 13 Jul 2026 21:16:25 +0200 Subject: [PATCH 051/117] Convert the RSS feed from a post-build task to the registered page Removes the GenerateRssFeed post-build task now that the feed page is compiled through the standard build, and rewires the build:rss command to build the registered page (or a new instance when the route is not registered, preserving the old task behavior where the explicit command generates the feed regardless of the feature conditions) through the StaticPageBuilder. Co-Authored-By: Claude Fable 5 --- .../Console/Commands/BuildRssFeedCommand.php | 22 ++++++++++-- .../PostBuildTasks/GenerateRssFeed.php | 35 ------------------- .../Framework/Services/BuildTaskService.php | 8 ----- 3 files changed, 19 insertions(+), 46 deletions(-) delete mode 100644 packages/framework/src/Framework/Actions/PostBuildTasks/GenerateRssFeed.php diff --git a/packages/framework/src/Console/Commands/BuildRssFeedCommand.php b/packages/framework/src/Console/Commands/BuildRssFeedCommand.php index 6880c50b6b6..e8703b4f646 100644 --- a/packages/framework/src/Console/Commands/BuildRssFeedCommand.php +++ b/packages/framework/src/Console/Commands/BuildRssFeedCommand.php @@ -4,8 +4,14 @@ namespace Hyde\Console\Commands; -use Hyde\Framework\Actions\PostBuildTasks\GenerateRssFeed; -use LaravelZero\Framework\Commands\Command; +use Hyde\Hyde; +use Hyde\Console\Concerns\Command; +use Hyde\Foundation\Facades\Routes; +use Hyde\Framework\Actions\StaticPageBuilder; +use Hyde\Framework\Features\XmlGenerators\RssFeedPage; +use Hyde\Pages\Concerns\HydePage; + +use function sprintf; /** * Run the build process for the RSS feed. @@ -20,6 +26,16 @@ class BuildRssFeedCommand extends Command public function handle(): int { - return (new GenerateRssFeed())->run($this->output); + $path = StaticPageBuilder::handle($this->getFeedPage()); + + $this->infoComment(sprintf('Created [%s]', Hyde::pathToRelative($path))); + + return Command::SUCCESS; + } + + /** Get the registered RSS feed page, falling back to a new instance when the route is not registered. */ + protected function getFeedPage(): HydePage + { + return Routes::find(RssFeedPage::routeKey())?->getPage() ?? new RssFeedPage(); } } diff --git a/packages/framework/src/Framework/Actions/PostBuildTasks/GenerateRssFeed.php b/packages/framework/src/Framework/Actions/PostBuildTasks/GenerateRssFeed.php deleted file mode 100644 index e974d12906b..00000000000 --- a/packages/framework/src/Framework/Actions/PostBuildTasks/GenerateRssFeed.php +++ /dev/null @@ -1,35 +0,0 @@ -path = Hyde::sitePath(RssFeedGenerator::getFilename()); - - $this->needsParentDirectory($this->path); - - file_put_contents($this->path, RssFeedGenerator::make()); - } - - public function printFinishMessage(): void - { - $this->createdSiteFile($this->path)->withExecutionTime(); - } -} diff --git a/packages/framework/src/Framework/Services/BuildTaskService.php b/packages/framework/src/Framework/Services/BuildTaskService.php index 8ab3cb26afc..9b3589892cf 100644 --- a/packages/framework/src/Framework/Services/BuildTaskService.php +++ b/packages/framework/src/Framework/Services/BuildTaskService.php @@ -5,13 +5,11 @@ namespace Hyde\Framework\Services; use Hyde\Facades\Config; -use Hyde\Facades\Features; use Hyde\Facades\Filesystem; use Hyde\Framework\Features\BuildTasks\BuildTask; use Hyde\Framework\Features\BuildTasks\PreBuildTask; use Hyde\Framework\Features\BuildTasks\PostBuildTask; use Hyde\Framework\Actions\PreBuildTasks\CleanSiteDirectory; -use Hyde\Framework\Actions\PostBuildTasks\GenerateRssFeed; use Hyde\Framework\Actions\PreBuildTasks\TransferMediaAssets; use Hyde\Framework\Actions\PostBuildTasks\GenerateBuildManifest; use Illuminate\Console\OutputStyle; @@ -134,7 +132,6 @@ private function registerFrameworkTasks(): void $this->registerIf(CleanSiteDirectory::class, $this->canCleanSiteDirectory()); $this->registerIf(TransferMediaAssets::class, $this->canTransferMediaAssets()); $this->registerIf(GenerateBuildManifest::class, $this->canGenerateManifest()); - $this->registerIf(GenerateRssFeed::class, $this->canGenerateFeed()); } private function canCleanSiteDirectory(): bool @@ -151,9 +148,4 @@ private function canGenerateManifest(): bool { return Config::getBool('hyde.generate_build_manifest', true); } - - private function canGenerateFeed(): bool - { - return Features::hasRss(); - } } From 42606f676097f454b96127d6d40c2ff70fd00621 Mon Sep 17 00:00:00 2001 From: Emma De Silva Date: Mon, 13 Jul 2026 21:16:25 +0200 Subject: [PATCH 052/117] Update tests for the RSS feed build task to page conversion Co-Authored-By: Claude Fable 5 --- .../Feature/Commands/BuildRssFeedCommandTest.php | 2 +- .../tests/Feature/Services/BuildTaskServiceTest.php | 1 - .../tests/Feature/StaticSiteServiceTest.php | 12 ++++++------ .../tests/Unit/BuildTaskServiceUnitTest.php | 11 +++-------- 4 files changed, 10 insertions(+), 16 deletions(-) diff --git a/packages/framework/tests/Feature/Commands/BuildRssFeedCommandTest.php b/packages/framework/tests/Feature/Commands/BuildRssFeedCommandTest.php index 56b891b4740..41456ac8e43 100644 --- a/packages/framework/tests/Feature/Commands/BuildRssFeedCommandTest.php +++ b/packages/framework/tests/Feature/Commands/BuildRssFeedCommandTest.php @@ -9,7 +9,7 @@ use Hyde\Testing\TestCase; #[\PHPUnit\Framework\Attributes\CoversClass(\Hyde\Console\Commands\BuildRssFeedCommand::class)] -#[\PHPUnit\Framework\Attributes\CoversClass(\Hyde\Framework\Actions\PostBuildTasks\GenerateRssFeed::class)] +#[\PHPUnit\Framework\Attributes\CoversClass(\Hyde\Framework\Features\XmlGenerators\RssFeedPage::class)] class BuildRssFeedCommandTest extends TestCase { public function testRssFeedIsGeneratedWhenConditionsAreMet() diff --git a/packages/framework/tests/Feature/Services/BuildTaskServiceTest.php b/packages/framework/tests/Feature/Services/BuildTaskServiceTest.php index 4fc39f3eb62..9dc0f97e423 100644 --- a/packages/framework/tests/Feature/Services/BuildTaskServiceTest.php +++ b/packages/framework/tests/Feature/Services/BuildTaskServiceTest.php @@ -19,7 +19,6 @@ #[\PHPUnit\Framework\Attributes\CoversClass(\Hyde\Framework\Features\BuildTasks\BuildTask::class)] #[\PHPUnit\Framework\Attributes\CoversClass(\Hyde\Framework\Features\BuildTasks\PreBuildTask::class)] #[\PHPUnit\Framework\Attributes\CoversClass(\Hyde\Framework\Features\BuildTasks\PostBuildTask::class)] -#[\PHPUnit\Framework\Attributes\CoversClass(\Hyde\Framework\Actions\PostBuildTasks\GenerateRssFeed::class)] #[\PHPUnit\Framework\Attributes\CoversClass(\Hyde\Console\Commands\BuildSiteCommand::class)] class BuildTaskServiceTest extends TestCase { diff --git a/packages/framework/tests/Feature/StaticSiteServiceTest.php b/packages/framework/tests/Feature/StaticSiteServiceTest.php index 3e730a2a415..a2430623b5e 100644 --- a/packages/framework/tests/Feature/StaticSiteServiceTest.php +++ b/packages/framework/tests/Feature/StaticSiteServiceTest.php @@ -215,9 +215,9 @@ public function testRssFeedIsNotGeneratedWhenConditionsAreNotMet() $this->withoutSiteUrl(); config(['hyde.rss.enabled' => false]); - $this->artisan('build') - ->doesntExpectOutput('Generating RSS feed...') - ->assertExitCode(0); + $this->artisan('build')->assertExitCode(0); + + $this->assertFileDoesNotExist(Hyde::path('_site/feed.xml')); } public function testRssFeedIsGeneratedWhenConditionsAreMet() @@ -227,9 +227,9 @@ public function testRssFeedIsGeneratedWhenConditionsAreMet() Filesystem::touch('_posts/foo.md'); - $this->artisan('build') - // ->expectsOutput('Generating RSS feed...') - ->assertExitCode(0); + $this->artisan('build')->assertExitCode(0); + + $this->assertFileExists(Hyde::path('_site/feed.xml')); Filesystem::unlink('_posts/foo.md'); Filesystem::unlink('_site/feed.xml'); diff --git a/packages/framework/tests/Unit/BuildTaskServiceUnitTest.php b/packages/framework/tests/Unit/BuildTaskServiceUnitTest.php index 3264f3c6287..5ef62223993 100644 --- a/packages/framework/tests/Unit/BuildTaskServiceUnitTest.php +++ b/packages/framework/tests/Unit/BuildTaskServiceUnitTest.php @@ -8,7 +8,7 @@ use Hyde\Foundation\HydeKernel; use Hyde\Foundation\Kernel\Filesystem; use Hyde\Framework\Actions\PostBuildTasks\GenerateBuildManifest as FrameworkGenerateBuildManifest; -use Hyde\Framework\Actions\PostBuildTasks\GenerateRssFeed; +use Hyde\Framework\Actions\PreBuildTasks\TransferMediaAssets; use Hyde\Framework\Features\BuildTasks\BuildTask; use Hyde\Framework\Features\BuildTasks\PostBuildTask; use Hyde\Framework\Features\BuildTasks\PreBuildTask; @@ -162,11 +162,6 @@ public function testGenerateBuildManifestExtendsPostBuildTask() $this->assertInstanceOf(PostBuildTask::class, new FrameworkGenerateBuildManifest()); } - public function testGenerateRssFeedExtendsPostBuildTask() - { - $this->assertInstanceOf(PostBuildTask::class, new GenerateRssFeed()); - } - public function testCanRunPreBuildTasks() { $this->can(fn () => $this->service->runPreBuildTasks(...)); @@ -276,7 +271,7 @@ public function testServiceFindsTasksInAppDirectory() { $files = [ 'app/Actions/GenerateBuildManifestBuildTask.php' => FrameworkGenerateBuildManifest::class, - 'app/Actions/GenerateRssFeedBuildTask.php' => GenerateRssFeed::class, + 'app/Actions/TransferMediaAssetsBuildTask.php' => TransferMediaAssets::class, ]; $this->mockKernelFilesystem($files); @@ -285,7 +280,7 @@ public function testServiceFindsTasksInAppDirectory() $this->assertSame([ 'Hyde\Framework\Actions\PostBuildTasks\GenerateBuildManifest', - 'Hyde\Framework\Actions\PostBuildTasks\GenerateRssFeed', + 'Hyde\Framework\Actions\PreBuildTasks\TransferMediaAssets', ], $this->service->getRegisteredTasks()); $this->resetKernelInstance(); From 10f3d9eb45bdbe41a6552ab0be04376bda954a13 Mon Sep 17 00:00:00 2001 From: Emma De Silva Date: Mon, 13 Jul 2026 21:18:30 +0200 Subject: [PATCH 053/117] Test the RSS feed page registration, customization, and serving paths Covers the feature-gated route registration including the post and site URL requirements, configured filenames used verbatim for any extension, compilation through the build command including build manifest and route list presence, the container rebind customization tier, user-defined pages suppressing the generated page through both booting callbacks and extensions, and the realtime compiler serving the feed with the XML content type. Co-Authored-By: Claude Fable 5 --- .../tests/Feature/RssFeedPageTest.php | 200 ++++++++++++++++++ .../tests/RealtimeCompilerTest.php | 27 +++ 2 files changed, 227 insertions(+) create mode 100644 packages/framework/tests/Feature/RssFeedPageTest.php diff --git a/packages/framework/tests/Feature/RssFeedPageTest.php b/packages/framework/tests/Feature/RssFeedPageTest.php new file mode 100644 index 00000000000..b743b9f8a5f --- /dev/null +++ b/packages/framework/tests/Feature/RssFeedPageTest.php @@ -0,0 +1,200 @@ +withSiteUrl(); + $this->file('_posts/hello-world.md', "# Hello, World!\n\nThis is the first post."); + } + + protected function tearDown(): void + { + File::cleanDirectory(Hyde::path('_site')); + + parent::tearDown(); + } + + public function testFeedPageIsRegisteredAsRouteWhenRssFeatureIsEnabled() + { + $this->assertTrue(Routes::exists('feed.xml')); + + $page = Routes::get('feed.xml')->getPage(); + + $this->assertInstanceOf(RssFeedPage::class, $page); + $this->assertSame('feed.xml', $page->getOutputPath()); + $this->assertSame('feed.xml', $page->getRouteKey()); + } + + public function testFeedPageIsNotRegisteredWithoutSiteUrl() + { + $this->withoutSiteUrl(); + + $this->assertFalse(Routes::exists('feed.xml')); + } + + public function testFeedPageIsNotRegisteredWhenThereAreNoPosts() + { + Filesystem::unlink('_posts/hello-world.md'); + + $this->assertFalse(Routes::exists('feed.xml')); + } + + public function testFeedPageIsNotRegisteredWhenRssIsDisabledInConfig() + { + config(['hyde.rss.enabled' => false]); + + $this->assertFalse(Routes::exists('feed.xml')); + } + + public function testFeedPageUsesConfiguredFilenameAsRouteKey() + { + config(['hyde.rss.filename' => 'blog.xml']); + + $this->assertFalse(Routes::exists('feed.xml')); + $this->assertTrue(Routes::exists('blog.xml')); + $this->assertSame('blog.xml', Routes::get('blog.xml')->getPage()->getOutputPath()); + } + + public function testFeedPageUsesConfiguredFilenameVerbatimForAnyExtension() + { + config(['hyde.rss.filename' => 'feed.rss']); + + $this->assertTrue(Routes::exists('feed.rss')); + $this->assertSame('feed.rss', Routes::get('feed.rss')->getPage()->getOutputPath()); + } + + public function testFeedPageIsHiddenFromNavigationAndExcludesItselfFromTheSitemap() + { + $page = new RssFeedPage(); + + $this->assertFalse($page->showInNavigation()); + $this->assertFalse($page->showInSitemap()); + } + + public function testFeedPageCompilesUsingTheRssFeedGenerator() + { + $contents = (new RssFeedPage())->compile(); + + $this->assertStringStartsWith('', $contents); + $this->assertStringContainsString('assertStringContainsString('version="2.0"', $contents); + $this->assertStringContainsString('Hello, World!', $contents); + } + + public function testRssFeedGeneratorCanBeSwappedThroughTheServiceContainer() + { + app()->bind(RssFeedGenerator::class, fn (): RssFeedGenerator => new class extends RssFeedGenerator + { + public function generate(): static + { + return $this; + } + + public function getXml(): string + { + return 'custom generator output'; + } + }); + + $this->assertSame('custom generator output', Routes::get('feed.xml')->getPage()->compile()); + } + + public function testBuildCommandCompilesFeedPageAsDynamicPage() + { + $this->artisan('build') + ->expectsOutput('Creating Dynamic Pages...') + ->assertExitCode(0); + + $contents = file_get_contents(Hyde::path('_site/feed.xml')); + + $this->assertStringStartsWith('', $contents); + $this->assertStringContainsString('assertStringContainsString('version="2.0"', $contents); + + $this->assertStringNotContainsString('feed.xml', file_get_contents(Hyde::path('_site/sitemap.xml'))); + } + + public function testFeedPageIsIncludedInTheBuildManifest() + { + $this->artisan('build')->assertExitCode(0); + + $manifest = json_decode(file_get_contents(Hyde::path('app/storage/framework/cache/build-manifest.json')), true); + + $this->assertArrayHasKey('feed.xml', $manifest['pages']); + $this->assertSame('feed.xml', $manifest['pages']['feed.xml']['output_path']); + } + + public function testFeedRouteIsIncludedInTheRouteList() + { + $this->artisan('route:list') + ->expectsOutputToContain('feed.xml') + ->assertExitCode(0); + } + + public function testUserPageRegisteredInBootingCallbackSuppressesTheGeneratedFeedPage() + { + Hyde::kernel()->booting(function (HydeKernel $kernel): void { + $kernel->pages()->addPage(new InMemoryPage('feed.xml', contents: 'user defined feed')); + }); + + $page = Routes::get('feed.xml')->getPage(); + + $this->assertNotInstanceOf(RssFeedPage::class, $page); + $this->assertSame(1, Hyde::pages()->filter(fn ($page) => $page->getRouteKey() === 'feed.xml')->count()); + + $this->artisan('build')->assertExitCode(0); + + $this->assertSame('user defined feed', file_get_contents(Hyde::path('_site/feed.xml'))); + } + + public function testUserPageRegisteredThroughExtensionSuppressesTheGeneratedFeedPage() + { + Hyde::kernel()->registerExtension(RssFeedPageTestExtension::class); + + $page = Routes::get('feed.xml')->getPage(); + + $this->assertNotInstanceOf(RssFeedPage::class, $page); + $this->assertSame(1, Hyde::pages()->filter(fn ($page) => $page->getRouteKey() === 'feed.xml')->count()); + + $this->artisan('build')->assertExitCode(0); + + $this->assertSame('extension defined feed', file_get_contents(Hyde::path('_site/feed.xml'))); + } +} + +class RssFeedPageTestExtension extends HydeExtension +{ + public function discoverPages(PageCollection $collection): void + { + $collection->addPage(new InMemoryPage('feed.xml', contents: 'extension defined feed')); + } +} diff --git a/packages/realtime-compiler/tests/RealtimeCompilerTest.php b/packages/realtime-compiler/tests/RealtimeCompilerTest.php index 54d031c3bd8..3c717f64868 100644 --- a/packages/realtime-compiler/tests/RealtimeCompilerTest.php +++ b/packages/realtime-compiler/tests/RealtimeCompilerTest.php @@ -368,6 +368,33 @@ public function testSitemapXmlRouteIsServedWithXmlContentType() $this->assertStringContainsString('body); } + public function testRssFeedRouteIsServedWithXmlContentType() + { + config(['hyde.url' => 'https://example.com']); + + $this->mockCompilerRoute('feed.xml'); + + Filesystem::put('_posts/rc-test-post.md', '# Hello World!'); + + try { + $kernel = new HttpKernel(); + $response = $kernel->handle(new Request()); + + $this->assertInstanceOf(Response::class, $response); + $this->assertNotInstanceOf(HtmlResponse::class, $response); + $this->assertSame(200, $response->statusCode); + $this->assertSame('OK', $response->statusMessage); + + $headers = $this->getResponseHeaders($response); + $this->assertSame('application/xml', $headers['Content-Type']); + + $this->assertStringStartsWith('', $response->body); + $this->assertStringContainsString('body); + } finally { + Filesystem::unlink('_posts/rc-test-post.md'); + } + } + public function testGetContentTypeReturnsApplicationJsonForJsonOutputPath() { $page = $this->makePageWithOutputPath('foo.json'); From 32fd359bb2ee19cff2f47f3df8a5610eaf13ff57 Mon Sep 17 00:00:00 2001 From: Emma De Silva Date: Mon, 13 Jul 2026 21:20:47 +0200 Subject: [PATCH 054/117] Add release notes, upgrade guide, and epic notes for the RSS feed page conversion Co-Authored-By: Claude Fable 5 --- EPIC_NON_HTML_PAGES.md | 38 +++++++++++++++++++++++++++++++++----- HYDEPHP_V3_PLANNING.md | 5 +++-- UPGRADE.md | 26 +++++++++++++++----------- 3 files changed, 51 insertions(+), 18 deletions(-) diff --git a/EPIC_NON_HTML_PAGES.md b/EPIC_NON_HTML_PAGES.md index 0ae02f22ce2..83073f1d7df 100644 --- a/EPIC_NON_HTML_PAGES.md +++ b/EPIC_NON_HTML_PAGES.md @@ -137,6 +137,13 @@ versioned docs route keys like `docs/1.x/index` would false-positive > *(PR 5 part A: confirmed for `sitemap.xml` — within the allowlist. `feed.xml`, > `robots.txt`, and `llms.txt` are too, so the framework itself will not need > option (b); the remaining call in PR 8 is only for the power-user audience.)* + > *(PR 5 part B qualification: the RSS filename is user-configurable, and the old + > task wrote any `hyde.rss.filename` verbatim — so `RssFeedPage` overrides + > `identifierHasExplicitOutputExtension()` to always treat the configured filename + > as the literal output path, keeping `feed.rss` (or an extensionless name) + > working. This confirms the subclass override is a workable escape hatch for + > first-party pages, but does not settle option (b) for user-land `make()` + > callers, which remains the PR 8 call.)* ### D3: Sitemap inclusion becomes a page-level concern @@ -244,7 +251,7 @@ container → fully custom page in code. > so the skip check cannot see its pages; instead the user page replaces the generated > one under the same collection key (`addPage()` keys by source path). Both are > asserted through the real `build` command output. The robots.txt equivalent remains -> mandatory for PR 6. +> mandatory for PR 6. *(Part B: both paths verified the same way for the feed page.)* ### D6: No built-in `TextPage` or `.txt` autodiscovery @@ -388,14 +395,14 @@ Implementation notes (branch `v3/non-html-pages-sitemap-inclusion-policy`): - No UPGRADE.md entry: the fix requires no user action, and nothing realistic depended on search indexes appearing in sitemaps. -### PR 5 — Convert sitemap and RSS from build tasks to pages 🚧 Part A (sitemap) implemented; part B (RSS) remaining +### PR 5 — Convert sitemap and RSS from build tasks to pages ✅ Implemented Goal: `sitemap.xml` and `feed.xml` are routes — served by `hyde serve`, listed in `route:list`, included in the build manifest, overridable in user land. -> **Split during implementation:** part A converts the sitemap, part B will convert -> the RSS feed the same way. The bullets below still describe both; the part A notes -> at the end of this section record what landed and what part B should mirror. +> **Split during implementation:** part A converted the sitemap, part B converted +> the RSS feed the same way. The bullets below describe both; the notes at the end +> of this section record what landed in each part. - Register `sitemap.xml` / `feed.xml` as `InMemoryPage`s per D4, with a lazy `compile` that resolves the generator from the container @@ -458,6 +465,27 @@ Implementation notes, part A (branch `v3/non-html-pages-convert-sitemap`): so removing the RSS task won't churn them again. Part B should mirror everything here with `RssFeedPage`, taking its route key from `RssFeedGenerator::getFilename()`. +Implementation notes, part B (branch `v3/non-html-pages-convert-rss-feed`): + +- `RssFeedPage` mirrors `SitemapPage` throughout: thin subclass in `XmlGenerators`, + container-resolved `compile()` (rebind verified by test), registered behind + `Features::hasRss()` with the D5 skip check, hidden from navigation, D3-excluded + from the sitemap, and both user override paths verified end-to-end. +- One divergence: the route key comes from `RssFeedGenerator::getFilename()` + (config `hyde.rss.filename`), and since the removed task wrote any configured + filename verbatim, `RssFeedPage` overrides `identifierHasExplicitOutputExtension()` + to always use the filename as the literal output path — `feed.rss` or an + extensionless name would otherwise regress to `.html`-suffixed output (see the + D2 part B qualification). +- `build:rss` keeps the old task's semantics of having no guard at all: invoked + explicitly it generates the feed regardless of the feature conditions (no site + URL, no posts, or `hyde.rss.enabled` false), falling back to `new RssFeedPage()` + when the route is not registered. Only `build:sitemap` has a base-URL guard, + matching the tasks each command replaced. +- `BuildTaskService` no longer registers any feature-gated tasks; the `Features` + facade import went with the last one. The remaining framework tasks + (clean/transfer/manifest) are all config-gated. + ### PR 6 — Generated `robots.txt` Goal: sensible robots.txt out of the box, zero config. diff --git a/HYDEPHP_V3_PLANNING.md b/HYDEPHP_V3_PLANNING.md index cd60a983710..ba504fcab7a 100644 --- a/HYDEPHP_V3_PLANNING.md +++ b/HYDEPHP_V3_PLANNING.md @@ -26,7 +26,7 @@ Having this document in code lets us know the devlopment state at any given poin - Added built-in terminal code blocks using the `terminal` fence language. Command prompts are styled for selection-free copying, and `terminal xml` supports four Symfony-style Console formatter tags. ([#2188](https://github.com/hydephp/develop/issues/2188), [#2485](https://github.com/hydephp/develop/issues/2485)) - Pages can now compile to non-HTML output files. Page classes declare their output file extension through the new static `$outputExtension` property (defaulting to `.html`), and in-memory page identifiers can declare a `.json`, `.txt`, or `.xml` extension directly, so `InMemoryPage::make('robots.txt', contents: ...)` compiles to `_site/robots.txt` through the standard site build. Only the HTML extension is implicit in route keys: pages compiled to non-HTML files keep their extension in the route key, formalizing the convention already used by the documentation search index. - Pages can now control their own sitemap inclusion. Set `sitemap: false` in a page's front matter to exclude it from the generated `sitemap.xml`, or override the new `HydePage::showInSitemap()` method in custom page classes. Pages compiled to non-HTML output files (like `robots.txt`) are excluded by default, and `sitemap: true` front matter opts such a page back in. -- The sitemap is now a first-class page instead of a post-build side effect: when sitemap generation is enabled, `sitemap.xml` is registered as a route, so it is served by `hyde serve`, listed in `route:list`, included in the build manifest, and compiled through the standard site build. The output can be customized by rebinding the `SitemapGenerator` class in the service container, and registering a user-defined page with the `sitemap.xml` route key (from a service provider, booting callback, or extension) replaces the generated page entirely. +- The sitemap and RSS feed are now first-class pages instead of post-build side effects: when the respective feature is enabled, `sitemap.xml` and the RSS feed (`feed.xml`, or the configured `hyde.rss.filename`) are registered as routes, so they are served by `hyde serve`, listed in `route:list`, included in the build manifest, and compiled through the standard site build. The output can be customized by rebinding the `SitemapGenerator` or `RssFeedGenerator` class in the service container, and registering a user-defined page with the same route key (from a service provider, booting callback, or extension) replaces the generated page entirely. ### Feature Changes @@ -51,6 +51,7 @@ Having this document in code lets us know the devlopment state at any given poin - In-memory page identifiers ending in `.json`, `.txt`, or `.xml` now compile to that path as-is instead of gaining a second `.html` extension. The old double-extension outputs (like `data.json.html`) were almost certainly never intended, so no real sites are expected to be affected. - Redirect source paths declared in `hyde.redirects` ending in `.json`, `.txt`, or `.xml` are now rejected with an exception, since a meta refresh redirect cannot work for files served as non-HTML content. Previously such entries silently produced an unreachable `legacy.json.html` file, so no working configuration is affected. - 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 register a custom `sitemap.xml` page or rebind `SitemapGenerator` in the container instead. The `build:sitemap` command now compiles the registered page, and reports failure with exit code 1 instead of 3 when no base URL is configured. +- 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 register a custom page with the configured feed route key or rebind `RssFeedGenerator` in the container instead. The `build:rss` command now compiles the registered page, and still generates the feed regardless of the feature conditions when invoked explicitly. - Removed `Redirect::create()`, `Redirect::store()`, and the `Redirect` constructor's `showText` argument. Redirects must now be declared in `hyde.redirects`, keeping all generated output inside the kernel-owned build graph. Redirect routes are intrinsically excluded from navigation menus and sitemaps, and always include an accessible fallback link. - Removed the `InMemoryPage` instance macro API. Dynamic contents should now be supplied with a closure, while custom methods and other behavior belong in an `InMemoryPage` subclass. - Removed `InMemoryPage` content-source precedence. Calls that previously supplied both `contents` and `view` must retain only the intended source; positional view calls that used an empty-string contents placeholder must use `null` instead. @@ -68,7 +69,7 @@ Please fill in UPGRADE.md as you make changes. - Move `InMemoryPage` `compile` macro callbacks into the contents argument, and replace other instance macros with methods on an `InMemoryPage` subclass. - Update `InMemoryPage` calls to supply only `contents` or `view`. Replace an empty-string positional contents placeholder with `null`, or use the named `view` argument. - Rename `$fileExtension` to `$sourceExtension` in custom page classes, and update any calls to `fileExtension()` or `setFileExtension()` to `sourceExtension()` and `setSourceExtension()`. -- If you referenced the removed `GenerateSitemap` build task class (for example to override it with a same-basename user-land task), customize the sitemap by rebinding `SitemapGenerator` in the service container or by registering your own `sitemap.xml` page instead. +- 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 rebinding `SitemapGenerator` or `RssFeedGenerator` in the service container or by registering your own page with the same route key instead. ## `InMemoryPage` content-source motivation diff --git a/UPGRADE.md b/UPGRADE.md index 9d94b4ef4f0..120c3d52e65 100644 --- a/UPGRADE.md +++ b/UPGRADE.md @@ -302,14 +302,16 @@ new InMemoryPage('example', view: ''); new InMemoryPage('example', view: null); ``` -## Step 6: Review Sitemap Customizations +## Step 6: Review Sitemap and RSS Feed Customizations -The sitemap is now generated as a regular page instead of by a post-build task, so `sitemap.xml` is served by -`php hyde serve`, listed in `route:list`, and included in the build manifest. Sites that just enable or disable -the sitemap through `hyde.generate_sitemap` and `hyde.url` need no changes. +The sitemap and RSS feed are now generated as regular pages instead of by post-build tasks, so `sitemap.xml` and +the RSS feed (`feed.xml`, or your configured `hyde.rss.filename`) are served by `php hyde serve`, listed in +`route:list`, and included in the build manifest. Sites that just enable or disable these features through +`hyde.generate_sitemap`, `hyde.rss`, and `hyde.url` need no changes. -The `GenerateSitemap` post-build task class has been removed. If you overrode it with a same-basename build task, -or referenced the class directly, customize the sitemap through one of its replacement tiers instead: +The `GenerateSitemap` and `GenerateRssFeed` post-build task classes have been removed. If you overrode one with a +same-basename build task, or referenced the classes directly, customize the output through one of the replacement +tiers instead: - Rebind the generator in the service container to change the output while keeping the page registration: @@ -319,8 +321,10 @@ use Hyde\Framework\Features\XmlGenerators\SitemapGenerator; app()->bind(SitemapGenerator::class, MyCustomSitemapGenerator::class); ``` -- Or register your own page with the `sitemap.xml` route key (from a service provider, booting callback, or - extension), which replaces the generated page entirely: +The same works for `RssFeedGenerator`. + +- Or register your own page with the same route key (`sitemap.xml`, or the configured feed filename) from a + service provider, booting callback, or extension, which replaces the generated page entirely: ```php use Hyde\Hyde; @@ -331,8 +335,8 @@ Hyde::kernel()->booting(function ($kernel): void { }); ``` -The `build:sitemap` command still works and now compiles the registered page. When no base URL is configured it -reports failure with exit code 1 instead of 3. +The `build:sitemap` and `build:rss` commands still work and now compile the registered pages. When no base URL is +configured, `build:sitemap` reports failure with exit code 1 instead of 3. ## Step 7: Rename Page File Extension References @@ -384,7 +388,7 @@ Use this checklist to track your upgrade progress: - [ ] Moved calls to `Redirect::create()` or `Redirect::store()` into the `hyde.redirects` configuration array - [ ] Moved `InMemoryPage` `compile` macro callbacks into the contents argument and replaced other macros with subclass methods - [ ] Updated `InMemoryPage` calls to supply only one of `contents` and `view` -- [ ] Replaced any references to the removed `GenerateSitemap` build task with a `SitemapGenerator` container rebind or a user-defined `sitemap.xml` page +- [ ] Replaced any references to the removed `GenerateSitemap` and `GenerateRssFeed` build tasks with a generator container rebind or a user-defined page - [ ] Renamed `$fileExtension`, `fileExtension()`, and `setFileExtension()` to `$sourceExtension`, `sourceExtension()`, and `setSourceExtension()` in custom page classes and call sites ## Troubleshooting From 1dd9a15ebec1712ae8c6ae2769476c0ad87c8ec5 Mon Sep 17 00:00:00 2001 From: Emma De Silva Date: Mon, 13 Jul 2026 21:22:48 +0200 Subject: [PATCH 055/117] Disable RSS feed generation in tests asserting exact default collections These tests create posts while the monorepo test environment configures a site URL, so the feed page would now appear in their exact collection assertions. The feed route registration itself is covered by RssFeedPageTest. Co-Authored-By: Claude Fable 5 --- packages/framework/tests/Feature/PageCollectionTest.php | 2 +- packages/framework/tests/Feature/RouteCollectionTest.php | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/framework/tests/Feature/PageCollectionTest.php b/packages/framework/tests/Feature/PageCollectionTest.php index d1dfdae3b97..eb21042990f 100644 --- a/packages/framework/tests/Feature/PageCollectionTest.php +++ b/packages/framework/tests/Feature/PageCollectionTest.php @@ -26,7 +26,7 @@ protected function setUp(): void { parent::setUp(); - config(['hyde.generate_sitemap' => false]); + config(['hyde.generate_sitemap' => false, 'hyde.rss.enabled' => false]); } public function testBootMethodCreatesNewPageCollectionAndDiscoversPagesAutomatically() diff --git a/packages/framework/tests/Feature/RouteCollectionTest.php b/packages/framework/tests/Feature/RouteCollectionTest.php index b3422edbf9b..b069f2f5b29 100644 --- a/packages/framework/tests/Feature/RouteCollectionTest.php +++ b/packages/framework/tests/Feature/RouteCollectionTest.php @@ -26,7 +26,7 @@ protected function setUp(): void { parent::setUp(); - config(['hyde.generate_sitemap' => false]); + config(['hyde.generate_sitemap' => false, 'hyde.rss.enabled' => false]); } public function testBootMethodDiscoversAllPages() From b853f85a4221e0d0c16a6afe3d5eb546c59bd2e4 Mon Sep 17 00:00:00 2001 From: Emma De Silva Date: Mon, 13 Jul 2026 21:32:12 +0200 Subject: [PATCH 056/117] Fail the sitemap and RSS commands with the unmet condition instead of generating anyway Drops the fallback to a fresh page instance when the route is not registered: build:sitemap no longer overrides a disabled configuration and build:rss no longer emits an empty feed when there are no posts. The commands now report the specific unmet feature condition and fail, while a user-defined page registered under the route key is still built regardless of the feature conditions since the lookup is route-first. Co-Authored-By: Claude Fable 5 --- .../Console/Commands/BuildRssFeedCommand.php | 33 ++++++++++++++++--- .../Console/Commands/BuildSitemapCommand.php | 24 ++++++++++---- 2 files changed, 45 insertions(+), 12 deletions(-) diff --git a/packages/framework/src/Console/Commands/BuildRssFeedCommand.php b/packages/framework/src/Console/Commands/BuildRssFeedCommand.php index e8703b4f646..f0062059668 100644 --- a/packages/framework/src/Console/Commands/BuildRssFeedCommand.php +++ b/packages/framework/src/Console/Commands/BuildRssFeedCommand.php @@ -6,11 +6,14 @@ use Hyde\Hyde; use Hyde\Console\Concerns\Command; +use Hyde\Facades\Config; +use Hyde\Facades\Features; use Hyde\Foundation\Facades\Routes; use Hyde\Framework\Actions\StaticPageBuilder; use Hyde\Framework\Features\XmlGenerators\RssFeedPage; -use Hyde\Pages\Concerns\HydePage; +use Hyde\Pages\MarkdownPost; +use function count; use function sprintf; /** @@ -26,16 +29,36 @@ class BuildRssFeedCommand extends Command public function handle(): int { - $path = StaticPageBuilder::handle($this->getFeedPage()); + $page = Routes::find(RssFeedPage::routeKey())?->getPage(); + + if ($page === null) { + $this->error($this->getSkipReason()); + + return Command::FAILURE; + } + + $path = StaticPageBuilder::handle($page); $this->infoComment(sprintf('Created [%s]', Hyde::pathToRelative($path))); return Command::SUCCESS; } - /** Get the registered RSS feed page, falling back to a new instance when the route is not registered. */ - protected function getFeedPage(): HydePage + /** Explain why the RSS feed route is not registered, mirroring the conditions of {@see \Hyde\Facades\Features::hasRss()}. */ + protected function getSkipReason(): string { - return Routes::find(RssFeedPage::routeKey())?->getPage() ?? new RssFeedPage(); + if (! Hyde::hasSiteUrl()) { + return 'Cannot generate an RSS feed without a valid base URL'; + } + + if (! Config::getBool('hyde.rss.enabled', true)) { + return 'Cannot generate the RSS feed as it is disabled in the configuration'; + } + + if (! Features::hasMarkdownPosts() || count(MarkdownPost::files()) === 0) { + return 'Cannot generate an RSS feed without any Markdown posts'; + } + + return 'Cannot generate the RSS feed as the SimpleXML extension is not installed'; } } diff --git a/packages/framework/src/Console/Commands/BuildSitemapCommand.php b/packages/framework/src/Console/Commands/BuildSitemapCommand.php index fba3ef4cfcc..601f7b09bff 100644 --- a/packages/framework/src/Console/Commands/BuildSitemapCommand.php +++ b/packages/framework/src/Console/Commands/BuildSitemapCommand.php @@ -6,10 +6,10 @@ use Hyde\Hyde; use Hyde\Console\Concerns\Command; +use Hyde\Facades\Config; use Hyde\Foundation\Facades\Routes; use Hyde\Framework\Actions\StaticPageBuilder; use Hyde\Framework\Features\XmlGenerators\SitemapPage; -use Hyde\Pages\Concerns\HydePage; use function sprintf; @@ -26,22 +26,32 @@ class BuildSitemapCommand extends Command public function handle(): int { - if (! Hyde::hasSiteUrl()) { - $this->error('Cannot generate sitemap without a valid base URL'); + $page = Routes::find(SitemapPage::routeKey())?->getPage(); + + if ($page === null) { + $this->error($this->getSkipReason()); return Command::FAILURE; } - $path = StaticPageBuilder::handle($this->getSitemapPage()); + $path = StaticPageBuilder::handle($page); $this->infoComment(sprintf('Created [%s]', Hyde::pathToRelative($path))); return Command::SUCCESS; } - /** Get the registered sitemap page, falling back to a new instance when the route is not registered. */ - protected function getSitemapPage(): HydePage + /** Explain why the sitemap route is not registered, mirroring the conditions of {@see \Hyde\Facades\Features::hasSitemap()}. */ + protected function getSkipReason(): string { - return Routes::find(SitemapPage::routeKey())?->getPage() ?? new SitemapPage(); + if (! Hyde::hasSiteUrl()) { + return 'Cannot generate sitemap without a valid base URL'; + } + + if (! Config::getBool('hyde.generate_sitemap', true)) { + return 'Cannot generate the sitemap as it is disabled in the configuration'; + } + + return 'Cannot generate the sitemap as the SimpleXML extension is not installed'; } } From f7e2bb36c5cbe04d56fe6ea83b8991424b2aa070 Mon Sep 17 00:00:00 2001 From: Emma De Silva Date: Mon, 13 Jul 2026 21:32:12 +0200 Subject: [PATCH 057/117] Update command tests for the removed generation fallbacks Co-Authored-By: Claude Fable 5 --- .../Commands/BuildRssFeedCommandTest.php | 54 +++++++++++++++++++ .../Commands/BuildSitemapCommandTest.php | 22 ++++++-- 2 files changed, 73 insertions(+), 3 deletions(-) diff --git a/packages/framework/tests/Feature/Commands/BuildRssFeedCommandTest.php b/packages/framework/tests/Feature/Commands/BuildRssFeedCommandTest.php index 41456ac8e43..6b8bba12d25 100644 --- a/packages/framework/tests/Feature/Commands/BuildRssFeedCommandTest.php +++ b/packages/framework/tests/Feature/Commands/BuildRssFeedCommandTest.php @@ -6,6 +6,8 @@ use Hyde\Facades\Filesystem; use Hyde\Hyde; +use Hyde\Pages\InMemoryPage; +use Hyde\Foundation\HydeKernel; use Hyde\Testing\TestCase; #[\PHPUnit\Framework\Attributes\CoversClass(\Hyde\Console\Commands\BuildRssFeedCommand::class)] @@ -41,4 +43,56 @@ public function testRssFilenameCanBeChanged() $this->assertFileExists(Hyde::path('_site/blog.xml')); Filesystem::unlink('_site/blog.xml'); } + + public function testRssFeedIsNotGeneratedWithoutSiteUrl() + { + config(['hyde.url' => '']); + $this->file('_posts/foo.md'); + + $this->artisan('build:rss') + ->expectsOutput('Cannot generate an RSS feed without a valid base URL') + ->assertExitCode(1); + + $this->assertFileDoesNotExist(Hyde::path('_site/feed.xml')); + } + + public function testRssFeedIsNotGeneratedWhenFeedIsDisabledInConfig() + { + $this->withSiteUrl(); + config(['hyde.rss.enabled' => false]); + $this->file('_posts/foo.md'); + + $this->artisan('build:rss') + ->expectsOutput('Cannot generate the RSS feed as it is disabled in the configuration') + ->assertExitCode(1); + + $this->assertFileDoesNotExist(Hyde::path('_site/feed.xml')); + } + + public function testRssFeedIsNotGeneratedWhenThereAreNoPosts() + { + $this->withSiteUrl(); + + $this->artisan('build:rss') + ->expectsOutput('Cannot generate an RSS feed without any Markdown posts') + ->assertExitCode(1); + + $this->assertFileDoesNotExist(Hyde::path('_site/feed.xml')); + } + + public function testCommandBuildsUserDefinedFeedPageEvenWhenRssFeatureConditionsAreNotMet() + { + $this->withSiteUrl(); + config(['hyde.rss.enabled' => false]); + + $this->cleanUpWhenDone('_site/feed.xml'); + + Hyde::kernel()->booting(function (HydeKernel $kernel): void { + $kernel->pages()->addPage(new InMemoryPage('feed.xml', contents: '')); + }); + + $this->artisan('build:rss')->assertExitCode(0); + + $this->assertSame('', file_get_contents(Hyde::path('_site/feed.xml'))); + } } diff --git a/packages/framework/tests/Feature/Commands/BuildSitemapCommandTest.php b/packages/framework/tests/Feature/Commands/BuildSitemapCommandTest.php index 978ecd0463c..8beda313882 100644 --- a/packages/framework/tests/Feature/Commands/BuildSitemapCommandTest.php +++ b/packages/framework/tests/Feature/Commands/BuildSitemapCommandTest.php @@ -41,21 +41,37 @@ public function testSitemapIsNotGeneratedWhenConditionsAreNotMet() $this->assertFileDoesNotExist(Hyde::path('_site/sitemap.xml')); } - public function testSitemapIsGeneratedWhenSitemapGenerationIsDisabledInConfig() + public function testSitemapIsNotGeneratedWhenSitemapGenerationIsDisabledInConfig() { config(['hyde.url' => 'https://example.com']); config(['hyde.generate_sitemap' => false]); + $this->artisan('build:sitemap') + ->expectsOutput('Cannot generate the sitemap as it is disabled in the configuration') + ->assertExitCode(1); + + $this->assertFileDoesNotExist(Hyde::path('_site/sitemap.xml')); + } + + public function testCommandBuildsUserDefinedSitemapPageWhenOneIsRegistered() + { + config(['hyde.url' => 'https://example.com']); + $this->cleanUpWhenDone('_site/sitemap.xml'); + Hyde::kernel()->booting(function (HydeKernel $kernel): void { + $kernel->pages()->addPage(new InMemoryPage('sitemap.xml', contents: '')); + }); + $this->artisan('build:sitemap')->assertExitCode(0); - $this->assertFileExists(Hyde::path('_site/sitemap.xml')); + $this->assertSame('', file_get_contents(Hyde::path('_site/sitemap.xml'))); } - public function testCommandBuildsUserDefinedSitemapPageWhenOneIsRegistered() + public function testCommandBuildsUserDefinedSitemapPageEvenWhenSitemapFeatureIsDisabled() { config(['hyde.url' => 'https://example.com']); + config(['hyde.generate_sitemap' => false]); $this->cleanUpWhenDone('_site/sitemap.xml'); From 179a87e34a4e789ec74ebb23ebe18519d9daf026 Mon Sep 17 00:00:00 2001 From: Emma De Silva Date: Mon, 13 Jul 2026 21:32:12 +0200 Subject: [PATCH 058/117] Document the revised sitemap and RSS command guard semantics Co-Authored-By: Claude Fable 5 --- EPIC_NON_HTML_PAGES.md | 26 +++++++++++++++++--------- HYDEPHP_V3_PLANNING.md | 4 ++-- UPGRADE.md | 7 +++++-- 3 files changed, 24 insertions(+), 13 deletions(-) diff --git a/EPIC_NON_HTML_PAGES.md b/EPIC_NON_HTML_PAGES.md index 83073f1d7df..afefa8854ee 100644 --- a/EPIC_NON_HTML_PAGES.md +++ b/EPIC_NON_HTML_PAGES.md @@ -450,12 +450,20 @@ Implementation notes, part A (branch `v3/non-html-pages-convert-sitemap`): with release-notes guidance to the rebind/override tiers is more honest. Recorded as a v3 breaking change with the realistic impact being same-basename user-land task overrides. -- `build:sitemap` builds the registered route's page via `StaticPageBuilder`, - falling back to `new SitemapPage()` when the route is not registered. The fallback - preserves the old task behavior where the explicit command generates the sitemap - even with `hyde.generate_sitemap` disabled (only the base URL gates it), and the +- `build:sitemap` builds the registered route's page via `StaticPageBuilder`. The route-first lookup means a user-defined `sitemap.xml` page wins here too. Skip exit code changed from 3 (task-runner semantics) to 1. + *(Revised in review, applies to both commands: an initial implementation fell back + to a fresh page instance when the route was not registered, for strict + backwards compatibility with the old tasks — `build:sitemap` generated even with + `hyde.generate_sitemap` disabled, and `build:rss` had no guard at all, emitting an + empty feed with zero posts. That silently overriding the user's own configuration + or producing a useless file is a trap, not a feature: the commands now fail with + a message stating the specific unmet condition (no base URL, disabled in config, + no posts, missing SimpleXML) when the route is not registered. Because the lookup + is route-first rather than feature-flag-first, a user-defined page under the route + key is still built even when the feature conditions are unmet — the only behavior + the fallback enabled that anyone could plausibly want, preserved without it.)* - `GlobalMetadataBag` verified: the sitemap head link is emitted under the same `Features::hasSitemap()` condition that registers the page — no drift possible. - Realtime compiler needed no changes (PR 2's route-first resolution); a serve test @@ -477,11 +485,11 @@ Implementation notes, part B (branch `v3/non-html-pages-convert-rss-feed`): to always use the filename as the literal output path — `feed.rss` or an extensionless name would otherwise regress to `.html`-suffixed output (see the D2 part B qualification). -- `build:rss` keeps the old task's semantics of having no guard at all: invoked - explicitly it generates the feed regardless of the feature conditions (no site - URL, no posts, or `hyde.rss.enabled` false), falling back to `new RssFeedPage()` - when the route is not registered. Only `build:sitemap` has a base-URL guard, - matching the tasks each command replaced. +- `build:rss` builds the registered route's page like `build:sitemap`, and fails + with the specific unmet condition when the route is not registered (see the + revised-in-review note in the part A section — the old task's no-guard semantics, + where an explicit invocation emitted an empty feed with zero posts, were + deliberately not preserved). - `BuildTaskService` no longer registers any feature-gated tasks; the `Features` facade import went with the last one. The remaining framework tasks (clean/transfer/manifest) are all config-gated. diff --git a/HYDEPHP_V3_PLANNING.md b/HYDEPHP_V3_PLANNING.md index ba504fcab7a..4b95de85b44 100644 --- a/HYDEPHP_V3_PLANNING.md +++ b/HYDEPHP_V3_PLANNING.md @@ -50,8 +50,8 @@ Having this document in code lets us know the devlopment state at any given poin - Renamed the static page class property `$fileExtension` to `$sourceExtension`, and the `fileExtension()` and `setFileExtension()` methods to `sourceExtension()` and `setSourceExtension()`. The old name was ambiguous now that page classes also declare an output extension through the new `$outputExtension` property, and the renamed pair makes the source/output distinction explicit. 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). Page discovery fails fast with an actionable exception for registered page classes that still use the old API, instead of silently skipping them during builds. - In-memory page identifiers ending in `.json`, `.txt`, or `.xml` now compile to that path as-is instead of gaining a second `.html` extension. The old double-extension outputs (like `data.json.html`) were almost certainly never intended, so no real sites are expected to be affected. - Redirect source paths declared in `hyde.redirects` ending in `.json`, `.txt`, or `.xml` are now rejected with an exception, since a meta refresh redirect cannot work for files served as non-HTML content. Previously such entries silently produced an unreachable `legacy.json.html` file, so no working configuration is affected. -- 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 register a custom `sitemap.xml` page or rebind `SitemapGenerator` in the container instead. The `build:sitemap` command now compiles the registered page, and reports failure with exit code 1 instead of 3 when no base URL is configured. -- 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 register a custom page with the configured feed route key or rebind `RssFeedGenerator` in the container instead. The `build:rss` command now compiles the registered page, and still generates the feed regardless of the feature conditions when invoked explicitly. +- 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 register a custom `sitemap.xml` page or rebind `SitemapGenerator` in the container instead. The `build:sitemap` command now compiles the registered page, and fails with an explanatory message (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 register a custom page with the configured feed route key or rebind `RssFeedGenerator` in the container instead. The `build:rss` command now compiles the registered page, and fails with an explanatory message when the feed cannot be generated (no base URL, disabled in the configuration, or no Markdown posts), instead of silently generating an empty feed. A user-defined page registered under the feed route key is still built even when the feature conditions are not met. - Removed `Redirect::create()`, `Redirect::store()`, and the `Redirect` constructor's `showText` argument. Redirects must now be declared in `hyde.redirects`, keeping all generated output inside the kernel-owned build graph. Redirect routes are intrinsically excluded from navigation menus and sitemaps, and always include an accessible fallback link. - Removed the `InMemoryPage` instance macro API. Dynamic contents should now be supplied with a closure, while custom methods and other behavior belong in an `InMemoryPage` subclass. - Removed `InMemoryPage` content-source precedence. Calls that previously supplied both `contents` and `view` must retain only the intended source; positional view calls that used an empty-string contents placeholder must use `null` instead. diff --git a/UPGRADE.md b/UPGRADE.md index 120c3d52e65..c7fb40ce889 100644 --- a/UPGRADE.md +++ b/UPGRADE.md @@ -335,8 +335,11 @@ Hyde::kernel()->booting(function ($kernel): void { }); ``` -The `build:sitemap` and `build:rss` commands still work and now compile the registered pages. When no base URL is -configured, `build:sitemap` reports failure with exit code 1 instead of 3. +The `build:sitemap` and `build:rss` commands still work and now compile the registered pages. When the output +cannot be generated, they fail with a message explaining why (no base URL, disabled in the configuration, or — +for the feed — no Markdown posts) instead of generating an empty or unwanted file. `build:sitemap` reports this +failure with exit code 1 instead of 3. If you registered your own page under the route key, the commands build +it regardless of these conditions. ## Step 7: Rename Page File Extension References From d6780136c4c488cdaac7be672695566eb347e6ec Mon Sep 17 00:00:00 2001 From: Emma De Silva Date: Mon, 13 Jul 2026 22:15:29 +0200 Subject: [PATCH 059/117] Cover the SimpleXML fallback skip reason in both XML build commands Co-Authored-By: Claude Fable 5 --- .../Feature/Commands/BuildRssFeedCommandTest.php | 14 ++++++++++++++ .../Feature/Commands/BuildSitemapCommandTest.php | 13 +++++++++++++ 2 files changed, 27 insertions(+) diff --git a/packages/framework/tests/Feature/Commands/BuildRssFeedCommandTest.php b/packages/framework/tests/Feature/Commands/BuildRssFeedCommandTest.php index 6b8bba12d25..f33481bd20a 100644 --- a/packages/framework/tests/Feature/Commands/BuildRssFeedCommandTest.php +++ b/packages/framework/tests/Feature/Commands/BuildRssFeedCommandTest.php @@ -80,6 +80,20 @@ public function testRssFeedIsNotGeneratedWhenThereAreNoPosts() $this->assertFileDoesNotExist(Hyde::path('_site/feed.xml')); } + public function testSkipReasonFallsBackToMissingSimpleXmlExtensionWhenOtherConditionsAreMet() + { + $this->withSiteUrl(); + $this->file('_posts/foo.md'); + + Hyde::routes()->forget('feed.xml'); + + $this->artisan('build:rss') + ->expectsOutput('Cannot generate the RSS feed as the SimpleXML extension is not installed') + ->assertExitCode(1); + + $this->assertFileDoesNotExist(Hyde::path('_site/feed.xml')); + } + public function testCommandBuildsUserDefinedFeedPageEvenWhenRssFeatureConditionsAreNotMet() { $this->withSiteUrl(); diff --git a/packages/framework/tests/Feature/Commands/BuildSitemapCommandTest.php b/packages/framework/tests/Feature/Commands/BuildSitemapCommandTest.php index 8beda313882..734042303cc 100644 --- a/packages/framework/tests/Feature/Commands/BuildSitemapCommandTest.php +++ b/packages/framework/tests/Feature/Commands/BuildSitemapCommandTest.php @@ -53,6 +53,19 @@ public function testSitemapIsNotGeneratedWhenSitemapGenerationIsDisabledInConfig $this->assertFileDoesNotExist(Hyde::path('_site/sitemap.xml')); } + public function testSkipReasonFallsBackToMissingSimpleXmlExtensionWhenOtherConditionsAreMet() + { + config(['hyde.url' => 'https://example.com']); + + Hyde::routes()->forget('sitemap.xml'); + + $this->artisan('build:sitemap') + ->expectsOutput('Cannot generate the sitemap as the SimpleXML extension is not installed') + ->assertExitCode(1); + + $this->assertFileDoesNotExist(Hyde::path('_site/sitemap.xml')); + } + public function testCommandBuildsUserDefinedSitemapPageWhenOneIsRegistered() { config(['hyde.url' => 'https://example.com']); From f5cc972ed70a56247d222aae5d14cddae3669d73 Mon Sep 17 00:00:00 2001 From: Emma De Silva Date: Mon, 13 Jul 2026 22:20:05 +0200 Subject: [PATCH 060/117] Replace the dynamic skip reasons with a static error message The commands re-derived the specific unmet condition by mirroring the Features::hasSitemap()/hasRss() checks, ending in a SimpleXML fallback that attributed the failure by elimination rather than observation. These commands fail too rarely to justify the duplicated feature logic. Co-Authored-By: Claude Fable 5 --- .../Console/Commands/BuildRssFeedCommand.php | 24 +------------------ .../Console/Commands/BuildSitemapCommand.php | 17 +------------ .../Commands/BuildRssFeedCommandTest.php | 20 +++------------- .../Commands/BuildSitemapCommandTest.php | 17 ++----------- 4 files changed, 7 insertions(+), 71 deletions(-) diff --git a/packages/framework/src/Console/Commands/BuildRssFeedCommand.php b/packages/framework/src/Console/Commands/BuildRssFeedCommand.php index f0062059668..85a9b820f3a 100644 --- a/packages/framework/src/Console/Commands/BuildRssFeedCommand.php +++ b/packages/framework/src/Console/Commands/BuildRssFeedCommand.php @@ -6,14 +6,10 @@ use Hyde\Hyde; use Hyde\Console\Concerns\Command; -use Hyde\Facades\Config; -use Hyde\Facades\Features; use Hyde\Foundation\Facades\Routes; use Hyde\Framework\Actions\StaticPageBuilder; use Hyde\Framework\Features\XmlGenerators\RssFeedPage; -use Hyde\Pages\MarkdownPost; -use function count; use function sprintf; /** @@ -32,7 +28,7 @@ public function handle(): int $page = Routes::find(RssFeedPage::routeKey())?->getPage(); if ($page === null) { - $this->error($this->getSkipReason()); + $this->error('Cannot generate the RSS feed as the feature is not enabled'); return Command::FAILURE; } @@ -43,22 +39,4 @@ public function handle(): int return Command::SUCCESS; } - - /** Explain why the RSS feed route is not registered, mirroring the conditions of {@see \Hyde\Facades\Features::hasRss()}. */ - protected function getSkipReason(): string - { - if (! Hyde::hasSiteUrl()) { - return 'Cannot generate an RSS feed without a valid base URL'; - } - - if (! Config::getBool('hyde.rss.enabled', true)) { - return 'Cannot generate the RSS feed as it is disabled in the configuration'; - } - - if (! Features::hasMarkdownPosts() || count(MarkdownPost::files()) === 0) { - return 'Cannot generate an RSS feed without any Markdown posts'; - } - - return 'Cannot generate the RSS feed as the SimpleXML extension is not installed'; - } } diff --git a/packages/framework/src/Console/Commands/BuildSitemapCommand.php b/packages/framework/src/Console/Commands/BuildSitemapCommand.php index 601f7b09bff..6f503d210a9 100644 --- a/packages/framework/src/Console/Commands/BuildSitemapCommand.php +++ b/packages/framework/src/Console/Commands/BuildSitemapCommand.php @@ -6,7 +6,6 @@ use Hyde\Hyde; use Hyde\Console\Concerns\Command; -use Hyde\Facades\Config; use Hyde\Foundation\Facades\Routes; use Hyde\Framework\Actions\StaticPageBuilder; use Hyde\Framework\Features\XmlGenerators\SitemapPage; @@ -29,7 +28,7 @@ public function handle(): int $page = Routes::find(SitemapPage::routeKey())?->getPage(); if ($page === null) { - $this->error($this->getSkipReason()); + $this->error('Cannot generate the sitemap as the feature is not enabled'); return Command::FAILURE; } @@ -40,18 +39,4 @@ public function handle(): int return Command::SUCCESS; } - - /** Explain why the sitemap route is not registered, mirroring the conditions of {@see \Hyde\Facades\Features::hasSitemap()}. */ - protected function getSkipReason(): string - { - if (! Hyde::hasSiteUrl()) { - return 'Cannot generate sitemap without a valid base URL'; - } - - if (! Config::getBool('hyde.generate_sitemap', true)) { - return 'Cannot generate the sitemap as it is disabled in the configuration'; - } - - return 'Cannot generate the sitemap as the SimpleXML extension is not installed'; - } } diff --git a/packages/framework/tests/Feature/Commands/BuildRssFeedCommandTest.php b/packages/framework/tests/Feature/Commands/BuildRssFeedCommandTest.php index f33481bd20a..9015f696290 100644 --- a/packages/framework/tests/Feature/Commands/BuildRssFeedCommandTest.php +++ b/packages/framework/tests/Feature/Commands/BuildRssFeedCommandTest.php @@ -50,7 +50,7 @@ public function testRssFeedIsNotGeneratedWithoutSiteUrl() $this->file('_posts/foo.md'); $this->artisan('build:rss') - ->expectsOutput('Cannot generate an RSS feed without a valid base URL') + ->expectsOutput('Cannot generate the RSS feed as the feature is not enabled') ->assertExitCode(1); $this->assertFileDoesNotExist(Hyde::path('_site/feed.xml')); @@ -63,7 +63,7 @@ public function testRssFeedIsNotGeneratedWhenFeedIsDisabledInConfig() $this->file('_posts/foo.md'); $this->artisan('build:rss') - ->expectsOutput('Cannot generate the RSS feed as it is disabled in the configuration') + ->expectsOutput('Cannot generate the RSS feed as the feature is not enabled') ->assertExitCode(1); $this->assertFileDoesNotExist(Hyde::path('_site/feed.xml')); @@ -74,21 +74,7 @@ public function testRssFeedIsNotGeneratedWhenThereAreNoPosts() $this->withSiteUrl(); $this->artisan('build:rss') - ->expectsOutput('Cannot generate an RSS feed without any Markdown posts') - ->assertExitCode(1); - - $this->assertFileDoesNotExist(Hyde::path('_site/feed.xml')); - } - - public function testSkipReasonFallsBackToMissingSimpleXmlExtensionWhenOtherConditionsAreMet() - { - $this->withSiteUrl(); - $this->file('_posts/foo.md'); - - Hyde::routes()->forget('feed.xml'); - - $this->artisan('build:rss') - ->expectsOutput('Cannot generate the RSS feed as the SimpleXML extension is not installed') + ->expectsOutput('Cannot generate the RSS feed as the feature is not enabled') ->assertExitCode(1); $this->assertFileDoesNotExist(Hyde::path('_site/feed.xml')); diff --git a/packages/framework/tests/Feature/Commands/BuildSitemapCommandTest.php b/packages/framework/tests/Feature/Commands/BuildSitemapCommandTest.php index 734042303cc..f8acb2b7a95 100644 --- a/packages/framework/tests/Feature/Commands/BuildSitemapCommandTest.php +++ b/packages/framework/tests/Feature/Commands/BuildSitemapCommandTest.php @@ -35,7 +35,7 @@ public function testSitemapIsNotGeneratedWhenConditionsAreNotMet() $this->assertFileDoesNotExist(Hyde::path('_site/sitemap.xml')); $this->artisan('build:sitemap') - ->expectsOutput('Cannot generate sitemap without a valid base URL') + ->expectsOutput('Cannot generate the sitemap as the feature is not enabled') ->assertExitCode(1); $this->assertFileDoesNotExist(Hyde::path('_site/sitemap.xml')); @@ -47,20 +47,7 @@ public function testSitemapIsNotGeneratedWhenSitemapGenerationIsDisabledInConfig config(['hyde.generate_sitemap' => false]); $this->artisan('build:sitemap') - ->expectsOutput('Cannot generate the sitemap as it is disabled in the configuration') - ->assertExitCode(1); - - $this->assertFileDoesNotExist(Hyde::path('_site/sitemap.xml')); - } - - public function testSkipReasonFallsBackToMissingSimpleXmlExtensionWhenOtherConditionsAreMet() - { - config(['hyde.url' => 'https://example.com']); - - Hyde::routes()->forget('sitemap.xml'); - - $this->artisan('build:sitemap') - ->expectsOutput('Cannot generate the sitemap as the SimpleXML extension is not installed') + ->expectsOutput('Cannot generate the sitemap as the feature is not enabled') ->assertExitCode(1); $this->assertFileDoesNotExist(Hyde::path('_site/sitemap.xml')); From 163f8909d86fbb7404e59c26f924f9791fa2a71c Mon Sep 17 00:00:00 2001 From: Emma De Silva Date: Mon, 13 Jul 2026 22:20:05 +0200 Subject: [PATCH 061/117] Document the generic command guard message revision Co-Authored-By: Claude Fable 5 --- EPIC_NON_HTML_PAGES.md | 26 +++++++++++++++++--------- HYDEPHP_V3_PLANNING.md | 4 ++-- UPGRADE.md | 4 ++-- 3 files changed, 21 insertions(+), 13 deletions(-) diff --git a/EPIC_NON_HTML_PAGES.md b/EPIC_NON_HTML_PAGES.md index afefa8854ee..59d326c4f41 100644 --- a/EPIC_NON_HTML_PAGES.md +++ b/EPIC_NON_HTML_PAGES.md @@ -459,11 +459,19 @@ Implementation notes, part A (branch `v3/non-html-pages-convert-sitemap`): `hyde.generate_sitemap` disabled, and `build:rss` had no guard at all, emitting an empty feed with zero posts. That silently overriding the user's own configuration or producing a useless file is a trap, not a feature: the commands now fail with - a message stating the specific unmet condition (no base URL, disabled in config, - no posts, missing SimpleXML) when the route is not registered. Because the lookup - is route-first rather than feature-flag-first, a user-defined page under the route - key is still built even when the feature conditions are unmet — the only behavior - the fallback enabled that anyone could plausibly want, preserved without it.)* + a generic "feature is not enabled" error when the route is not registered. Because + the lookup is route-first rather than feature-flag-first, a user-defined page under + the route key is still built even when the feature conditions are unmet — the only + behavior the fallback enabled that anyone could plausibly want, preserved without + it.)* + *(Revised again in a second review pass: the first revision reported the specific + unmet condition — no base URL, disabled in config, no posts, missing SimpleXML — + by re-checking the `Features::hasSitemap()`/`hasRss()` conditions in the command. + That mirror was dropped for a single static message: its final SimpleXML branch + attributed the failure by elimination rather than observation, so any drift in the + mirrored conditions or an extension removing the page would blame SimpleXML on a + system where it is fine, and these commands fail too rarely to justify carrying + duplicated feature logic for a nicer message.)* - `GlobalMetadataBag` verified: the sitemap head link is emitted under the same `Features::hasSitemap()` condition that registers the page — no drift possible. - Realtime compiler needed no changes (PR 2's route-first resolution); a serve test @@ -486,10 +494,10 @@ Implementation notes, part B (branch `v3/non-html-pages-convert-rss-feed`): extensionless name would otherwise regress to `.html`-suffixed output (see the D2 part B qualification). - `build:rss` builds the registered route's page like `build:sitemap`, and fails - with the specific unmet condition when the route is not registered (see the - revised-in-review note in the part A section — the old task's no-guard semantics, - where an explicit invocation emitted an empty feed with zero posts, were - deliberately not preserved). + with the generic "feature is not enabled" error when the route is not registered + (see the revised-in-review notes in the part A section — the old task's no-guard + semantics, where an explicit invocation emitted an empty feed with zero posts, + were deliberately not preserved). - `BuildTaskService` no longer registers any feature-gated tasks; the `Features` facade import went with the last one. The remaining framework tasks (clean/transfer/manifest) are all config-gated. diff --git a/HYDEPHP_V3_PLANNING.md b/HYDEPHP_V3_PLANNING.md index 4b95de85b44..77d93c0e2bf 100644 --- a/HYDEPHP_V3_PLANNING.md +++ b/HYDEPHP_V3_PLANNING.md @@ -50,8 +50,8 @@ Having this document in code lets us know the devlopment state at any given poin - Renamed the static page class property `$fileExtension` to `$sourceExtension`, and the `fileExtension()` and `setFileExtension()` methods to `sourceExtension()` and `setSourceExtension()`. The old name was ambiguous now that page classes also declare an output extension through the new `$outputExtension` property, and the renamed pair makes the source/output distinction explicit. 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). Page discovery fails fast with an actionable exception for registered page classes that still use the old API, instead of silently skipping them during builds. - In-memory page identifiers ending in `.json`, `.txt`, or `.xml` now compile to that path as-is instead of gaining a second `.html` extension. The old double-extension outputs (like `data.json.html`) were almost certainly never intended, so no real sites are expected to be affected. - Redirect source paths declared in `hyde.redirects` ending in `.json`, `.txt`, or `.xml` are now rejected with an exception, since a meta refresh redirect cannot work for files served as non-HTML content. Previously such entries silently produced an unreachable `legacy.json.html` file, so no working configuration is affected. -- 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 register a custom `sitemap.xml` page or rebind `SitemapGenerator` in the container instead. The `build:sitemap` command now compiles the registered page, and fails with an explanatory message (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 register a custom page with the configured feed route key or rebind `RssFeedGenerator` in the container instead. The `build:rss` command now compiles the registered page, and fails with an explanatory message when the feed cannot be generated (no base URL, disabled in the configuration, or no Markdown posts), instead of silently generating an empty feed. A user-defined page registered under the feed route key is still built even when the feature conditions are not met. +- 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 register a custom `sitemap.xml` page or rebind `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 register a custom page with the configured feed route key or rebind `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. A user-defined page registered under the feed route key is still built even when the feature conditions are not met. - Removed `Redirect::create()`, `Redirect::store()`, and the `Redirect` constructor's `showText` argument. Redirects must now be declared in `hyde.redirects`, keeping all generated output inside the kernel-owned build graph. Redirect routes are intrinsically excluded from navigation menus and sitemaps, and always include an accessible fallback link. - Removed the `InMemoryPage` instance macro API. Dynamic contents should now be supplied with a closure, while custom methods and other behavior belong in an `InMemoryPage` subclass. - Removed `InMemoryPage` content-source precedence. Calls that previously supplied both `contents` and `view` must retain only the intended source; positional view calls that used an empty-string contents placeholder must use `null` instead. diff --git a/UPGRADE.md b/UPGRADE.md index c7fb40ce889..340c8def4fb 100644 --- a/UPGRADE.md +++ b/UPGRADE.md @@ -336,8 +336,8 @@ Hyde::kernel()->booting(function ($kernel): void { ``` The `build:sitemap` and `build:rss` commands still work and now compile the registered pages. When the output -cannot be generated, they fail with a message explaining why (no base URL, disabled in the configuration, or — -for the feed — no Markdown posts) instead of generating an empty or unwanted file. `build:sitemap` reports this +cannot be generated (no base URL, disabled in the configuration, or — for the feed — no Markdown posts), they +fail with an error instead of generating an empty or unwanted file. `build:sitemap` reports this failure with exit code 1 instead of 3. If you registered your own page under the route key, the commands build it regardless of these conditions. From 709b2d453e2c0daad5bd5c14c14ebe793b45d0bb Mon Sep 17 00:00:00 2001 From: Emma De Silva Date: Mon, 13 Jul 2026 23:33:49 +0200 Subject: [PATCH 062/117] Update CLAUDE.md --- CLAUDE.md | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/CLAUDE.md b/CLAUDE.md index 7191cb2e81a..f916f76fdf1 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -72,3 +72,19 @@ you are actually trying to do before reaching for a comment. Comments are only f Never write comments that narrate what the next line does, where code came from, or why a change is correct — that belongs in the PR description, not the code. + +## Developer Experience + +HydePHP treats Developer Experience as a design constraint, not a layer of polish added after a feature works. The framework exists to make creating content-focused websites feel simple without taking away the power developers expect from Laravel. Its guiding promise is that users should be able to begin with Markdown and sensible defaults, while retaining the freedom to use Blade, customize the frontend, replace conventions, or extend the build process when their project demands it. + +The default path should therefore be the shortest path. A common task should work without configuration, manual registration, or knowledge of internal architecture. HydePHP favors convention over configuration, automatic discovery, appropriate default layouts, generated navigation, scaffolding commands, and ready-to-use frontend assets. Configuration and extension points are still important, but they should remain optional until the user has a reason to reach for them. A new feature is aligned with HydePHP when its basic use feels obvious and its advanced use remains possible. + +HydePHP also aims to reuse mental models its users already know. APIs should follow Laravel conventions where practical, and features should compose naturally with familiar tools such as collections, facades, Blade components, console commands, configuration files, service providers, and lifecycle callbacks. Naming should describe what an operation does rather than expose how Hyde implements it. Before inventing a new abstraction, an agent should look for the closest existing HydePHP or Laravel pattern and extend that vocabulary consistently. + +Good Developer Experience includes the failure path. HydePHP should validate assumptions early, produce actionable error messages, and avoid letting mistakes silently reach the generated site. Recent work on the asset and data systems reflects this approach through automatic validation, clearer exception handling, syntax checking, and helpers that remove repetitive filesystem work. Commands should explain what they are doing, generated files should be predictable, and errors should tell the developer what needs to change. + +Performance and feedback speed matter as well. Improvements such as realtime compilation, Vite integration, hot module replacement, intelligent caching, and faster document processing are Developer Experience features because they shorten the distance between an edit and a trustworthy result. Agents should avoid unnecessary work in normal builds, preserve deterministic output, and prefer lazy or cached computation where it reduces repeated cost without making behavior harder to understand. + +Finally, a feature is not complete when its implementation compiles. HydePHP requires focused changes, tests that demonstrate the intended behavior, and documentation for changes users can observe. Backward compatibility and the appropriate release branch must also be considered. Tests protect the experience from regression, while documentation confirms that the public API can be explained clearly. When an API is difficult to test or document, that is often evidence that it is also difficult to use. + +An AI coding agent working on HydePHP should evaluate every feature with a simple standard: does this make the common case joyful, preserve control for advanced users, behave like the rest of the Laravel ecosystem, fail helpfully, and remain understandable through tests and documentation? The most Hyde-like implementation is rarely the one with the most options or abstractions. It is the one that removes the most friction while introducing the least surprise. From cc2bcdc1f4e3b934a1a6515132103f2f8e4c00a9 Mon Sep 17 00:00:00 2001 From: Emma De Silva Date: Mon, 13 Jul 2026 22:50:54 +0200 Subject: [PATCH 063/117] Add a generated robots.txt page Registers a RobotsTxtPage in the core extension per the non-HTML pages epic (PR 6): all crawlers allowed by default, Disallow rules from the new hyde.robots.disallow config, and a Sitemap link when the sitemap feature is enabled. The contents come from a container-resolved RobotsTxtGenerator in a new TextGenerators namespace mirroring XmlGenerators, and the page can be disabled with hyde.robots.enabled or replaced by a user-defined page under the same route key. Co-Authored-By: Claude Fable 5 --- config/hyde.php | 24 ++++++++ packages/framework/config/hyde.php | 24 ++++++++ packages/framework/src/Facades/Features.php | 8 +++ .../src/Foundation/HydeCoreExtension.php | 13 +++++ .../TextGenerators/RobotsTxtGenerator.php | 55 +++++++++++++++++++ .../Features/TextGenerators/RobotsTxtPage.php | 39 +++++++++++++ 6 files changed, 163 insertions(+) create mode 100644 packages/framework/src/Framework/Features/TextGenerators/RobotsTxtGenerator.php create mode 100644 packages/framework/src/Framework/Features/TextGenerators/RobotsTxtPage.php diff --git a/config/hyde.php b/config/hyde.php index 0214fd7d1d8..798f7a238ef 100644 --- a/config/hyde.php +++ b/config/hyde.php @@ -134,6 +134,30 @@ 'description' => env('SITE_NAME', 'HydePHP').' RSS Feed', ], + /* + |-------------------------------------------------------------------------- + | Robots.txt Generation + |-------------------------------------------------------------------------- + | + | When enabled, a robots.txt file allowing all crawlers will be generated + | when you compile your static site. A link to your sitemap is included + | when the sitemap feature is enabled. + | + | Paths added to the disallow array are written verbatim as Disallow + | rules for all crawlers, and should therefore start with a slash. + | + */ + + 'robots' => [ + // Should the robots.txt file be generated? + 'enabled' => true, + + // Paths to ask crawlers not to access. + 'disallow' => [ + // '/private', + ], + ], + /* |-------------------------------------------------------------------------- | Source Root Directory diff --git a/packages/framework/config/hyde.php b/packages/framework/config/hyde.php index 0214fd7d1d8..798f7a238ef 100644 --- a/packages/framework/config/hyde.php +++ b/packages/framework/config/hyde.php @@ -134,6 +134,30 @@ 'description' => env('SITE_NAME', 'HydePHP').' RSS Feed', ], + /* + |-------------------------------------------------------------------------- + | Robots.txt Generation + |-------------------------------------------------------------------------- + | + | When enabled, a robots.txt file allowing all crawlers will be generated + | when you compile your static site. A link to your sitemap is included + | when the sitemap feature is enabled. + | + | Paths added to the disallow array are written verbatim as Disallow + | rules for all crawlers, and should therefore start with a slash. + | + */ + + 'robots' => [ + // Should the robots.txt file be generated? + 'enabled' => true, + + // Paths to ask crawlers not to access. + 'disallow' => [ + // '/private', + ], + ], + /* |-------------------------------------------------------------------------- | Source Root Directory diff --git a/packages/framework/src/Facades/Features.php b/packages/framework/src/Facades/Features.php index 4a9a6383eee..d5e353454c7 100644 --- a/packages/framework/src/Facades/Features.php +++ b/packages/framework/src/Facades/Features.php @@ -114,6 +114,14 @@ public static function hasRss(): bool && count(MarkdownPost::files()) > 0; } + /** + * Can a robots.txt file be generated? + */ + public static function hasRobotsTxt(): bool + { + return Config::getBool('hyde.robots.enabled', true); + } + /** * Should documentation search be enabled? */ diff --git a/packages/framework/src/Foundation/HydeCoreExtension.php b/packages/framework/src/Foundation/HydeCoreExtension.php index 7a1933580e9..be2ef639bb8 100644 --- a/packages/framework/src/Foundation/HydeCoreExtension.php +++ b/packages/framework/src/Foundation/HydeCoreExtension.php @@ -21,6 +21,7 @@ use Hyde\Facades\Config; use Hyde\Framework\Features\Documentation\DocumentationSearchPage; use Hyde\Framework\Features\Documentation\DocumentationSearchIndex; +use Hyde\Framework\Features\TextGenerators\RobotsTxtPage; use Hyde\Framework\Features\XmlGenerators\RssFeedPage; use Hyde\Framework\Features\XmlGenerators\SitemapPage; use Hyde\Framework\Features\Documentation\Versioning\DocumentationVersion; @@ -90,6 +91,10 @@ public function discoverPages(PageCollection $collection): void if (Features::hasRss()) { $this->discoverRssFeedPage($collection); } + + if (Features::hasRobotsTxt()) { + $this->discoverRobotsTxtPage($collection); + } } /** Add the generated sitemap page unless the route is user-defined. */ @@ -108,6 +113,14 @@ protected function discoverRssFeedPage(PageCollection $collection): void } } + /** Add the generated robots.txt page unless the route is user-defined. */ + protected function discoverRobotsTxtPage(PageCollection $collection): void + { + if (! $this->hasPageWithRouteKey($collection, RobotsTxtPage::routeKey())) { + $collection->addPage(new RobotsTxtPage()); + } + } + /** Discard documentation source files stored outside the version directories. */ protected function discardUnversionedDocumentationFiles(FileCollection $collection): void { diff --git a/packages/framework/src/Framework/Features/TextGenerators/RobotsTxtGenerator.php b/packages/framework/src/Framework/Features/TextGenerators/RobotsTxtGenerator.php new file mode 100644 index 00000000000..f4c81a336c5 --- /dev/null +++ b/packages/framework/src/Framework/Features/TextGenerators/RobotsTxtGenerator.php @@ -0,0 +1,55 @@ +getLines())."\n"; + } + + /** @return array */ + protected function getLines(): array + { + $lines = array_merge(['User-agent: *'], $this->getRuleLines()); + + if (Features::hasSitemap()) { + $lines = array_merge($lines, ['', 'Sitemap: '.Hyde::url(SitemapPage::routeKey())]); + } + + return $lines; + } + + /** @return array */ + protected function getRuleLines(): array + { + $disallow = Config::getArray('hyde.robots.disallow', []); + + if ($disallow === []) { + return ['Allow: /']; + } + + return array_map(fn (string $path): string => "Disallow: $path", $disallow); + } +} diff --git a/packages/framework/src/Framework/Features/TextGenerators/RobotsTxtPage.php b/packages/framework/src/Framework/Features/TextGenerators/RobotsTxtPage.php new file mode 100644 index 00000000000..62653a35f5c --- /dev/null +++ b/packages/framework/src/Framework/Features/TextGenerators/RobotsTxtPage.php @@ -0,0 +1,39 @@ + ['hidden' => true], + ]); + } + + public function compile(): string + { + return app(RobotsTxtGenerator::class)->generate(); + } + + /** + * Get the route key of the robots.txt file, which for this page is also its output path. + */ + public static function routeKey(): string + { + return 'robots.txt'; + } +} From 199c827e8e94fc1810587b5c3be7beb373b78df3 Mon Sep 17 00:00:00 2001 From: Emma De Silva Date: Mon, 13 Jul 2026 23:01:15 +0200 Subject: [PATCH 064/117] Adapt existing tests to the generated robots.txt page The tests asserting exact page and route collections, route list output, or build manifest contents already disable the sitemap and RSS pages in their setup; they now disable the robots.txt page the same way. Co-Authored-By: Claude Fable 5 --- .../framework/tests/Feature/Commands/RouteListCommandTest.php | 2 +- packages/framework/tests/Feature/PageCollectionTest.php | 2 +- packages/framework/tests/Feature/RouteCollectionTest.php | 2 +- packages/framework/tests/Feature/StaticSiteServiceTest.php | 2 +- packages/framework/tests/Unit/GenerateBuildManifestTest.php | 2 ++ 5 files changed, 6 insertions(+), 4 deletions(-) diff --git a/packages/framework/tests/Feature/Commands/RouteListCommandTest.php b/packages/framework/tests/Feature/Commands/RouteListCommandTest.php index eb80fe6b812..400c4f95991 100644 --- a/packages/framework/tests/Feature/Commands/RouteListCommandTest.php +++ b/packages/framework/tests/Feature/Commands/RouteListCommandTest.php @@ -20,7 +20,7 @@ protected function setUp(): void { parent::setUp(); - config(['hyde.generate_sitemap' => false]); + config(['hyde.generate_sitemap' => false, 'hyde.robots.enabled' => false]); } public function testRouteListCommand() diff --git a/packages/framework/tests/Feature/PageCollectionTest.php b/packages/framework/tests/Feature/PageCollectionTest.php index eb21042990f..e6067073755 100644 --- a/packages/framework/tests/Feature/PageCollectionTest.php +++ b/packages/framework/tests/Feature/PageCollectionTest.php @@ -26,7 +26,7 @@ protected function setUp(): void { parent::setUp(); - config(['hyde.generate_sitemap' => false, 'hyde.rss.enabled' => false]); + config(['hyde.generate_sitemap' => false, 'hyde.rss.enabled' => false, 'hyde.robots.enabled' => false]); } public function testBootMethodCreatesNewPageCollectionAndDiscoversPagesAutomatically() diff --git a/packages/framework/tests/Feature/RouteCollectionTest.php b/packages/framework/tests/Feature/RouteCollectionTest.php index b069f2f5b29..0878c7d3d4e 100644 --- a/packages/framework/tests/Feature/RouteCollectionTest.php +++ b/packages/framework/tests/Feature/RouteCollectionTest.php @@ -26,7 +26,7 @@ protected function setUp(): void { parent::setUp(); - config(['hyde.generate_sitemap' => false, 'hyde.rss.enabled' => false]); + config(['hyde.generate_sitemap' => false, 'hyde.rss.enabled' => false, 'hyde.robots.enabled' => false]); } public function testBootMethodDiscoversAllPages() diff --git a/packages/framework/tests/Feature/StaticSiteServiceTest.php b/packages/framework/tests/Feature/StaticSiteServiceTest.php index a2430623b5e..1efeaa58274 100644 --- a/packages/framework/tests/Feature/StaticSiteServiceTest.php +++ b/packages/framework/tests/Feature/StaticSiteServiceTest.php @@ -145,7 +145,7 @@ public function testAllPageTypesCanBeCompiled() public function testOnlyProgressBarsForTypesWithPagesAreShown() { - config(['hyde.generate_sitemap' => false]); + config(['hyde.generate_sitemap' => false, 'hyde.robots.enabled' => false]); $this->file('_pages/blade.blade.php'); $this->file('_pages/markdown.md'); diff --git a/packages/framework/tests/Unit/GenerateBuildManifestTest.php b/packages/framework/tests/Unit/GenerateBuildManifestTest.php index d8410dcc853..9dac849b21c 100644 --- a/packages/framework/tests/Unit/GenerateBuildManifestTest.php +++ b/packages/framework/tests/Unit/GenerateBuildManifestTest.php @@ -20,6 +20,8 @@ class GenerateBuildManifestTest extends UnitTestCase public function testActionGeneratesBuildManifest() { + self::mockConfig(['hyde.robots.enabled' => false]); + Hyde::pages()->addPage(new DocumentationSearchIndex()); (new GenerateBuildManifest())->handle(); From fb3b7bfac939c725f402cd91d1a9249fd71b6469 Mon Sep 17 00:00:00 2001 From: Emma De Silva Date: Mon, 13 Jul 2026 23:01:15 +0200 Subject: [PATCH 065/117] Test the generated robots.txt page Covers registration through the core extension, the config disable switch, generator output shapes (disallow rules, sitemap line), the container rebind tier, compilation through the real build command, build manifest and route list presence, both user-defined page override paths, and serving through the realtime compiler with the text/plain content type. Co-Authored-By: Claude Fable 5 --- .../Feature/ConfigurableFeaturesTest.php | 11 ++ .../tests/Feature/RobotsTxtGeneratorTest.php | 63 +++++++ .../tests/Feature/RobotsTxtPageTest.php | 164 ++++++++++++++++++ .../tests/RealtimeCompilerTest.php | 18 ++ 4 files changed, 256 insertions(+) create mode 100644 packages/framework/tests/Feature/RobotsTxtGeneratorTest.php create mode 100644 packages/framework/tests/Feature/RobotsTxtPageTest.php diff --git a/packages/framework/tests/Feature/ConfigurableFeaturesTest.php b/packages/framework/tests/Feature/ConfigurableFeaturesTest.php index fb04fe0a978..b1825139b3b 100644 --- a/packages/framework/tests/Feature/ConfigurableFeaturesTest.php +++ b/packages/framework/tests/Feature/ConfigurableFeaturesTest.php @@ -64,6 +64,17 @@ public function testCanGenerateSitemapHelperReturnsFalseIfSitemapsAreDisabledInC $this->assertFalse(Features::hasSitemap()); } + public function testHasRobotsTxtReturnsTrueByDefault() + { + $this->assertTrue(Features::hasRobotsTxt()); + } + + public function testHasRobotsTxtReturnsFalseWhenDisabledInConfig() + { + config(['hyde.robots.enabled' => false]); + $this->assertFalse(Features::hasRobotsTxt()); + } + public function testHasThemeToggleButtonsReturnsTrueWhenDarkmodeEnabledAndConfigTrue() { // Enable dark mode and set hyde.theme_toggle_buttons config option to true diff --git a/packages/framework/tests/Feature/RobotsTxtGeneratorTest.php b/packages/framework/tests/Feature/RobotsTxtGeneratorTest.php new file mode 100644 index 00000000000..0ea97616f98 --- /dev/null +++ b/packages/framework/tests/Feature/RobotsTxtGeneratorTest.php @@ -0,0 +1,63 @@ +withoutSiteUrl(); + + $this->assertSame("User-agent: *\nAllow: /\n", $this->generate()); + } + + public function testGeneratesSitemapLineWhenSitemapFeatureIsEnabled() + { + $this->withSiteUrl(); + + $this->assertSame("User-agent: *\nAllow: /\n\nSitemap: https://example.com/sitemap.xml\n", $this->generate()); + } + + public function testOmitsSitemapLineWhenSitemapIsDisabledInConfig() + { + $this->withSiteUrl(); + config(['hyde.generate_sitemap' => false]); + + $this->assertSame("User-agent: *\nAllow: /\n", $this->generate()); + } + + public function testGeneratesDisallowRulesFromConfig() + { + $this->withoutSiteUrl(); + config(['hyde.robots.disallow' => ['/private', '/admin']]); + + $this->assertSame("User-agent: *\nDisallow: /private\nDisallow: /admin\n", $this->generate()); + } + + public function testDisallowRulesAreWrittenVerbatim() + { + $this->withoutSiteUrl(); + config(['hyde.robots.disallow' => ['/*.pdf$', '']]); + + $this->assertSame("User-agent: *\nDisallow: /*.pdf$\nDisallow: \n", $this->generate()); + } + + public function testGeneratesDisallowRulesAndSitemapLineTogether() + { + $this->withSiteUrl(); + config(['hyde.robots.disallow' => ['/private']]); + + $this->assertSame("User-agent: *\nDisallow: /private\n\nSitemap: https://example.com/sitemap.xml\n", $this->generate()); + } + + protected function generate(): string + { + return (new RobotsTxtGenerator())->generate(); + } +} diff --git a/packages/framework/tests/Feature/RobotsTxtPageTest.php b/packages/framework/tests/Feature/RobotsTxtPageTest.php new file mode 100644 index 00000000000..c30f14d92f2 --- /dev/null +++ b/packages/framework/tests/Feature/RobotsTxtPageTest.php @@ -0,0 +1,164 @@ +assertTrue(Routes::exists('robots.txt')); + + $page = Routes::get('robots.txt')->getPage(); + + $this->assertInstanceOf(RobotsTxtPage::class, $page); + $this->assertSame('robots.txt', $page->getOutputPath()); + $this->assertSame('robots.txt', $page->getRouteKey()); + } + + public function testRobotsTxtPageIsRegisteredWithoutSiteUrl() + { + $this->withoutSiteUrl(); + + $this->assertTrue(Routes::exists('robots.txt')); + } + + public function testRobotsTxtPageIsNotRegisteredWhenDisabledInConfig() + { + config(['hyde.robots.enabled' => false]); + + $this->assertFalse(Routes::exists('robots.txt')); + } + + public function testRobotsTxtPageIsHiddenFromNavigationAndExcludedFromTheSitemap() + { + $page = new RobotsTxtPage(); + + $this->assertFalse($page->showInNavigation()); + $this->assertFalse($page->showInSitemap()); + } + + public function testRobotsTxtPageCompilesUsingTheRobotsTxtGenerator() + { + $this->withoutSiteUrl(); + + $this->assertSame("User-agent: *\nAllow: /\n", (new RobotsTxtPage())->compile()); + } + + public function testRobotsTxtGeneratorCanBeSwappedThroughTheServiceContainer() + { + app()->bind(RobotsTxtGenerator::class, fn (): RobotsTxtGenerator => new class extends RobotsTxtGenerator + { + public function generate(): string + { + return 'custom generator output'; + } + }); + + $this->assertSame('custom generator output', Routes::get('robots.txt')->getPage()->compile()); + } + + public function testBuildCommandCompilesRobotsTxtPageAsDynamicPage() + { + $this->withoutSiteUrl(); + + $this->artisan('build') + ->expectsOutput('Creating Dynamic Pages...') + ->assertExitCode(0); + + $this->assertSame("User-agent: *\nAllow: /\n", file_get_contents(Hyde::path('_site/robots.txt'))); + } + + public function testBuiltRobotsTxtLinksToSitemapAndIsExcludedFromIt() + { + $this->withSiteUrl(); + + $this->artisan('build')->assertExitCode(0); + + $this->assertStringContainsString('Sitemap: https://example.com/sitemap.xml', file_get_contents(Hyde::path('_site/robots.txt'))); + $this->assertStringNotContainsString('robots.txt', file_get_contents(Hyde::path('_site/sitemap.xml'))); + } + + public function testRobotsTxtPageIsIncludedInTheBuildManifest() + { + $this->artisan('build')->assertExitCode(0); + + $manifest = json_decode(file_get_contents(Hyde::path('app/storage/framework/cache/build-manifest.json')), true); + + $this->assertArrayHasKey('robots.txt', $manifest['pages']); + $this->assertSame('robots.txt', $manifest['pages']['robots.txt']['output_path']); + } + + public function testRobotsTxtRouteIsIncludedInTheRouteList() + { + $this->artisan('route:list') + ->expectsOutputToContain('robots.txt') + ->assertExitCode(0); + } + + public function testUserPageRegisteredInBootingCallbackSuppressesTheGeneratedRobotsTxtPage() + { + Hyde::kernel()->booting(function (HydeKernel $kernel): void { + $kernel->pages()->addPage(new InMemoryPage('robots.txt', contents: 'user defined robots')); + }); + + $page = Routes::get('robots.txt')->getPage(); + + $this->assertNotInstanceOf(RobotsTxtPage::class, $page); + $this->assertSame(1, Hyde::pages()->filter(fn ($page) => $page->getRouteKey() === 'robots.txt')->count()); + + $this->artisan('build')->assertExitCode(0); + + $this->assertSame('user defined robots', file_get_contents(Hyde::path('_site/robots.txt'))); + } + + public function testUserPageRegisteredThroughExtensionSuppressesTheGeneratedRobotsTxtPage() + { + Hyde::kernel()->registerExtension(RobotsTxtPageTestExtension::class); + + $page = Routes::get('robots.txt')->getPage(); + + $this->assertNotInstanceOf(RobotsTxtPage::class, $page); + $this->assertSame(1, Hyde::pages()->filter(fn ($page) => $page->getRouteKey() === 'robots.txt')->count()); + + $this->artisan('build')->assertExitCode(0); + + $this->assertSame('extension defined robots', file_get_contents(Hyde::path('_site/robots.txt'))); + } +} + +class RobotsTxtPageTestExtension extends HydeExtension +{ + public function discoverPages(PageCollection $collection): void + { + $collection->addPage(new InMemoryPage('robots.txt', contents: 'extension defined robots')); + } +} diff --git a/packages/realtime-compiler/tests/RealtimeCompilerTest.php b/packages/realtime-compiler/tests/RealtimeCompilerTest.php index 3c717f64868..b214a846067 100644 --- a/packages/realtime-compiler/tests/RealtimeCompilerTest.php +++ b/packages/realtime-compiler/tests/RealtimeCompilerTest.php @@ -395,6 +395,24 @@ public function testRssFeedRouteIsServedWithXmlContentType() } } + public function testRobotsTxtRouteIsServedWithPlainTextContentType() + { + $this->mockCompilerRoute('robots.txt'); + + $kernel = new HttpKernel(); + $response = $kernel->handle(new Request()); + + $this->assertInstanceOf(Response::class, $response); + $this->assertNotInstanceOf(HtmlResponse::class, $response); + $this->assertSame(200, $response->statusCode); + $this->assertSame('OK', $response->statusMessage); + + $headers = $this->getResponseHeaders($response); + $this->assertSame('text/plain', $headers['Content-Type']); + + $this->assertSame("User-agent: *\nAllow: /\n\nSitemap: http://localhost:8080/sitemap.xml\n", $response->body); + } + public function testGetContentTypeReturnsApplicationJsonForJsonOutputPath() { $page = $this->makePageWithOutputPath('foo.json'); From 64ebea709aaedf7c049f2b4aad100709c8d3a7a0 Mon Sep 17 00:00:00 2001 From: Emma De Silva Date: Mon, 13 Jul 2026 23:02:12 +0200 Subject: [PATCH 066/117] Add release notes and upgrade guidance for the generated robots.txt Co-Authored-By: Claude Fable 5 --- HYDEPHP_V3_PLANNING.md | 1 + UPGRADE.md | 12 +++++++++++- 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/HYDEPHP_V3_PLANNING.md b/HYDEPHP_V3_PLANNING.md index 77d93c0e2bf..c9b5eadd122 100644 --- a/HYDEPHP_V3_PLANNING.md +++ b/HYDEPHP_V3_PLANNING.md @@ -27,6 +27,7 @@ Having this document in code lets us know the devlopment state at any given poin - Pages can now compile to non-HTML output files. Page classes declare their output file extension through the new static `$outputExtension` property (defaulting to `.html`), and in-memory page identifiers can declare a `.json`, `.txt`, or `.xml` extension directly, so `InMemoryPage::make('robots.txt', contents: ...)` compiles to `_site/robots.txt` through the standard site build. Only the HTML extension is implicit in route keys: pages compiled to non-HTML files keep their extension in the route key, formalizing the convention already used by the documentation search index. - Pages can now control their own sitemap inclusion. Set `sitemap: false` in a page's front matter to exclude it from the generated `sitemap.xml`, or override the new `HydePage::showInSitemap()` method in custom page classes. Pages compiled to non-HTML output files (like `robots.txt`) are excluded by default, and `sitemap: true` front matter opts such a page back in. - The sitemap and RSS feed are now first-class pages instead of post-build side effects: when the respective feature is enabled, `sitemap.xml` and the RSS feed (`feed.xml`, or the configured `hyde.rss.filename`) are registered as routes, so they are served by `hyde serve`, listed in `route:list`, included in the build manifest, and compiled through the standard site build. The output can be customized by rebinding the `SitemapGenerator` or `RssFeedGenerator` class in the service container, and registering a user-defined page with the same route key (from a service provider, booting callback, or extension) replaces the generated page entirely. +- Hyde now generates a `robots.txt` file for the site out of the box. The default output allows all crawlers, and links to the sitemap when that feature is enabled. Paths listed in the new `hyde.robots.disallow` configuration array are written as `Disallow` rules, and the file can be disabled entirely with `hyde.robots.enabled`. The page is wired like the sitemap and RSS feed: it is a real route (served by `hyde serve`, listed in `route:list`, included in the build manifest), the output can be customized by rebinding the `RobotsTxtGenerator` class in the service container, and a user-defined `robots.txt` page replaces the generated one entirely. ### Feature Changes diff --git a/UPGRADE.md b/UPGRADE.md index 340c8def4fb..75b3daa4693 100644 --- a/UPGRADE.md +++ b/UPGRADE.md @@ -341,7 +341,16 @@ fail with an error instead of generating an empty or unwanted file. `build:sitem failure with exit code 1 instead of 3. If you registered your own page under the route key, the commands build it regardless of these conditions. -## Step 7: Rename Page File Extension References +## Step 7: Review the New Generated robots.txt + +Hyde now generates a `robots.txt` file by default, allowing all crawlers and linking to the sitemap when that +feature is enabled. Most sites want this and need no changes. If you already publish a `robots.txt` through your +own tooling — a deploy step or web server configuration that would conflict with the generated file — either +disable the feature with `hyde.robots.enabled => false`, or move the contents into Hyde by registering your own +`robots.txt` page (which replaces the generated one, using the same registration pattern as the sitemap example +above). Crawl rules can be added to the `hyde.robots.disallow` configuration array without any custom code. + +## Step 8: Rename Page File Extension References The static page class property `$fileExtension` has been renamed to `$sourceExtension`, along with the `fileExtension()` and `setFileExtension()` methods, which are now `sourceExtension()` and `setSourceExtension()`. @@ -392,6 +401,7 @@ Use this checklist to track your upgrade progress: - [ ] Moved `InMemoryPage` `compile` macro callbacks into the contents argument and replaced other macros with subclass methods - [ ] Updated `InMemoryPage` calls to supply only one of `contents` and `view` - [ ] Replaced any references to the removed `GenerateSitemap` and `GenerateRssFeed` build tasks with a generator container rebind or a user-defined page +- [ ] Confirmed the new generated `robots.txt` does not conflict with an existing one, or disabled it with `hyde.robots.enabled` - [ ] Renamed `$fileExtension`, `fileExtension()`, and `setFileExtension()` to `$sourceExtension`, `sourceExtension()`, and `setSourceExtension()` in custom page classes and call sites ## Troubleshooting From 70b546e0edf2509fd3fbb036cb0f22d783cbfd19 Mon Sep 17 00:00:00 2001 From: Emma De Silva Date: Mon, 13 Jul 2026 23:02:53 +0200 Subject: [PATCH 067/117] Mark PR 6 as implemented in the non-HTML pages epic Co-Authored-By: Claude Fable 5 --- EPIC_NON_HTML_PAGES.md | 39 ++++++++++++++++++++++++++++++++++++++- 1 file changed, 38 insertions(+), 1 deletion(-) diff --git a/EPIC_NON_HTML_PAGES.md b/EPIC_NON_HTML_PAGES.md index 59d326c4f41..7a60615ebad 100644 --- a/EPIC_NON_HTML_PAGES.md +++ b/EPIC_NON_HTML_PAGES.md @@ -252,6 +252,7 @@ container → fully custom page in code. > one under the same collection key (`addPage()` keys by source path). Both are > asserted through the real `build` command output. The robots.txt equivalent remains > mandatory for PR 6. *(Part B: both paths verified the same way for the feed page.)* +> *(PR 6: both paths verified the same way for the robots.txt page.)* ### D6: No built-in `TextPage` or `.txt` autodiscovery @@ -502,7 +503,7 @@ Implementation notes, part B (branch `v3/non-html-pages-convert-rss-feed`): facade import went with the last one. The remaining framework tasks (clean/transfer/manifest) are all config-gated. -### PR 6 — Generated `robots.txt` +### PR 6 — Generated `robots.txt` ✅ Implemented Goal: sensible robots.txt out of the box, zero config. @@ -512,6 +513,42 @@ Goal: sensible robots.txt out of the box, zero config. precedence per D5 (an explicitly registered `robots.txt` page wins). - Depends on PRs 1, 2, 5 patterns. +Implementation notes (branch `v3/non-html-pages-robots`): + +- `RobotsTxtPage extends InMemoryPage` mirrors `SitemapPage`/`RssFeedPage` throughout: + thin subclass, container-resolved generator in `compile()` (rebind verified by test), + registered in `HydeCoreExtension::discoverPages()` with the D5 skip check, hidden + from navigation, D3-excluded from the sitemap via the non-HTML default, and both + user override paths (booting callback and extension) verified end-to-end through + the real `build` command per the D5 mandate. +- The page and its `RobotsTxtGenerator` live in a new + `Hyde\Framework\Features\TextGenerators` namespace mirroring `XmlGenerators` + (and likewise outside `Hyde\Pages`, exempting the page from the discovered-page + unit test contract). PR 7's llms.txt generator and page should land there too — + as `LlmsTxtGenerator`/`LlmsTxtPage` for symmetry, superseding the epic's earlier + `GeneratesLlmsTxt` working name. +- Feature gate: `Features::hasRobotsTxt()` reads only `hyde.robots.enabled` + (default `true`). Unlike `hasSitemap()`/`hasRss()` there is no site URL + requirement — robots.txt directives are relative, and the one absolute URL (the + `Sitemap:` line) is gated separately inside the generator by `Features::hasSitemap()`, + the same condition that registers the sitemap page and emits its head link + (the `GlobalMetadataBag` no-drift precedent). Consequence: the page registers + unconditionally on default config, so it appears in zero-config builds — several + existing tests asserting exact collections gained a `hyde.robots.enabled => false` + in their setup, alongside their existing sitemap/RSS switches. +- Generator output: `User-agent: *`, then verbatim `Disallow:` lines from the + `hyde.robots.disallow` config array, or `Allow: /` when there are none (a group + needs at least one rule; an unconditional `Allow: /` next to disallow rules would + be noise). Rules are deliberately not normalized (no leading-slash fixup): + normalization would guess intent and break valid values like wildcard patterns or + the empty string (a valid "allow everything" rule); the config stub documents the + leading-slash convention instead. +- `robots.txt` is within the D2 allowlist, consistent with the PR 5 confirmation + that first-party generated files do not need option (b). +- No `build:robots` command: the sitemap/RSS commands exist only as carry-overs of + the removed post-build tasks; robots.txt never had one, and the standard build + and realtime compiler (serve test asserts `text/plain`) cover the lifecycle. + ### PR 7 — Generated `llms.txt` Goal: best-in-class llms.txt support — no other SSG generates this well out of the box. From 0a82afb8850e2a325976f28544cdd8120e639e3b Mon Sep 17 00:00:00 2001 From: Emma De Silva Date: Mon, 13 Jul 2026 23:30:30 +0200 Subject: [PATCH 068/117] Validate that configured robots.txt disallow rules are strings Each hyde.robots.disallow entry now fails fast with an InvalidConfigurationException naming the config key and offending index when it is not a string, instead of surfacing as a PHP-level type coercion or failure inside the generation callback. Values themselves stay verbatim per the epic's decision: no trimming, slash prepending, or empty-string removal. Co-Authored-By: Claude Fable 5 --- .../TextGenerators/RobotsTxtGenerator.php | 31 ++++++++++++++----- .../tests/Feature/RobotsTxtGeneratorTest.php | 21 +++++++++++++ 2 files changed, 45 insertions(+), 7 deletions(-) diff --git a/packages/framework/src/Framework/Features/TextGenerators/RobotsTxtGenerator.php b/packages/framework/src/Framework/Features/TextGenerators/RobotsTxtGenerator.php index f4c81a336c5..fd73dcb512f 100644 --- a/packages/framework/src/Framework/Features/TextGenerators/RobotsTxtGenerator.php +++ b/packages/framework/src/Framework/Features/TextGenerators/RobotsTxtGenerator.php @@ -7,18 +7,22 @@ use Hyde\Hyde; use Hyde\Facades\Config; use Hyde\Facades\Features; +use Hyde\Framework\Exceptions\InvalidConfigurationException; use Hyde\Framework\Features\XmlGenerators\SitemapPage; -use function array_map; use function array_merge; +use function get_debug_type; use function implode; +use function is_string; +use function sprintf; /** * Generates the contents for the robots.txt file. * - * All crawlers are allowed by default, and paths configured in `hyde.robots.disallow` - * are written verbatim as Disallow rules. A link to the sitemap is included when - * the sitemap feature is enabled. + * All crawlers are allowed by default, and each value in `hyde.robots.disallow` + * is written verbatim as a Disallow rule, so wildcard patterns and the empty + * rule are supported. A link to the sitemap is included when the sitemap + * feature is enabled. * * @see \Hyde\Framework\Features\TextGenerators\RobotsTxtPage */ @@ -44,12 +48,25 @@ protected function getLines(): array /** @return array */ protected function getRuleLines(): array { - $disallow = Config::getArray('hyde.robots.disallow', []); + $rules = Config::getArray('hyde.robots.disallow', []); - if ($disallow === []) { + if ($rules === []) { return ['Allow: /']; } - return array_map(fn (string $path): string => "Disallow: $path", $disallow); + $lines = []; + + foreach ($rules as $index => $rule) { + if (! is_string($rule)) { + throw new InvalidConfigurationException(sprintf( + 'Invalid `hyde.robots.disallow` entry at index [%s]: each Disallow rule must be a string, %s given.', + $index, get_debug_type($rule) + ), 'hyde', 'disallow'); + } + + $lines[] = "Disallow: $rule"; + } + + return $lines; } } diff --git a/packages/framework/tests/Feature/RobotsTxtGeneratorTest.php b/packages/framework/tests/Feature/RobotsTxtGeneratorTest.php index 0ea97616f98..2fb62ab68b9 100644 --- a/packages/framework/tests/Feature/RobotsTxtGeneratorTest.php +++ b/packages/framework/tests/Feature/RobotsTxtGeneratorTest.php @@ -5,6 +5,7 @@ namespace Hyde\Framework\Testing\Feature; use Hyde\Testing\TestCase; +use Hyde\Framework\Exceptions\InvalidConfigurationException; use Hyde\Framework\Features\TextGenerators\RobotsTxtGenerator; #[\PHPUnit\Framework\Attributes\CoversClass(\Hyde\Framework\Features\TextGenerators\RobotsTxtGenerator::class)] @@ -48,6 +49,26 @@ public function testDisallowRulesAreWrittenVerbatim() $this->assertSame("User-agent: *\nDisallow: /*.pdf$\nDisallow: \n", $this->generate()); } + public function testNonStringDisallowRuleFailsWithConfigurationException() + { + config(['hyde.robots.disallow' => ['/private', 123]]); + + $this->expectException(InvalidConfigurationException::class); + $this->expectExceptionMessage('Invalid `hyde.robots.disallow` entry at index [1]: each Disallow rule must be a string, int given.'); + + $this->generate(); + } + + public function testNonStringDisallowRuleExceptionIdentifiesStringKeys() + { + config(['hyde.robots.disallow' => ['foo' => null]]); + + $this->expectException(InvalidConfigurationException::class); + $this->expectExceptionMessage('Invalid `hyde.robots.disallow` entry at index [foo]: each Disallow rule must be a string, null given.'); + + $this->generate(); + } + public function testGeneratesDisallowRulesAndSitemapLineTogether() { $this->withSiteUrl(); From 63bf75dafe13062c840919e8c704ac9aa049d76c Mon Sep 17 00:00:00 2001 From: Emma De Silva Date: Mon, 13 Jul 2026 23:30:30 +0200 Subject: [PATCH 069/117] Document robots.txt disallow entries as rule values, not paths The config stubs, generator docblock, and release note now describe hyde.robots.disallow entries as verbatim Disallow rule values with a wildcard pattern example, so the supported contract is discoverable from the config file alone. Also drops a narrating comment from the core extension and records the string-only validation contract in the epic. Co-Authored-By: Claude Fable 5 --- EPIC_NON_HTML_PAGES.md | 12 +++++++++--- HYDEPHP_V3_PLANNING.md | 2 +- config/hyde.php | 7 ++++--- packages/framework/config/hyde.php | 7 ++++--- .../framework/src/Foundation/HydeCoreExtension.php | 1 - 5 files changed, 18 insertions(+), 11 deletions(-) diff --git a/EPIC_NON_HTML_PAGES.md b/EPIC_NON_HTML_PAGES.md index 7a60615ebad..f4ac6246b9a 100644 --- a/EPIC_NON_HTML_PAGES.md +++ b/EPIC_NON_HTML_PAGES.md @@ -539,10 +539,16 @@ Implementation notes (branch `v3/non-html-pages-robots`): - Generator output: `User-agent: *`, then verbatim `Disallow:` lines from the `hyde.robots.disallow` config array, or `Allow: /` when there are none (a group needs at least one rule; an unconditional `Allow: /` next to disallow rules would - be noise). Rules are deliberately not normalized (no leading-slash fixup): + be noise). The config entries are *rule values*, not filesystem paths — named and + documented as such in the config stubs and generator — and are deliberately not + normalized (no leading-slash fixup, trimming, or empty-string removal): normalization would guess intent and break valid values like wildcard patterns or - the empty string (a valid "allow everything" rule); the config stub documents the - leading-slash convention instead. + the empty string (a valid "allow everything" rule). The verbatim contract is + string-only, validated per entry: a non-string value throws an + `InvalidConfigurationException` naming `hyde.robots.disallow` and the offending + index, instead of surfacing as a PHP-level type error at build time. Later + generated text pages copying this pattern (llms.txt) should keep both halves — + verbatim strings, explicit validation. - `robots.txt` is within the D2 allowlist, consistent with the PR 5 confirmation that first-party generated files do not need option (b). - No `build:robots` command: the sitemap/RSS commands exist only as carry-overs of diff --git a/HYDEPHP_V3_PLANNING.md b/HYDEPHP_V3_PLANNING.md index c9b5eadd122..823cc264ac2 100644 --- a/HYDEPHP_V3_PLANNING.md +++ b/HYDEPHP_V3_PLANNING.md @@ -27,7 +27,7 @@ Having this document in code lets us know the devlopment state at any given poin - Pages can now compile to non-HTML output files. Page classes declare their output file extension through the new static `$outputExtension` property (defaulting to `.html`), and in-memory page identifiers can declare a `.json`, `.txt`, or `.xml` extension directly, so `InMemoryPage::make('robots.txt', contents: ...)` compiles to `_site/robots.txt` through the standard site build. Only the HTML extension is implicit in route keys: pages compiled to non-HTML files keep their extension in the route key, formalizing the convention already used by the documentation search index. - Pages can now control their own sitemap inclusion. Set `sitemap: false` in a page's front matter to exclude it from the generated `sitemap.xml`, or override the new `HydePage::showInSitemap()` method in custom page classes. Pages compiled to non-HTML output files (like `robots.txt`) are excluded by default, and `sitemap: true` front matter opts such a page back in. - The sitemap and RSS feed are now first-class pages instead of post-build side effects: when the respective feature is enabled, `sitemap.xml` and the RSS feed (`feed.xml`, or the configured `hyde.rss.filename`) are registered as routes, so they are served by `hyde serve`, listed in `route:list`, included in the build manifest, and compiled through the standard site build. The output can be customized by rebinding the `SitemapGenerator` or `RssFeedGenerator` class in the service container, and registering a user-defined page with the same route key (from a service provider, booting callback, or extension) replaces the generated page entirely. -- Hyde now generates a `robots.txt` file for the site out of the box. The default output allows all crawlers, and links to the sitemap when that feature is enabled. Paths listed in the new `hyde.robots.disallow` configuration array are written as `Disallow` rules, and the file can be disabled entirely with `hyde.robots.enabled`. The page is wired like the sitemap and RSS feed: it is a real route (served by `hyde serve`, listed in `route:list`, included in the build manifest), the output can be customized by rebinding the `RobotsTxtGenerator` class in the service container, and a user-defined `robots.txt` page replaces the generated one entirely. +- Hyde now generates a `robots.txt` file for the site out of the box. The default output allows all crawlers, and links to the sitemap when that feature is enabled. Rule values listed in the new `hyde.robots.disallow` configuration array are written verbatim as `Disallow` rules (so wildcard patterns are supported), and the file can be disabled entirely with `hyde.robots.enabled`. The page is wired like the sitemap and RSS feed: it is a real route (served by `hyde serve`, listed in `route:list`, included in the build manifest), the output can be customized by rebinding the `RobotsTxtGenerator` class in the service container, and a user-defined `robots.txt` page replaces the generated one entirely. ### Feature Changes diff --git a/config/hyde.php b/config/hyde.php index 798f7a238ef..2037816f2f3 100644 --- a/config/hyde.php +++ b/config/hyde.php @@ -143,8 +143,8 @@ | when you compile your static site. A link to your sitemap is included | when the sitemap feature is enabled. | - | Paths added to the disallow array are written verbatim as Disallow - | rules for all crawlers, and should therefore start with a slash. + | Values added to the disallow array are written verbatim as Disallow + | rule values for all crawlers, so wildcard patterns are supported. | */ @@ -152,9 +152,10 @@ // Should the robots.txt file be generated? 'enabled' => true, - // Paths to ask crawlers not to access. + // Disallow rule values asking crawlers not to access matching paths. 'disallow' => [ // '/private', + // '/*.pdf$', ], ], diff --git a/packages/framework/config/hyde.php b/packages/framework/config/hyde.php index 798f7a238ef..2037816f2f3 100644 --- a/packages/framework/config/hyde.php +++ b/packages/framework/config/hyde.php @@ -143,8 +143,8 @@ | when you compile your static site. A link to your sitemap is included | when the sitemap feature is enabled. | - | Paths added to the disallow array are written verbatim as Disallow - | rules for all crawlers, and should therefore start with a slash. + | Values added to the disallow array are written verbatim as Disallow + | rule values for all crawlers, so wildcard patterns are supported. | */ @@ -152,9 +152,10 @@ // Should the robots.txt file be generated? 'enabled' => true, - // Paths to ask crawlers not to access. + // Disallow rule values asking crawlers not to access matching paths. 'disallow' => [ // '/private', + // '/*.pdf$', ], ], diff --git a/packages/framework/src/Foundation/HydeCoreExtension.php b/packages/framework/src/Foundation/HydeCoreExtension.php index be2ef639bb8..7425d5cac8c 100644 --- a/packages/framework/src/Foundation/HydeCoreExtension.php +++ b/packages/framework/src/Foundation/HydeCoreExtension.php @@ -113,7 +113,6 @@ protected function discoverRssFeedPage(PageCollection $collection): void } } - /** Add the generated robots.txt page unless the route is user-defined. */ protected function discoverRobotsTxtPage(PageCollection $collection): void { if (! $this->hasPageWithRouteKey($collection, RobotsTxtPage::routeKey())) { From 40dc81362824b2dbbe9bbdc0017bf0b930dab949 Mon Sep 17 00:00:00 2001 From: Emma De Silva Date: Mon, 13 Jul 2026 23:59:47 +0200 Subject: [PATCH 070/117] Create LLMS_TXT_RESEARCH.md --- LLMS_TXT_RESEARCH.md | 315 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 315 insertions(+) create mode 100644 LLMS_TXT_RESEARCH.md diff --git a/LLMS_TXT_RESEARCH.md b/LLMS_TXT_RESEARCH.md new file mode 100644 index 00000000000..67ace780f16 --- /dev/null +++ b/LLMS_TXT_RESEARCH.md @@ -0,0 +1,315 @@ +# llms.txt and HydePHP + +## Executive summary + +`llms.txt` is an emerging, non-IETF proposal for publishing a small, curated, Markdown file at `/llms.txt` so language models and AI agents can find the most important material on a site without trawling full HTML navigation, scripts, and boilerplate. The proposal was published by Jeremy Howard on 3 September 2024 and is maintained publicly through the AnswerDotAI GitHub repository and llmstxt.org, with the authors explicitly inviting community input rather than claiming formal standards status. citeturn5view0turn6view0turn7view0 + +For a HydePHP site, `llms.txt` is relevant because Hyde sites are often documentation-heavy and static by design: exactly the kind of content where a concise, plain-text, agent-friendly index is useful. The uploaded HydePHP v3 planning document is especially relevant: it says there is currently “no easy way” to add plain-text files like `robots.txt` or `llms.txt`, and it proposes first-class non-HTML pages plus a generated `llms.txt` feature in the v3 branch. That document also shows Hyde’s direction of travel: route-native non-HTML output, generator-backed pages, and user override paths. fileciteturn0file0L11-L18 fileciteturn0file0L29-L31 fileciteturn0file0L515-L529 + +The practical conclusion is straightforward. For today’s typical static Hyde deployment, the safest approach is a build-time generated root file, ideally from site metadata and per-page front matter. For a future Hyde v3-style implementation, a first-class `llms.txt` page is cleaner because it participates in routing, build manifests, local serve, and extension points. A fully dynamic endpoint is possible, but it is a poor fit for “typical static hosting” unless you deliberately add an edge/serverless layer. fileciteturn0file0L5-L7 fileciteturn0file0L48-L55 + +`llms.txt` should not be confused with a control plane. It does **not** replace `robots.txt`, access control, authentication, or contractual/licensing terms. The llmstxt.org proposal frames it as an inference-time aid, while `robots.txt` remains an advisory crawler-access mechanism and `security.txt` remains a separate RFC-backed disclosure-contact format. If you care about blocking or monetising AI crawlers, you still need crawler controls, logs, and enforcement outside `llms.txt`. citeturn7view0turn12view0turn13view0turn20view0 + +## What llms.txt is and where it comes from + +The official llmstxt.org proposal describes `llms.txt` as a Markdown file placed at `/llms.txt` to “provide information to help LLMs use a website at inference time”. Its rationale is that LLMs increasingly rely on website information, but full websites are often too large and noisy for context windows, whereas a single concise, curated file can point them to the right materials. citeturn5view0turn7view0 + +The primary sources worth bookmarking are the llmstxt.org proposal, the public AnswerDotAI GitHub repository, the `llms-txt` parser/CLI documentation, and the HydePHP planning note you supplied. The llmstxt.org page names Jeremy Howard as author and 3 September 2024 as publication date, while the site’s “Next steps” section says the specification is open for community input and points to the GitHub repository and Discord for discussion. citeturn5view0turn7view0turn6view0 + +Two clarifications matter: + +First, `llms.txt` is a **proposal** and an emerging convention, not a formal web standard. The proposal language is explicit, and there is no RFC or W3C Recommendation defining it. By contrast, `robots.txt` is standardised in RFC 9309 and `security.txt` is defined in RFC 9116. citeturn5view0turn12view0turn25view0 + +Second, the proposal is intentionally narrow. It does not prescribe how an agent must process the file; it says processing depends on the application. That means adoption can be useful even before universal support exists, but it also means interoperability is partly conventional rather than guaranteed. citeturn6view0turn7view0 + +For HydePHP specifically, the uploaded v3 draft is unusually aligned with the proposal. It explicitly calls out `robots.txt` and `llms.txt` as target outputs, says non-HTML pages should become first-class pages rather than post-build side effects, and sketches a generated `llms.txt` action with route links, page titles, and descriptions/abstracts. It also emphasises that the default enable/disable decision should be deliberate because some users will have privacy or OPSEC concerns. fileciteturn0file0L5-L7 fileciteturn0file0L29-L31 fileciteturn0file0L515-L529 + +## Format, syntax, and how it differs from robots.txt and security.txt + +The llmstxt.org format is Markdown-based. The proposal says a conforming file is normally located at `/llms.txt` and contains, in order: an optional BOM, a single H1 with the project/site name, a blockquote summary, optional free-form non-heading detail sections, and then zero or more H2 sections containing lists of links. Each list item contains a required Markdown link and may optionally add a colon and short description. The only required element is the H1. An H2 section named `Optional` has special semantics: agents may skip it when they need a shorter context. citeturn7view0 + +The proposal also recommends, where useful, that pages expose clean Markdown versions by appending `.md` to the page URL, because Markdown is easier for models to consume than HTML. This is important for documentation sites, but it is a recommendation, not a hard dependency. In practice, implementations vary: Cloudflare heavily uses Markdown-addressable pages and even tells agents to prefer the Markdown version or `Accept: text/markdown`, whereas Vercel’s `llms.txt` points at ordinary documentation URLs and separately advertises `llms-full.txt`. citeturn6view0turn20view0turn20view1turn15view0 + +The following comparison summarises the practical differences. It is derived from the llmstxt.org proposal, RFC 9309, RFC 9116, and Google’s robots guidance. citeturn7view0turn12view0turn13view0turn25view0turn13view2 + +| File | Primary purpose | Status | Typical location | Structure | Enforcement | +|---|---|---|---|---|---| +| `llms.txt` | Curated guidance and content discovery for LLMs/agents | Proposal / emerging convention | `/llms.txt` | Markdown with H1, blockquote, H2 link sections | None by itself | +| `robots.txt` | Advisory crawler access rules | IETF Proposed Standard, RFC 9309 | `/robots.txt` | Line-oriented rules such as `User-agent`, `Allow`, `Disallow` | Voluntary crawler compliance | +| `security.txt` | Vulnerability disclosure contacts and policy | IETF RFC 9116 informational RFC | `/.well-known/security.txt` preferred; root allowed for legacy compatibility | Field/value records such as `Contact`, `Expires`, `Canonical` | Informational, but formally specified | + +A few practical consequences flow from this comparison. + +`robots.txt` is about crawler access, not explanation. Google says it tells search engine crawlers which URLs they can access, mainly to manage crawl load, and also warns that it is **not** a mechanism for keeping a page out of Google by itself. RFC 9309 likewise says the rules are not a form of access authorisation. citeturn13view0turn13view1turn12view0 + +`security.txt` is about security disclosure contacts, not AI guidance. RFC 9116 requires a machine-parsable text file, mandates an `Expires` field, recommends HTTPS, defines specific fields such as `Contact`, `Canonical`, and `Preferred-Languages`, and assigns a well-known URI. citeturn13view2turn13view3turn13view4turn25view3turn25view4 + +`llms.txt` is closer to a curated documentation index than to either control file. It is unusual in using Markdown instead of a classic structured format because the proposal expects the file itself to be read directly by language models and agents. citeturn7view0 + +An illustrative `llms.txt` for a Hyde site might look like this: + +```txt +# Example HydePHP Site + +> Official documentation and articles for Example HydePHP Site. Public content only. Prefer the Docs and Reference sections before browsing the wider site. + +This file is a curated guide for language models and AI agents. Link descriptions are authoritative summaries maintained by the site team. + +## Docs +- [Getting started](https://example.com/docs/getting-started.md): Installation, local development, and first build. +- [Content authoring](https://example.com/docs/authoring.md): Markdown, front matter, Blade, and assets. +- [Deployment](https://example.com/docs/deployment.md): Static hosting, CDN, cache invalidation, and previews. + +## Reference +- [Configuration](https://example.com/docs/configuration.md): Site configuration keys and defaults. +- [Extensions](https://example.com/docs/extensions.md): Custom generators, hooks, and extension points. + +## Blog +- [Release notes](https://example.com/blog/releases): Product changes and upgrade notes. +- [Architecture notes](https://example.com/blog/architecture): Design decisions and implementation rationale. + +## Optional +- [About](https://example.com/about): Project overview and maintainers. +- [Privacy policy](https://example.com/privacy): Public privacy notice. +``` + +That sample follows the official ordering and the special `Optional` convention from llmstxt.org. citeturn7view0 + +## Adoption, common use cases, and implications + +Adoption is now real enough to matter for documentation tooling, even though the standard remains informal. The llmstxt.org site itself lists integrations including a Python parser/CLI, a JavaScript implementation, a VitePress plugin, a Docusaurus plugin, a Drupal recipe, and a PHP library. Public directories also list many deployed `llms.txt` files and many `llms-full.txt` companions across documentation sites and commercial domains. citeturn5view0turn7view1turn22view2turn22view3turn22view4turn22view0turn19view0turn19view2 + +Among major companies and platforms, the strongest primary-source examples are documentation ecosystems: + +Cloudflare publishes a top-level documentation `llms.txt`, many product-level `llms.txt` files, and corresponding `llms-full.txt` resources. Its docs also explicitly instruct agents to fetch Markdown instead of HTML and to use `llms.txt` as the documentation index. citeturn14view0turn20view0turn20view1turn26view3 + +Stripe serves `docs.stripe.com/llms.txt`, and its content is clearly agent-oriented: it includes operational guidance such as checking package registries for current versions rather than trusting memorised version numbers. That is a useful signal that `llms.txt` is already being used for more than a passive site map. citeturn14view1 + +Vercel serves `vercel.com/llms.txt` and prominently points to a `llms-full.txt` file containing its full documentation content. Anthropic’s developer documentation also exposes a root `llms.txt`, and Meta’s Horizon developer docs publish a large documentation index in `llms.txt` form. citeturn15view0turn26view2turn15view1turn24view0 + +The most common use cases are therefore documentation discovery, curated agent context, and easier retrieval for AI assistants. The llmstxt.org proposal itself emphasises developer documentation, software APIs, e-commerce/product explanation, legislation, personal websites, and institutional information. citeturn5view0turn6view0 + +The security, privacy, and legal picture is more modest than the marketing around the concept sometimes suggests. + +`llms.txt` is public by design. It should therefore contain only public URLs and public summaries; do not place secrets, unpublished materials, admin paths, preview URLs, or anything whose mere disclosure would be sensitive. This is an inference from the proposal’s public-root-file design and the general limitations shared with advisory text files. citeturn7view0turn12view0turn13view0 + +It is not an enforcement mechanism. If your aim is “do not crawl this” or “do not train on this”, `llms.txt` alone is too weak. The llmstxt.org proposal frames the file mainly as an inference-time aid; RFC 9309 and Google both make clear that even `robots.txt` is only advisory and not authorisation or robust secrecy. Cloudflare’s AI Crawl Control documentation reinforces this by offering separate monitoring, allow/block policies, robots compliance tracking, and even monetisation for AI crawling. citeturn7view0turn12view0turn13view0turn20view0 + +Legally, because no jurisdiction was specified, the safest general position is this: treat `llms.txt` as notice and guidance, not as a substitute for terms, licences, authentication, rate limits, or contractual controls. If you need stronger legal signalling, put the real terms in your existing legal documents and link them as public resources; if you need stronger technical control, use robots directives, auth, WAF/CDN controls, logging, and bot management. That conclusion is an inference from the proposal’s non-standard status and the enforcement limits of comparable files. citeturn5view0turn12view0turn25view0turn20view0 + +## HydePHP implementation strategy + +The exact HydePHP version was unspecified, so the right recommendation depends on whether you are working with current static-site workflows or the v3 draft direction in the uploaded planning note. That note says the v3 branch wants non-HTML outputs like `robots.txt`, `llms.txt`, sitemap, RSS, and JSON pages to become first-class pages in routing and the build pipeline, and it proposes a generated `llms.txt` feature with route grouping and exclusions. fileciteturn0file0L1-L7 fileciteturn0file0L19-L31 fileciteturn0file0L515-L529 + +```mermaid +flowchart LR + A[Hyde content and front matter] --> B[llms metadata selection] + B --> C[Generator or Blade template] + C --> D[/llms.txt in build output] + D --> E[Static host or CDN] + E --> F[LLM or agent fetches /llms.txt] + F --> G[Follows curated links] + G --> H[Markdown pages if available] +``` + +The key architectural choice is when generation happens. + +A build-time file is the best default for a typical Hyde deployment because it works everywhere Hyde does: local builds, GitHub Pages, Netlify, Cloudflare Pages, S3-style static hosting, and CDN-only stacks. A route-native page is cleaner, but only if your Hyde version actually supports non-HTML pages cleanly. The uploaded draft suggests Hyde v3 aims to make that the canonical approach. fileciteturn0file0L11-L18 fileciteturn0file0L48-L55 + +The following table compares the realistic options for HydePHP. It synthesises the llmstxt.org requirements with the HydePHP planning note and the static-hosting assumption. citeturn7view0 fileciteturn0file0L11-L18 fileciteturn0file0L184-L200 + +| Option | Static-hosting fit | Main advantages | Main drawbacks | Best use | +|---|---|---|---|---| +| Hand-maintained root file | Excellent | Simple, no framework coupling | Easy to drift out of date | Small brochure/blog sites | +| Build-time generated file | Excellent | Deterministic, CI-friendly, no runtime needed | Requires custom generator logic | Best default for most Hyde sites | +| Metadata-driven Blade/template at build | Excellent | Editors can steer sections/descriptions via front matter | Needs template + generation step | Docs/blog sites with rich metadata | +| Route-native generated page | Good if Hyde version supports non-HTML pages | Clean routing, local serve, extension hooks, easier override story | Depends on Hyde version/branch capability | Future-facing Hyde v3 style | +| Dynamic endpoint | Poor for typical static hosting | Always current, easy runtime filtering | Adds runtime infrastructure, cache complexity, less “static” | Only when you already run edge/serverless/PHP | + +A sensible application-level config file might look like this. This is **illustrative**, not current Hyde core API: + +```php + env('HYDE_LLMS_ENABLED', true), + + 'title' => env('APP_NAME', 'Example HydePHP Site'), + 'summary' => config('hyde.description', ''), + 'details' => 'Public documentation and articles for this site. Prefer Docs and Reference before wider browsing.', + 'base_url' => config('hyde.url'), + + 'prefer_markdown_urls' => true, + + 'sections' => [ + 'Docs' => ['docs/*'], + 'Reference' => ['reference/*'], + 'Blog' => ['posts/*'], + 'Project' => ['about', 'changelog'], + ], + + 'exclude' => [ + '404', + 'search.json', + 'sitemap.xml', + 'feed.xml', + 'robots.txt', + 'llms.txt', + 'media/*', + 'drafts/*', + ], + + 'optional' => [ + 'privacy', + 'terms', + 'contact', + ], +]; +``` + +A metadata-driven Blade template is a good middle path because it lets maintainers curate output without hand-editing the final text: + +```php +{{-- resources/views/llms-txt.blade.php --}} +# {{ $title }} + +> {{ $summary }} + +@if (!empty($details)) +{{ $details }} +@endif + +@foreach ($sections as $sectionName => $links) +## {{ $sectionName }} + +@foreach ($links as $link) +- [{{ $link['title'] }}]({{ $link['url'] }})@if (!empty($link['description'])): {{ $link['description'] }}@endif +@endforeach + +@endforeach +``` + +A simple generator service can then render that template and write the final file during build: + +```php + $config['title'], + 'summary' => $config['summary'], + 'details' => $config['details'] ?? '', + 'sections' => $sections, + ]); + } +} +``` + +If your Hyde version still treats non-HTML outputs as awkward post-build artefacts, write `_site/llms.txt` after the page collection has been built. If Hyde v3 lands as described in the planning note, the cleaner design is a first-class page that compiles lazily and resolves its generator from the container, matching the draft’s approach for generated non-HTML pages. fileciteturn0file0L15-L27 fileciteturn0file0L184-L200 + +A future-facing v3-style sketch would look roughly like this: + +```php +generateFromSite(); + } +} +``` + +And registration would ideally happen through Hyde’s boot/extension mechanism, because the planning note explicitly points to `Hyde::kernel()->booting()` callbacks and `HydeExtension` as the user-land extension points, and says user-defined pages should beat framework-generated ones when route keys collide. fileciteturn0file0L53-L57 fileciteturn0file0L223-L254 + +For a dynamic endpoint, only use it if you knowingly accept non-static infrastructure. An illustrative PHP route might be: + +```php +generateFromSite(); + + return response($content, 200, [ + 'Content-Type' => 'text/plain; charset=utf-8', + 'Cache-Control' => 'public, max-age=300', + ]); +}); +``` + +That approach is technically fine, but it is not a natural fit for the assumed “typical static hosting” environment. + +## Deployment, validation, interoperability, and recommended defaults + +Operationally, the most important serving rule is simple: publish a UTF-8 plain-text file at the root path `/llms.txt`. The official proposal targets that location, and major implementations from Cloudflare, Stripe, Vercel, Anthropic, and Meta all expose plain-text root or documentation-index files that way. Hyde’s v3 note is also helpful here because it says the realtime compiler already maps `.txt`, `.xml`, and `.json` output paths to correct content types. citeturn7view0turn14view0turn14view1turn14view2turn15view1turn24view0 fileciteturn0file0L48-L49 + +On static hosting and CDNs, treat `llms.txt` like any other generated public artefact: publish it at the origin root, cache it normally, and purge/invalidate it whenever docs or key pages change. Because the file is small and low-risk, short-to-moderate cache TTLs are usually more useful than aggressive long-lived caching; the value of the file depends on freshness. That is an implementation recommendation rather than a standard requirement. + +Interoperability is currently best thought of in layers. + +At the broadest layer, old browsers and legacy crawlers are unaffected: `llms.txt` is just another public text file, so publishing it is backwards-compatible by default. citeturn7view0 + +At the agent layer, support is uneven but improving. Some vendors and docs platforms clearly use it operationally, and tooling exists to parse it, generate context bundles, and validate structure. The official Python tooling parses the file and can expand it into XML context for models such as Claude; the PHP library supports creating, reading, and validating files; and ecosystem plugins generate both `llms.txt` and, in some cases, `llms-full.txt`. citeturn7view1turn22view0turn22view2turn22view3 + +At the control layer, do not assume compliance. If you need to manage AI crawlers, pair `llms.txt` with `robots.txt`, logs, and enforcement. Cloudflare’s AI Crawl Control is a good illustration: it separately tracks AI crawler activity, allows/block rules, monitors robots compliance, and can monetise crawl access. citeturn20view0turn20view1 + +For HydePHP sites, the recommended default content is conservative: + +Use the site/project name as the H1. Use the Hyde description as the summary. Provide one short explanatory paragraph if needed. Then publish a small number of sections such as `Docs`, `Reference`, `Blog`, and `Optional`. Within those sections, prefer stable, canonical pages with strong titles and strong descriptions. Exclude search indexes, feeds, sitemaps, tag archives, pagination artefacts, media files, drafts, previews, and machine-only outputs. The HydePHP planning note explicitly associates `llms.txt` generation with page titles and documentation abstracts, which is exactly the right default. citeturn7view0turn6view0 fileciteturn0file0L519-L524 + +A good maintainer checklist is therefore: + +- Confirm that `/llms.txt` exists at the site root and is served as plain text. citeturn7view0turn14view0turn14view1 +- Keep exactly one H1 and use the canonical project/site name. citeturn7view0 +- Include a short summary blockquote and concise link descriptions. citeturn7view0 +- Prefer Markdown or other clean text targets where available; otherwise use stable canonical URLs. citeturn6view0turn20view0turn15view0 +- Exclude private, draft, preview, search-index, feed, sitemap, and other non-user-facing outputs. fileciteturn0file0L148-L166 +- Pair the file with `robots.txt`, access control, and bot management where policy matters. citeturn13view0turn20view0 +- Rebuild and purge caches whenever key content changes. +- Validate the structure in CI before deployment. citeturn22view0turn7view1 + +For automated tests, I would add both content tests and behaviour tests. The llmstxt.org guidance explicitly recommends expanding the file into an LLM context file and then testing models against real questions. The official parser/CLI and the PHP library make that easy. citeturn7view0turn7view1turn22view0 + +A practical PHPUnit-style test might be: + +```php +assertFileExists($path); + + $txt = file_get_contents($path); + $this->assertStringStartsWith('# ', $txt); + $this->assertStringContainsString('## Docs', $txt); + $this->assertStringNotContainsString('/drafts/', $txt); + $this->assertStringNotContainsString('search.json', $txt); + + $parsed = (new LlmsTxt())->parse($path); + $this->assertTrue($parsed->validate()); + } +} +``` + +I would also add a link-check test that every URL listed in `llms.txt` resolves successfully after build, plus a snapshot/regression test to catch accidental churn in headings, section names, or excluded paths. For a higher-level test, run the generated file through `llms_txt2ctx` and verify that a model or parser can answer a few deterministic site questions from the expanded context. citeturn7view1turn22view0 \ No newline at end of file From 50a80ba438af43c61036bfdc295561d7d1cba61c Mon Sep 17 00:00:00 2001 From: Emma De Silva Date: Tue, 14 Jul 2026 00:10:16 +0200 Subject: [PATCH 071/117] Add page-level llms.txt inclusion policy Co-Authored-By: Claude Opus 4.8 --- .../framework/src/Pages/Concerns/HydePage.php | 15 +++++++++++++++ .../framework/src/Support/Models/Redirect.php | 5 +++++ packages/framework/tests/Feature/RedirectTest.php | 5 +++++ .../tests/Unit/Pages/BladePageUnitTest.php | 6 ++++++ .../Unit/Pages/DocumentationPageUnitTest.php | 6 ++++++ .../tests/Unit/Pages/HtmlPageUnitTest.php | 6 ++++++ .../tests/Unit/Pages/InMemoryPageUnitTest.php | 15 +++++++++++++++ .../tests/Unit/Pages/MarkdownPageUnitTest.php | 6 ++++++ .../tests/Unit/Pages/MarkdownPostUnitTest.php | 6 ++++++ .../testing/src/Common/BaseHydePageUnitTest.php | 2 ++ 10 files changed, 72 insertions(+) diff --git a/packages/framework/src/Pages/Concerns/HydePage.php b/packages/framework/src/Pages/Concerns/HydePage.php index d03d88e02a6..b0fe558c9d2 100644 --- a/packages/framework/src/Pages/Concerns/HydePage.php +++ b/packages/framework/src/Pages/Concerns/HydePage.php @@ -421,6 +421,21 @@ public function showInSitemap(): bool return filter_var($this->matter('sitemap', str_ends_with($this->getOutputPath(), '.html')), FILTER_VALIDATE_BOOLEAN); } + /** + * Can the page be listed in the llms.txt file? + * + * It can be explicitly set in the front matter using the `llms` key, + * otherwise it defaults to true for pages compiled to HTML files, and + * false for pages compiled to non-HTML files like `robots.txt`. + * + * Note that only pages of a type registered in the `hyde.llms.sections` + * config are listed in the file, regardless of this value. + */ + public function showInLlmsTxt(): bool + { + return filter_var($this->matter('llms', str_ends_with($this->getOutputPath(), '.html')), FILTER_VALIDATE_BOOLEAN); + } + /** * Get the priority of the page in the navigation menu. */ diff --git a/packages/framework/src/Support/Models/Redirect.php b/packages/framework/src/Support/Models/Redirect.php index d85baf1fa82..9a3204e9112 100644 --- a/packages/framework/src/Support/Models/Redirect.php +++ b/packages/framework/src/Support/Models/Redirect.php @@ -50,6 +50,11 @@ public function showInSitemap(): bool return false; } + public function showInLlmsTxt(): bool + { + return false; + } + /** * @throws \Hyde\Framework\Exceptions\InvalidConfigurationException If the path ends in a non-HTML output extension, * as meta refresh redirects only work for HTML pages. diff --git a/packages/framework/tests/Feature/RedirectTest.php b/packages/framework/tests/Feature/RedirectTest.php index 9e4fcf35715..73a08d73a07 100644 --- a/packages/framework/tests/Feature/RedirectTest.php +++ b/packages/framework/tests/Feature/RedirectTest.php @@ -100,4 +100,9 @@ public function testRedirectsAreHiddenFromSitemaps() { $this->assertFalse((new Redirect('foo', 'bar'))->showInSitemap()); } + + public function testRedirectsAreHiddenFromLlmsTxt() + { + $this->assertFalse((new Redirect('foo', 'bar'))->showInLlmsTxt()); + } } diff --git a/packages/framework/tests/Unit/Pages/BladePageUnitTest.php b/packages/framework/tests/Unit/Pages/BladePageUnitTest.php index 742b9d763dd..0e21861795e 100644 --- a/packages/framework/tests/Unit/Pages/BladePageUnitTest.php +++ b/packages/framework/tests/Unit/Pages/BladePageUnitTest.php @@ -92,6 +92,12 @@ public function testShowInSitemap() $this->assertFalse((new BladePage('foo', ['sitemap' => false]))->showInSitemap()); } + public function testShowInLlmsTxt() + { + $this->assertTrue((new BladePage())->showInLlmsTxt()); + $this->assertFalse((new BladePage('foo', ['llms' => false]))->showInLlmsTxt()); + } + public function testNavigationMenuPriority() { $this->assertSame(999, (new BladePage())->navigationMenuPriority()); diff --git a/packages/framework/tests/Unit/Pages/DocumentationPageUnitTest.php b/packages/framework/tests/Unit/Pages/DocumentationPageUnitTest.php index 791ef32b70d..bb8b8cc5f43 100644 --- a/packages/framework/tests/Unit/Pages/DocumentationPageUnitTest.php +++ b/packages/framework/tests/Unit/Pages/DocumentationPageUnitTest.php @@ -94,6 +94,12 @@ public function testShowInSitemap() $this->assertFalse((new DocumentationPage('foo', ['sitemap' => false]))->showInSitemap()); } + public function testShowInLlmsTxt() + { + $this->assertTrue((new DocumentationPage())->showInLlmsTxt()); + $this->assertFalse((new DocumentationPage('foo', ['llms' => false]))->showInLlmsTxt()); + } + public function testNavigationMenuPriority() { $this->assertSame(999, (new DocumentationPage())->navigationMenuPriority()); diff --git a/packages/framework/tests/Unit/Pages/HtmlPageUnitTest.php b/packages/framework/tests/Unit/Pages/HtmlPageUnitTest.php index d8bab24b266..4b9b4a0ed01 100644 --- a/packages/framework/tests/Unit/Pages/HtmlPageUnitTest.php +++ b/packages/framework/tests/Unit/Pages/HtmlPageUnitTest.php @@ -128,6 +128,12 @@ public function testShowInSitemap() $this->assertFalse((new HtmlPage('foo', ['sitemap' => false]))->showInSitemap()); } + public function testShowInLlmsTxt() + { + $this->assertTrue((new HtmlPage())->showInLlmsTxt()); + $this->assertFalse((new HtmlPage('foo', ['llms' => false]))->showInLlmsTxt()); + } + public function testNavigationMenuPriority() { $this->assertSame(999, (new HtmlPage())->navigationMenuPriority()); diff --git a/packages/framework/tests/Unit/Pages/InMemoryPageUnitTest.php b/packages/framework/tests/Unit/Pages/InMemoryPageUnitTest.php index 17b77ae2524..d2fdcd6923b 100644 --- a/packages/framework/tests/Unit/Pages/InMemoryPageUnitTest.php +++ b/packages/framework/tests/Unit/Pages/InMemoryPageUnitTest.php @@ -141,6 +141,21 @@ public function testShowInSitemapIsFalseForPagesWithNonHtmlOutputPaths() $this->assertTrue((new InMemoryPage('robots.txt', ['sitemap' => true]))->showInSitemap()); } + public function testShowInLlmsTxt() + { + $this->assertTrue((new InMemoryPage('foo'))->showInLlmsTxt()); + $this->assertFalse((new InMemoryPage('foo', ['llms' => false]))->showInLlmsTxt()); + } + + public function testShowInLlmsTxtIsFalseForPagesWithNonHtmlOutputPaths() + { + $this->assertFalse((new InMemoryPage('robots.txt'))->showInLlmsTxt()); + $this->assertFalse((new InMemoryPage('data.json'))->showInLlmsTxt()); + $this->assertFalse((new InMemoryPage('custom.xml'))->showInLlmsTxt()); + + $this->assertTrue((new InMemoryPage('robots.txt', ['llms' => true]))->showInLlmsTxt()); + } + public function testNavigationMenuPriority() { $this->assertSame(999, (new InMemoryPage('foo'))->navigationMenuPriority()); diff --git a/packages/framework/tests/Unit/Pages/MarkdownPageUnitTest.php b/packages/framework/tests/Unit/Pages/MarkdownPageUnitTest.php index 8b335b43845..5b1d16ac6d7 100644 --- a/packages/framework/tests/Unit/Pages/MarkdownPageUnitTest.php +++ b/packages/framework/tests/Unit/Pages/MarkdownPageUnitTest.php @@ -130,6 +130,12 @@ public function testShowInSitemap() $this->assertFalse((new MarkdownPage('foo', ['sitemap' => false]))->showInSitemap()); } + public function testShowInLlmsTxt() + { + $this->assertTrue((new MarkdownPage())->showInLlmsTxt()); + $this->assertFalse((new MarkdownPage('foo', ['llms' => false]))->showInLlmsTxt()); + } + public function testNavigationMenuPriority() { $this->assertSame(999, (new MarkdownPage())->navigationMenuPriority()); diff --git a/packages/framework/tests/Unit/Pages/MarkdownPostUnitTest.php b/packages/framework/tests/Unit/Pages/MarkdownPostUnitTest.php index c8c30195dbe..f5665cca813 100644 --- a/packages/framework/tests/Unit/Pages/MarkdownPostUnitTest.php +++ b/packages/framework/tests/Unit/Pages/MarkdownPostUnitTest.php @@ -130,6 +130,12 @@ public function testShowInSitemap() $this->assertFalse((new MarkdownPost('foo', ['sitemap' => false]))->showInSitemap()); } + public function testShowInLlmsTxt() + { + $this->assertTrue((new MarkdownPost())->showInLlmsTxt()); + $this->assertFalse((new MarkdownPost('foo', ['llms' => false]))->showInLlmsTxt()); + } + public function testNavigationMenuPriority() { $this->assertSame(10, (new MarkdownPost())->navigationMenuPriority()); diff --git a/packages/testing/src/Common/BaseHydePageUnitTest.php b/packages/testing/src/Common/BaseHydePageUnitTest.php index 47b3791e329..ea892e812fb 100644 --- a/packages/testing/src/Common/BaseHydePageUnitTest.php +++ b/packages/testing/src/Common/BaseHydePageUnitTest.php @@ -94,6 +94,8 @@ abstract public function testShowInNavigation(); abstract public function testShowInSitemap(); + abstract public function testShowInLlmsTxt(); + abstract public function testGetSourcePath(); abstract public function testGetLink(); From 3962af640ee196f41cb0e6555424cc8968463df6 Mon Sep 17 00:00:00 2001 From: Emma De Silva Date: Tue, 14 Jul 2026 00:13:30 +0200 Subject: [PATCH 072/117] Add a generated llms.txt page Co-Authored-By: Claude Opus 4.8 --- config/hyde.php | 42 ++++ packages/framework/config/hyde.php | 42 ++++ packages/framework/src/Facades/Features.php | 9 + .../src/Foundation/HydeCoreExtension.php | 14 ++ .../TextGenerators/LlmsTxtGenerator.php | 188 ++++++++++++++++++ .../Features/TextGenerators/LlmsTxtPage.php | 39 ++++ 6 files changed, 334 insertions(+) create mode 100644 packages/framework/src/Framework/Features/TextGenerators/LlmsTxtGenerator.php create mode 100644 packages/framework/src/Framework/Features/TextGenerators/LlmsTxtPage.php diff --git a/config/hyde.php b/config/hyde.php index 2037816f2f3..5af644f95d6 100644 --- a/config/hyde.php +++ b/config/hyde.php @@ -159,6 +159,48 @@ ], ], + /* + |-------------------------------------------------------------------------- + | Llms.txt Generation + |-------------------------------------------------------------------------- + | + | When enabled, an llms.txt file indexing your site is generated when you + | compile your static site, so that AI services and agents can discover + | your content without having to crawl through your rendered HTML. + | + | The file only links to pages that are already published, so it surfaces + | nothing your sitemap does not. Still, if you would rather not help AI + | services read your site, set 'enabled' to false to skip the file. + | + | This feature requires that a site base URL has been set, as the file + | needs absolute links. Pages can opt out using `llms: false` in their + | front matter, and the abstract or description front matter of each + | page is used as its link description. + | + | Please note that llms.txt is an emerging standard which is still subject + | to change. We may therefore need to change the format of the generated + | file in future minor and patch releases in order to follow the spec. + | + */ + + 'llms' => [ + // Should the llms.txt file be generated? + 'enabled' => true, + + // An optional summary of your site, added as the introductory blockquote. + 'description' => null, + + // The page types to list in the file, and the heading each is listed under. + // Page types that are not listed here are not added to the file at all. + 'sections' => [ + \Hyde\Pages\HtmlPage::class => 'Pages', + \Hyde\Pages\BladePage::class => 'Pages', + \Hyde\Pages\MarkdownPage::class => 'Pages', + \Hyde\Pages\DocumentationPage::class => 'Documentation', + \Hyde\Pages\MarkdownPost::class => 'Blog Posts', + ], + ], + /* |-------------------------------------------------------------------------- | Source Root Directory diff --git a/packages/framework/config/hyde.php b/packages/framework/config/hyde.php index 2037816f2f3..5af644f95d6 100644 --- a/packages/framework/config/hyde.php +++ b/packages/framework/config/hyde.php @@ -159,6 +159,48 @@ ], ], + /* + |-------------------------------------------------------------------------- + | Llms.txt Generation + |-------------------------------------------------------------------------- + | + | When enabled, an llms.txt file indexing your site is generated when you + | compile your static site, so that AI services and agents can discover + | your content without having to crawl through your rendered HTML. + | + | The file only links to pages that are already published, so it surfaces + | nothing your sitemap does not. Still, if you would rather not help AI + | services read your site, set 'enabled' to false to skip the file. + | + | This feature requires that a site base URL has been set, as the file + | needs absolute links. Pages can opt out using `llms: false` in their + | front matter, and the abstract or description front matter of each + | page is used as its link description. + | + | Please note that llms.txt is an emerging standard which is still subject + | to change. We may therefore need to change the format of the generated + | file in future minor and patch releases in order to follow the spec. + | + */ + + 'llms' => [ + // Should the llms.txt file be generated? + 'enabled' => true, + + // An optional summary of your site, added as the introductory blockquote. + 'description' => null, + + // The page types to list in the file, and the heading each is listed under. + // Page types that are not listed here are not added to the file at all. + 'sections' => [ + \Hyde\Pages\HtmlPage::class => 'Pages', + \Hyde\Pages\BladePage::class => 'Pages', + \Hyde\Pages\MarkdownPage::class => 'Pages', + \Hyde\Pages\DocumentationPage::class => 'Documentation', + \Hyde\Pages\MarkdownPost::class => 'Blog Posts', + ], + ], + /* |-------------------------------------------------------------------------- | Source Root Directory diff --git a/packages/framework/src/Facades/Features.php b/packages/framework/src/Facades/Features.php index d5e353454c7..8235fbd3c07 100644 --- a/packages/framework/src/Facades/Features.php +++ b/packages/framework/src/Facades/Features.php @@ -122,6 +122,15 @@ public static function hasRobotsTxt(): bool return Config::getBool('hyde.robots.enabled', true); } + /** + * Can an llms.txt file be generated? + */ + public static function hasLlmsTxt(): bool + { + return Hyde::hasSiteUrl() + && Config::getBool('hyde.llms.enabled', true); + } + /** * Should documentation search be enabled? */ diff --git a/packages/framework/src/Foundation/HydeCoreExtension.php b/packages/framework/src/Foundation/HydeCoreExtension.php index 7425d5cac8c..85ab57ee089 100644 --- a/packages/framework/src/Foundation/HydeCoreExtension.php +++ b/packages/framework/src/Foundation/HydeCoreExtension.php @@ -21,6 +21,7 @@ use Hyde\Facades\Config; use Hyde\Framework\Features\Documentation\DocumentationSearchPage; use Hyde\Framework\Features\Documentation\DocumentationSearchIndex; +use Hyde\Framework\Features\TextGenerators\LlmsTxtPage; use Hyde\Framework\Features\TextGenerators\RobotsTxtPage; use Hyde\Framework\Features\XmlGenerators\RssFeedPage; use Hyde\Framework\Features\XmlGenerators\SitemapPage; @@ -95,6 +96,10 @@ public function discoverPages(PageCollection $collection): void if (Features::hasRobotsTxt()) { $this->discoverRobotsTxtPage($collection); } + + if (Features::hasLlmsTxt()) { + $this->discoverLlmsTxtPage($collection); + } } /** Add the generated sitemap page unless the route is user-defined. */ @@ -113,6 +118,7 @@ protected function discoverRssFeedPage(PageCollection $collection): void } } + /** Add the generated robots.txt page unless the route is user-defined. */ protected function discoverRobotsTxtPage(PageCollection $collection): void { if (! $this->hasPageWithRouteKey($collection, RobotsTxtPage::routeKey())) { @@ -120,6 +126,14 @@ protected function discoverRobotsTxtPage(PageCollection $collection): void } } + /** Add the generated llms.txt page unless the route is user-defined. */ + protected function discoverLlmsTxtPage(PageCollection $collection): void + { + if (! $this->hasPageWithRouteKey($collection, LlmsTxtPage::routeKey())) { + $collection->addPage(new LlmsTxtPage()); + } + } + /** Discard documentation source files stored outside the version directories. */ protected function discardUnversionedDocumentationFiles(FileCollection $collection): void { diff --git a/packages/framework/src/Framework/Features/TextGenerators/LlmsTxtGenerator.php b/packages/framework/src/Framework/Features/TextGenerators/LlmsTxtGenerator.php new file mode 100644 index 00000000000..dc0fd565212 --- /dev/null +++ b/packages/framework/src/Framework/Features/TextGenerators/LlmsTxtGenerator.php @@ -0,0 +1,188 @@ +, string> + */ + protected const DEFAULT_SECTIONS = [ + HtmlPage::class => 'Pages', + BladePage::class => 'Pages', + MarkdownPage::class => 'Pages', + DocumentationPage::class => 'Documentation', + MarkdownPost::class => 'Blog Posts', + ]; + + public function generate(): string + { + return implode("\n", $this->getLines())."\n"; + } + + /** @return array */ + protected function getLines(): array + { + $lines = ['# '.Config::getString('hyde.name', 'HydePHP')]; + + $description = Config::getNullableString('hyde.llms.description'); + + if (filled($description)) { + $lines[] = ''; + $lines[] = '> '.$this->normalizeText($description); + } + + foreach ($this->getSections() as $heading => $routes) { + $lines[] = ''; + $lines[] = "## $heading"; + $lines[] = ''; + + foreach ($routes as $route) { + $lines[] = $this->makeLink($route); + } + } + + return $lines; + } + + /** + * Group the site's routes into their configured sections, discarding empty ones. + * + * @return array> + */ + protected function getSections(): array + { + $map = $this->getSectionMap(); + + $sections = array_fill_keys(array_values($map), []); + + Routes::all()->each(function (Route $route) use ($map, &$sections): void { + $page = $route->getPage(); + + if (! $page->showInLlmsTxt() || $this->isErrorPage($page)) { + return; + } + + foreach ($map as $pageClass => $heading) { + if ($page instanceof $pageClass) { + $sections[$heading][] = $route; + + return; + } + } + }); + + return array_filter($sections); + } + + /** + * @return array, string> + * + * @throws \Hyde\Framework\Exceptions\InvalidConfigurationException If an entry is not a page class mapped to a heading. + */ + protected function getSectionMap(): array + { + /** @var array, string> $sections */ + $sections = Config::getArray('hyde.llms.sections', static::DEFAULT_SECTIONS); + + foreach ($sections as $pageClass => $heading) { + if (! is_string($pageClass) || ! is_a($pageClass, HydePage::class, true)) { + throw new InvalidConfigurationException(sprintf( + 'Invalid `hyde.llms.sections` entry at index [%s]: each key must be a page class extending %s.', + $pageClass, HydePage::class + ), 'hyde', 'sections'); + } + + if (! is_string($heading) || ! filled($heading)) { + throw new InvalidConfigurationException(sprintf( + 'Invalid `hyde.llms.sections` entry at index [%s]: each section heading must be a non-empty string, %s given.', + $pageClass, get_debug_type($heading) + ), 'hyde', 'sections'); + } + } + + return $sections; + } + + /** + * Error pages are not content, so they are never listed in the file. + */ + protected function isErrorPage(HydePage $page): bool + { + return $page->getIdentifier() === '404'; + } + + protected function makeLink(Route $route): string + { + $page = $route->getPage(); + + $link = sprintf('- [%s](%s)', $page->title, Hyde::url($route->getOutputPath())); + + $description = $this->getPageDescription($page); + + return $description === null ? $link : "$link: $description"; + } + + protected function getPageDescription(HydePage $page): ?string + { + $description = $page->matter('abstract') ?? $page->matter('description'); + + if (! is_string($description) || ! filled($description)) { + return null; + } + + return $this->normalizeText($description); + } + + /** + * Collapse whitespace so that multi-line front matter cannot break the line-based file format. + */ + protected function normalizeText(string $text): string + { + return preg_replace('/\s+/', ' ', trim($text)); + } +} diff --git a/packages/framework/src/Framework/Features/TextGenerators/LlmsTxtPage.php b/packages/framework/src/Framework/Features/TextGenerators/LlmsTxtPage.php new file mode 100644 index 00000000000..9d10ec260f9 --- /dev/null +++ b/packages/framework/src/Framework/Features/TextGenerators/LlmsTxtPage.php @@ -0,0 +1,39 @@ + ['hidden' => true], + ]); + } + + public function compile(): string + { + return app(LlmsTxtGenerator::class)->generate(); + } + + /** + * Get the route key of the llms.txt file, which for this page is also its output path. + */ + public static function routeKey(): string + { + return 'llms.txt'; + } +} From f40d2a71003999e476a59b527530fb871edd1a01 Mon Sep 17 00:00:00 2001 From: Emma De Silva Date: Tue, 14 Jul 2026 00:16:54 +0200 Subject: [PATCH 073/117] Test the generated llms.txt page Co-Authored-By: Claude Opus 4.8 --- .../tests/Feature/LlmsTxtGeneratorTest.php | 246 ++++++++++++++++++ .../tests/Feature/LlmsTxtPageTest.php | 166 ++++++++++++ .../tests/RealtimeCompilerTest.php | 19 ++ 3 files changed, 431 insertions(+) create mode 100644 packages/framework/tests/Feature/LlmsTxtGeneratorTest.php create mode 100644 packages/framework/tests/Feature/LlmsTxtPageTest.php diff --git a/packages/framework/tests/Feature/LlmsTxtGeneratorTest.php b/packages/framework/tests/Feature/LlmsTxtGeneratorTest.php new file mode 100644 index 00000000000..c110e9b1b31 --- /dev/null +++ b/packages/framework/tests/Feature/LlmsTxtGeneratorTest.php @@ -0,0 +1,246 @@ +withSiteUrl(); + + // Reset the pages directory so that the generated listings are deterministic. + File::deleteDirectory(Hyde::path('_pages')); + File::makeDirectory(Hyde::path('_pages')); + + copy(Hyde::vendorPath('resources/views/homepages/welcome.blade.php'), Hyde::path('_pages/index.blade.php')); + copy(Hyde::vendorPath('resources/views/pages/404.blade.php'), Hyde::path('_pages/404.blade.php')); + } + + public function testGeneratesFileWithSiteNameAsHeading() + { + config(['hyde.name' => 'My Site']); + + $this->assertSame(<<<'TXT' + # My Site + + ## Pages + + - [Index](https://example.com/index.html) + + TXT, $this->generate()); + } + + public function testGeneratesSummaryBlockquoteWhenDescriptionIsConfigured() + { + config(['hyde.llms.description' => 'Everything about the example project.']); + + $this->assertStringContainsString("# HydePHP\n\n> Everything about the example project.\n\n## Pages", $this->generate()); + } + + public function testOmitsSummaryBlockquoteWhenNoDescriptionIsConfigured() + { + $this->assertStringStartsWith("# HydePHP\n\n## Pages", $this->generate()); + } + + public function testGroupsPagesIntoTheirConfiguredSections() + { + $this->file('_pages/about.md', '# About'); + $this->file('_docs/installation.md', '# Installation'); + $this->file('_posts/hello-world.md', '# Hello World'); + + $this->assertSame(<<<'TXT' + # HydePHP + + ## Pages + + - [Index](https://example.com/index.html) + - [About](https://example.com/about.html) + + ## Documentation + + - [Installation](https://example.com/docs/installation.html) + + ## Blog Posts + + - [Hello World](https://example.com/posts/hello-world.html) + + TXT, $this->generate()); + } + + public function testSectionsFollowTheOrderOfTheConfiguredMap() + { + config(['hyde.llms.sections' => [ + MarkdownPost::class => 'Blog Posts', + DocumentationPage::class => 'Documentation', + ]]); + + $this->file('_docs/installation.md', '# Installation'); + $this->file('_posts/hello-world.md', '# Hello World'); + + $this->assertSame(<<<'TXT' + # HydePHP + + ## Blog Posts + + - [Hello World](https://example.com/posts/hello-world.html) + + ## Documentation + + - [Installation](https://example.com/docs/installation.html) + + TXT, $this->generate()); + } + + public function testEmptySectionsAreOmitted() + { + $this->assertStringNotContainsString('## Documentation', $this->generate()); + $this->assertStringNotContainsString('## Blog Posts', $this->generate()); + } + + public function testUsesPageAbstractAsLinkDescription() + { + $this->markdown('_docs/installation.md', '# Installation', ['abstract' => 'How to install the project.']); + + $this->assertStringContainsString('- [Installation](https://example.com/docs/installation.html): How to install the project.', $this->generate()); + } + + public function testFallsBackToPageDescriptionWhenThereIsNoAbstract() + { + $this->markdown('_docs/installation.md', '# Installation', ['description' => 'The meta description.']); + + $this->assertStringContainsString('- [Installation](https://example.com/docs/installation.html): The meta description.', $this->generate()); + } + + public function testPrefersTheAbstractOverTheDescription() + { + $this->markdown('_docs/installation.md', '# Installation', [ + 'abstract' => 'The abstract.', + 'description' => 'The meta description.', + ]); + + $this->assertStringContainsString('- [Installation](https://example.com/docs/installation.html): The abstract.', $this->generate()); + } + + public function testMultiLineDescriptionsAreCollapsedToASingleLine() + { + $this->file('_docs/installation.md', "---\nabstract: |\n First line.\n Second line.\n---\n\n# Installation"); + + $this->assertStringContainsString('- [Installation](https://example.com/docs/installation.html): First line. Second line.', $this->generate()); + } + + public function testPagesCanOptOutUsingFrontMatter() + { + $this->markdown('_pages/private.md', '# Private', ['llms' => false]); + + $this->assertStringNotContainsString('Private', $this->generate()); + } + + public function testErrorPagesAreNotListed() + { + $this->assertStringNotContainsString('404', $this->generate()); + } + + public function testGeneratedNonHtmlPagesAreNotListed() + { + $contents = $this->generate(); + + $this->assertStringNotContainsString('llms.txt', $contents); + $this->assertStringNotContainsString('robots.txt', $contents); + $this->assertStringNotContainsString('sitemap.xml', $contents); + } + + public function testRedirectsAreNotListed() + { + config(['hyde.llms.sections' => [Redirect::class => 'Redirects']]); + + Routes::addRoute(new Route(new Redirect('old-page', 'new-page'))); + + $this->assertStringNotContainsString('old-page', $this->generate()); + } + + public function testPageTypesNotInTheSectionsConfigAreNotListed() + { + config(['hyde.llms.sections' => [DocumentationPage::class => 'Documentation']]); + + $this->file('_posts/hello-world.md', '# Hello World'); + $this->file('_docs/installation.md', '# Installation'); + + $contents = $this->generate(); + + $this->assertStringContainsString('Installation', $contents); + $this->assertStringNotContainsString('Hello World', $contents); + } + + public function testCustomPageClassesAreListedUnderTheirParentClassSection() + { + Routes::addRoute(new Route(new LlmsTxtGeneratorTestPage('custom'))); + + $this->assertStringContainsString("## Pages\n\n- [Index](https://example.com/index.html)\n- [Custom](https://example.com/custom.html)", $this->generate()); + } + + public function testPrettyUrlsAreUsedWhenEnabled() + { + config(['hyde.pretty_urls' => true]); + + $this->file('_docs/installation.md', '# Installation'); + + $this->assertStringContainsString('- [Installation](https://example.com/docs/installation)', $this->generate()); + } + + public function testSectionKeyThatIsNotAPageClassFailsWithConfigurationException() + { + config(['hyde.llms.sections' => ['NotAPageClass' => 'Pages']]); + + $this->expectException(InvalidConfigurationException::class); + $this->expectExceptionMessage('Invalid `hyde.llms.sections` entry at index [NotAPageClass]: each key must be a page class extending Hyde\Pages\Concerns\HydePage.'); + + $this->generate(); + } + + public function testNonStringSectionHeadingFailsWithConfigurationException() + { + config(['hyde.llms.sections' => [MarkdownPage::class => 123]]); + + $this->expectException(InvalidConfigurationException::class); + $this->expectExceptionMessage('Invalid `hyde.llms.sections` entry at index [Hyde\Pages\MarkdownPage]: each section heading must be a non-empty string, int given.'); + + $this->generate(); + } + + public function testEmptySectionHeadingFailsWithConfigurationException() + { + config(['hyde.llms.sections' => [MarkdownPage::class => '']]); + + $this->expectException(InvalidConfigurationException::class); + $this->expectExceptionMessage('Invalid `hyde.llms.sections` entry at index [Hyde\Pages\MarkdownPage]: each section heading must be a non-empty string, string given.'); + + $this->generate(); + } + + protected function generate(): string + { + return (new LlmsTxtGenerator())->generate(); + } +} + +class LlmsTxtGeneratorTestPage extends MarkdownPage +{ + // +} diff --git a/packages/framework/tests/Feature/LlmsTxtPageTest.php b/packages/framework/tests/Feature/LlmsTxtPageTest.php new file mode 100644 index 00000000000..da835ded6b8 --- /dev/null +++ b/packages/framework/tests/Feature/LlmsTxtPageTest.php @@ -0,0 +1,166 @@ +withSiteUrl(); + } + + protected function tearDown(): void + { + File::cleanDirectory(Hyde::path('_site')); + + parent::tearDown(); + } + + public function testLlmsTxtPageIsRegisteredAsRouteByDefault() + { + $this->assertTrue(Routes::exists('llms.txt')); + + $page = Routes::get('llms.txt')->getPage(); + + $this->assertInstanceOf(LlmsTxtPage::class, $page); + $this->assertSame('llms.txt', $page->getOutputPath()); + $this->assertSame('llms.txt', $page->getRouteKey()); + } + + public function testLlmsTxtPageIsNotRegisteredWithoutSiteUrl() + { + $this->withoutSiteUrl(); + + $this->assertFalse(Routes::exists('llms.txt')); + } + + public function testLlmsTxtPageIsNotRegisteredWhenDisabledInConfig() + { + config(['hyde.llms.enabled' => false]); + + $this->assertFalse(Routes::exists('llms.txt')); + } + + public function testLlmsTxtPageIsHiddenFromNavigationAndExcludedFromTheSitemap() + { + $page = new LlmsTxtPage(); + + $this->assertFalse($page->showInNavigation()); + $this->assertFalse($page->showInSitemap()); + $this->assertFalse($page->showInLlmsTxt()); + } + + public function testLlmsTxtPageCompilesUsingTheLlmsTxtGenerator() + { + $this->assertSame((new LlmsTxtGenerator())->generate(), (new LlmsTxtPage())->compile()); + } + + public function testLlmsTxtGeneratorCanBeSwappedThroughTheServiceContainer() + { + app()->bind(LlmsTxtGenerator::class, fn (): LlmsTxtGenerator => new class extends LlmsTxtGenerator + { + public function generate(): string + { + return 'custom generator output'; + } + }); + + $this->assertSame('custom generator output', Routes::get('llms.txt')->getPage()->compile()); + } + + public function testBuildCommandCompilesLlmsTxtPageAsDynamicPage() + { + $this->artisan('build') + ->expectsOutput('Creating Dynamic Pages...') + ->assertExitCode(0); + + $this->assertStringStartsWith('# HydePHP', file_get_contents(Hyde::path('_site/llms.txt'))); + } + + public function testBuiltLlmsTxtIsExcludedFromTheSitemapAndItself() + { + $this->artisan('build')->assertExitCode(0); + + $this->assertStringNotContainsString('llms.txt', file_get_contents(Hyde::path('_site/sitemap.xml'))); + $this->assertStringNotContainsString('llms.txt', file_get_contents(Hyde::path('_site/llms.txt'))); + } + + public function testLlmsTxtPageIsIncludedInTheBuildManifest() + { + $this->artisan('build')->assertExitCode(0); + + $manifest = json_decode(file_get_contents(Hyde::path('app/storage/framework/cache/build-manifest.json')), true); + + $this->assertArrayHasKey('llms.txt', $manifest['pages']); + $this->assertSame('llms.txt', $manifest['pages']['llms.txt']['output_path']); + } + + public function testLlmsTxtRouteIsIncludedInTheRouteList() + { + $this->artisan('route:list') + ->expectsOutputToContain('llms.txt') + ->assertExitCode(0); + } + + public function testUserPageRegisteredInBootingCallbackSuppressesTheGeneratedLlmsTxtPage() + { + Hyde::kernel()->booting(function (HydeKernel $kernel): void { + $kernel->pages()->addPage(new InMemoryPage('llms.txt', contents: 'user defined llms')); + }); + + $page = Routes::get('llms.txt')->getPage(); + + $this->assertNotInstanceOf(LlmsTxtPage::class, $page); + $this->assertSame(1, Hyde::pages()->filter(fn ($page) => $page->getRouteKey() === 'llms.txt')->count()); + + $this->artisan('build')->assertExitCode(0); + + $this->assertSame('user defined llms', file_get_contents(Hyde::path('_site/llms.txt'))); + } + + public function testUserPageRegisteredThroughExtensionSuppressesTheGeneratedLlmsTxtPage() + { + Hyde::kernel()->registerExtension(LlmsTxtPageTestExtension::class); + + $page = Routes::get('llms.txt')->getPage(); + + $this->assertNotInstanceOf(LlmsTxtPage::class, $page); + $this->assertSame(1, Hyde::pages()->filter(fn ($page) => $page->getRouteKey() === 'llms.txt')->count()); + + $this->artisan('build')->assertExitCode(0); + + $this->assertSame('extension defined llms', file_get_contents(Hyde::path('_site/llms.txt'))); + } +} + +class LlmsTxtPageTestExtension extends HydeExtension +{ + public function discoverPages(PageCollection $collection): void + { + $collection->addPage(new InMemoryPage('llms.txt', contents: 'extension defined llms')); + } +} diff --git a/packages/realtime-compiler/tests/RealtimeCompilerTest.php b/packages/realtime-compiler/tests/RealtimeCompilerTest.php index b214a846067..603f6c2a0f2 100644 --- a/packages/realtime-compiler/tests/RealtimeCompilerTest.php +++ b/packages/realtime-compiler/tests/RealtimeCompilerTest.php @@ -413,6 +413,25 @@ public function testRobotsTxtRouteIsServedWithPlainTextContentType() $this->assertSame("User-agent: *\nAllow: /\n\nSitemap: http://localhost:8080/sitemap.xml\n", $response->body); } + public function testLlmsTxtRouteIsServedWithPlainTextContentType() + { + $this->mockCompilerRoute('llms.txt'); + + $kernel = new HttpKernel(); + $response = $kernel->handle(new Request()); + + $this->assertInstanceOf(Response::class, $response); + $this->assertNotInstanceOf(HtmlResponse::class, $response); + $this->assertSame(200, $response->statusCode); + $this->assertSame('OK', $response->statusMessage); + + $headers = $this->getResponseHeaders($response); + $this->assertSame('text/plain', $headers['Content-Type']); + + $this->assertStringStartsWith('# HydePHP', $response->body); + $this->assertStringContainsString('http://localhost:8080/', $response->body); + } + public function testGetContentTypeReturnsApplicationJsonForJsonOutputPath() { $page = $this->makePageWithOutputPath('foo.json'); From 4f35e822ecb15022a61667af2f158d60931bb9d0 Mon Sep 17 00:00:00 2001 From: Emma De Silva Date: Tue, 14 Jul 2026 00:22:09 +0200 Subject: [PATCH 074/117] Adapt existing tests to the generated llms.txt page Co-Authored-By: Claude Opus 4.8 --- .../Feature/Commands/RouteListCommandTest.php | 2 +- .../Feature/ConfigurableFeaturesTest.php | 19 +++++++++++++++++++ .../tests/Feature/LlmsTxtGeneratorTest.php | 9 --------- .../tests/Feature/PageCollectionTest.php | 2 +- .../tests/Feature/RouteCollectionTest.php | 2 +- .../tests/Feature/StaticSiteServiceTest.php | 2 +- .../tests/Unit/GenerateBuildManifestTest.php | 2 +- 7 files changed, 24 insertions(+), 14 deletions(-) diff --git a/packages/framework/tests/Feature/Commands/RouteListCommandTest.php b/packages/framework/tests/Feature/Commands/RouteListCommandTest.php index 400c4f95991..15864f2705f 100644 --- a/packages/framework/tests/Feature/Commands/RouteListCommandTest.php +++ b/packages/framework/tests/Feature/Commands/RouteListCommandTest.php @@ -20,7 +20,7 @@ protected function setUp(): void { parent::setUp(); - config(['hyde.generate_sitemap' => false, 'hyde.robots.enabled' => false]); + config(['hyde.generate_sitemap' => false, 'hyde.robots.enabled' => false, 'hyde.llms.enabled' => false]); } public function testRouteListCommand() diff --git a/packages/framework/tests/Feature/ConfigurableFeaturesTest.php b/packages/framework/tests/Feature/ConfigurableFeaturesTest.php index b1825139b3b..51dc4f489bc 100644 --- a/packages/framework/tests/Feature/ConfigurableFeaturesTest.php +++ b/packages/framework/tests/Feature/ConfigurableFeaturesTest.php @@ -75,6 +75,25 @@ public function testHasRobotsTxtReturnsFalseWhenDisabledInConfig() $this->assertFalse(Features::hasRobotsTxt()); } + public function testHasLlmsTxtReturnsTrueByDefaultWhenHydeHasBaseUrl() + { + $this->withSiteUrl(); + $this->assertTrue(Features::hasLlmsTxt()); + } + + public function testHasLlmsTxtReturnsFalseIfHydeDoesNotHaveBaseUrl() + { + config(['hyde.url' => '']); + $this->assertFalse(Features::hasLlmsTxt()); + } + + public function testHasLlmsTxtReturnsFalseWhenDisabledInConfig() + { + $this->withSiteUrl(); + config(['hyde.llms.enabled' => false]); + $this->assertFalse(Features::hasLlmsTxt()); + } + public function testHasThemeToggleButtonsReturnsTrueWhenDarkmodeEnabledAndConfigTrue() { // Enable dark mode and set hyde.theme_toggle_buttons config option to true diff --git a/packages/framework/tests/Feature/LlmsTxtGeneratorTest.php b/packages/framework/tests/Feature/LlmsTxtGeneratorTest.php index c110e9b1b31..18ff53f6ecf 100644 --- a/packages/framework/tests/Feature/LlmsTxtGeneratorTest.php +++ b/packages/framework/tests/Feature/LlmsTxtGeneratorTest.php @@ -4,7 +4,6 @@ namespace Hyde\Framework\Testing\Feature; -use Hyde\Hyde; use Hyde\Testing\TestCase; use Hyde\Pages\MarkdownPage; use Hyde\Pages\MarkdownPost; @@ -14,7 +13,6 @@ use Hyde\Foundation\Facades\Routes; use Hyde\Framework\Exceptions\InvalidConfigurationException; use Hyde\Framework\Features\TextGenerators\LlmsTxtGenerator; -use Illuminate\Support\Facades\File; #[\PHPUnit\Framework\Attributes\CoversClass(\Hyde\Framework\Features\TextGenerators\LlmsTxtGenerator::class)] class LlmsTxtGeneratorTest extends TestCase @@ -24,13 +22,6 @@ protected function setUp(): void parent::setUp(); $this->withSiteUrl(); - - // Reset the pages directory so that the generated listings are deterministic. - File::deleteDirectory(Hyde::path('_pages')); - File::makeDirectory(Hyde::path('_pages')); - - copy(Hyde::vendorPath('resources/views/homepages/welcome.blade.php'), Hyde::path('_pages/index.blade.php')); - copy(Hyde::vendorPath('resources/views/pages/404.blade.php'), Hyde::path('_pages/404.blade.php')); } public function testGeneratesFileWithSiteNameAsHeading() diff --git a/packages/framework/tests/Feature/PageCollectionTest.php b/packages/framework/tests/Feature/PageCollectionTest.php index e6067073755..4d08f92ee04 100644 --- a/packages/framework/tests/Feature/PageCollectionTest.php +++ b/packages/framework/tests/Feature/PageCollectionTest.php @@ -26,7 +26,7 @@ protected function setUp(): void { parent::setUp(); - config(['hyde.generate_sitemap' => false, 'hyde.rss.enabled' => false, 'hyde.robots.enabled' => false]); + config(['hyde.generate_sitemap' => false, 'hyde.rss.enabled' => false, 'hyde.robots.enabled' => false, 'hyde.llms.enabled' => false]); } public function testBootMethodCreatesNewPageCollectionAndDiscoversPagesAutomatically() diff --git a/packages/framework/tests/Feature/RouteCollectionTest.php b/packages/framework/tests/Feature/RouteCollectionTest.php index 0878c7d3d4e..0e75ad34344 100644 --- a/packages/framework/tests/Feature/RouteCollectionTest.php +++ b/packages/framework/tests/Feature/RouteCollectionTest.php @@ -26,7 +26,7 @@ protected function setUp(): void { parent::setUp(); - config(['hyde.generate_sitemap' => false, 'hyde.rss.enabled' => false, 'hyde.robots.enabled' => false]); + config(['hyde.generate_sitemap' => false, 'hyde.rss.enabled' => false, 'hyde.robots.enabled' => false, 'hyde.llms.enabled' => false]); } public function testBootMethodDiscoversAllPages() diff --git a/packages/framework/tests/Feature/StaticSiteServiceTest.php b/packages/framework/tests/Feature/StaticSiteServiceTest.php index 1efeaa58274..b4065fbe67c 100644 --- a/packages/framework/tests/Feature/StaticSiteServiceTest.php +++ b/packages/framework/tests/Feature/StaticSiteServiceTest.php @@ -145,7 +145,7 @@ public function testAllPageTypesCanBeCompiled() public function testOnlyProgressBarsForTypesWithPagesAreShown() { - config(['hyde.generate_sitemap' => false, 'hyde.robots.enabled' => false]); + config(['hyde.generate_sitemap' => false, 'hyde.robots.enabled' => false, 'hyde.llms.enabled' => false]); $this->file('_pages/blade.blade.php'); $this->file('_pages/markdown.md'); diff --git a/packages/framework/tests/Unit/GenerateBuildManifestTest.php b/packages/framework/tests/Unit/GenerateBuildManifestTest.php index 9dac849b21c..bc1b3b4e5ea 100644 --- a/packages/framework/tests/Unit/GenerateBuildManifestTest.php +++ b/packages/framework/tests/Unit/GenerateBuildManifestTest.php @@ -20,7 +20,7 @@ class GenerateBuildManifestTest extends UnitTestCase public function testActionGeneratesBuildManifest() { - self::mockConfig(['hyde.robots.enabled' => false]); + self::mockConfig(['hyde.robots.enabled' => false, 'hyde.llms.enabled' => false]); Hyde::pages()->addPage(new DocumentationSearchIndex()); From 03384fd54df656abcf0988d0992d9a5cf588f497 Mon Sep 17 00:00:00 2001 From: Emma De Silva Date: Tue, 14 Jul 2026 00:23:07 +0200 Subject: [PATCH 075/117] Add release notes and upgrade guidance for the generated llms.txt Co-Authored-By: Claude Opus 4.8 --- HYDEPHP_V3_PLANNING.md | 3 +++ UPGRADE.md | 23 ++++++++++++++++++++++- 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/HYDEPHP_V3_PLANNING.md b/HYDEPHP_V3_PLANNING.md index 823cc264ac2..fcb95ee8cc3 100644 --- a/HYDEPHP_V3_PLANNING.md +++ b/HYDEPHP_V3_PLANNING.md @@ -28,6 +28,9 @@ Having this document in code lets us know the devlopment state at any given poin - Pages can now control their own sitemap inclusion. Set `sitemap: false` in a page's front matter to exclude it from the generated `sitemap.xml`, or override the new `HydePage::showInSitemap()` method in custom page classes. Pages compiled to non-HTML output files (like `robots.txt`) are excluded by default, and `sitemap: true` front matter opts such a page back in. - The sitemap and RSS feed are now first-class pages instead of post-build side effects: when the respective feature is enabled, `sitemap.xml` and the RSS feed (`feed.xml`, or the configured `hyde.rss.filename`) are registered as routes, so they are served by `hyde serve`, listed in `route:list`, included in the build manifest, and compiled through the standard site build. The output can be customized by rebinding the `SitemapGenerator` or `RssFeedGenerator` class in the service container, and registering a user-defined page with the same route key (from a service provider, booting callback, or extension) replaces the generated page entirely. - Hyde now generates a `robots.txt` file for the site out of the box. The default output allows all crawlers, and links to the sitemap when that feature is enabled. Rule values listed in the new `hyde.robots.disallow` configuration array are written verbatim as `Disallow` rules (so wildcard patterns are supported), and the file can be disabled entirely with `hyde.robots.enabled`. The page is wired like the sitemap and RSS feed: it is a real route (served by `hyde serve`, listed in `route:list`, included in the build manifest), the output can be customized by rebinding the `RobotsTxtGenerator` class in the service container, and a user-defined `robots.txt` page replaces the generated one entirely. +- Hyde now generates an [`llms.txt`](https://llmstxt.org/) file for the site out of the box, so that AI services and agents can discover your content without crawling your rendered HTML. The file uses the site name as its heading, the optional `hyde.llms.description` as its summary blockquote, and lists your pages as Markdown links grouped into sections. The new `hyde.llms.sections` configuration array maps each page type to the heading it is listed under, in the order the sections appear (page types not listed there are left out of the file entirely), each link uses the page's `abstract` front matter, falling back to its `description`, and individual pages can opt out with `llms: false` front matter. The feature requires a site base URL since the file needs absolute links, and can be disabled with `hyde.llms.enabled`. The page is wired like the sitemap, RSS feed, and robots.txt: it is a real route (served by `hyde serve`, listed in `route:list`, included in the build manifest), the output can be customized by rebinding the `LlmsTxtGenerator` class in the service container, and a user-defined `llms.txt` page replaces the generated one entirely. + + Please note that llms.txt is an emerging standard which is still subject to change, and we are unable to make a backwards compatibility promise while implementing against a moving specification. We expect to change the format of the generated file in minor and patch releases as the standard evolves. We still think that shipping this is better than nothing, assuming you want AI services to read your site — and if you would rather they did not, set `hyde.llms.enabled` to `false` to skip the file. ### Feature Changes diff --git a/UPGRADE.md b/UPGRADE.md index 75b3daa4693..c63e36ebb77 100644 --- a/UPGRADE.md +++ b/UPGRADE.md @@ -350,7 +350,27 @@ disable the feature with `hyde.robots.enabled => false`, or move the contents in `robots.txt` page (which replaces the generated one, using the same registration pattern as the sitemap example above). Crawl rules can be added to the `hyde.robots.disallow` configuration array without any custom code. -## Step 8: Rename Page File Extension References +## Step 8: Decide Whether to Publish the New llms.txt + +Hyde now generates an [`llms.txt`](https://llmstxt.org/) file by default, indexing your site's content for AI +services and agents. It requires a site base URL, since the file links to your pages with absolute URLs, so +sites without one are unaffected. The file only links to pages you already publish, and it lists nothing your +sitemap does not, but it is a deliberate invitation for AI services to read your site. That is a choice worth +making consciously: if you would rather not extend that invitation, set `hyde.llms.enabled` to `false`. + +If you do keep it, the defaults need no configuration. Pages are grouped into sections by page type through the +`hyde.llms.sections` configuration array, and each link is described by the page's `abstract` front matter, +falling back to its `description`, so filling those in improves the file. Individual pages can be left out with +`llms: false` front matter, and page types can be left out by removing them from the sections array. As with the +sitemap and robots.txt, you can replace the file wholesale by registering your own `llms.txt` page, or adjust +the output by rebinding the `LlmsTxtGenerator` class in the service container. + +Be aware that llms.txt is an emerging standard which is still subject to change. We cannot make a backwards +compatibility promise for the generated output while the specification is still moving, and we expect to change +the file format in minor and patch releases as the standard evolves. If you depend on the exact output, pin the +format by registering your own page. + +## Step 9: Rename Page File Extension References The static page class property `$fileExtension` has been renamed to `$sourceExtension`, along with the `fileExtension()` and `setFileExtension()` methods, which are now `sourceExtension()` and `setSourceExtension()`. @@ -402,6 +422,7 @@ Use this checklist to track your upgrade progress: - [ ] Updated `InMemoryPage` calls to supply only one of `contents` and `view` - [ ] Replaced any references to the removed `GenerateSitemap` and `GenerateRssFeed` build tasks with a generator container rebind or a user-defined page - [ ] Confirmed the new generated `robots.txt` does not conflict with an existing one, or disabled it with `hyde.robots.enabled` +- [ ] Decided whether to publish the new generated `llms.txt` for AI services, or disabled it with `hyde.llms.enabled` - [ ] Renamed `$fileExtension`, `fileExtension()`, and `setFileExtension()` to `$sourceExtension`, `sourceExtension()`, and `setSourceExtension()` in custom page classes and call sites ## Troubleshooting From c5e33b7205daf95ea2f0bbfbb398fe43d564e561 Mon Sep 17 00:00:00 2001 From: Emma De Silva Date: Tue, 14 Jul 2026 00:24:50 +0200 Subject: [PATCH 076/117] Mark PR 7 as implemented in the non-HTML pages epic Co-Authored-By: Claude Opus 4.8 --- EPIC_NON_HTML_PAGES.md | 92 +++++++++++++++++++++++++++++++++++++++++- 1 file changed, 91 insertions(+), 1 deletion(-) diff --git a/EPIC_NON_HTML_PAGES.md b/EPIC_NON_HTML_PAGES.md index f4ac6246b9a..6ced7052dc8 100644 --- a/EPIC_NON_HTML_PAGES.md +++ b/EPIC_NON_HTML_PAGES.md @@ -144,6 +144,10 @@ versioned docs route keys like `docs/1.x/index` would false-positive > working. This confirms the subclass override is a workable escape hatch for > first-party pages, but does not settle option (b) for user-land `make()` > callers, which remains the PR 8 call.)* + > *(PR 7: confirmed for `llms.txt` — within the allowlist, needing no override. + > All four first-party generated files have now landed inside the allowlist, so + > the framework never needed option (b); the PR 8 call is purely about the + > power-user `make()` audience.)* ### D3: Sitemap inclusion becomes a page-level concern @@ -179,6 +183,18 @@ a standalone feature in its own right. > listing redirects in a sitemap is an SEO anti-pattern, so an opt-in would only > be a trap. +> **Extended (PR 7):** the same policy shape was reused for llms.txt. +> `HydePage::showInLlmsTxt()` reads the `llms` front matter key with the identical +> resolved-output-path default (so generated non-HTML pages self-exclude), `Redirect` +> overrides it to `false` unconditionally, and it joined the `BaseHydePageUnitTest` +> contract next to `showInSitemap()`. The two methods are deliberately separate +> rather than one generic "machine index" flag: a page's presence in a search-engine +> sitemap and its presence in an AI-facing content index are different editorial +> decisions, and users will want `sitemap: true` with `llms: false` (or the reverse). +> Note the layering — `showInLlmsTxt()` is the *per-page* opt-out, while the +> `hyde.llms.sections` config decides which page *types* are listed at all (PR 7); +> a page is listed only if both allow it. + ### D4: Generators become container-resolved pages; generator actions stay Each generated file is registered as an `InMemoryPage` whose compiled contents @@ -253,6 +269,7 @@ container → fully custom page in code. > asserted through the real `build` command output. The robots.txt equivalent remains > mandatory for PR 6. *(Part B: both paths verified the same way for the feed page.)* > *(PR 6: both paths verified the same way for the robots.txt page.)* +> *(PR 7: both paths verified the same way for the llms.txt page.)* ### D6: No built-in `TextPage` or `.txt` autodiscovery @@ -555,7 +572,7 @@ Implementation notes (branch `v3/non-html-pages-robots`): the removed post-build tasks; robots.txt never had one, and the standard build and realtime compiler (serve test asserts `text/plain`) cover the lifecycle. -### PR 7 — Generated `llms.txt` +### PR 7 — Generated `llms.txt` ✅ Implemented Goal: best-in-class llms.txt support — no other SSG generates this well out of the box. @@ -572,6 +589,79 @@ Goal: best-in-class llms.txt support — no other SSG generates this well out of works, rather than something a user has to discover. - Consider `llms-full.txt` (full page contents) as a follow-up, not in scope. +Implementation notes (branch `v3/non-html-pages-llms-txt`): + +- `LlmsTxtPage` + `LlmsTxtGenerator` land in `Hyde\Framework\Features\TextGenerators` + next to the robots.txt pair (superseding the `GeneratesLlmsTxt` working name, as + PR 6 anticipated), and mirror `RobotsTxtPage` throughout: thin `InMemoryPage` + subclass, container-resolved generator in `compile()` (rebind verified by test), + registered in `HydeCoreExtension::discoverPages()` with the D5 skip check, hidden + from navigation, D3-excluded from the sitemap, and both user override paths verified + end-to-end through the real `build` command. No `build:llms` command, for the same + reason PR 6 added no `build:robots`. +- **Default on, with the opt-out as the documented choice.** `Features::hasLlmsTxt()` + reads `hyde.llms.enabled` (default `true`), so the file ships by default. The + decision the epic demanded: llms.txt lists only already-published pages and surfaces + nothing the sitemap does not, sitemap/RSS/robots are all on by default, and the + actual crawler control plane is robots.txt, not llms.txt — so an opt-*in* would + bury the feature for the majority to protect a minority that a `false` in the config + serves just as well. The opt-out is called out in the config stub, the release notes, + and its own UPGRADE.md step rather than being left for users to discover. +- **Emerging-standard caveat, recorded deliberately.** llms.txt is a proposal, not a + ratified standard, so the generated *format* carries no backwards-compatibility + promise: we expect to change it in minor and patch releases as the spec moves. This + is stated in the config stub, the generator docblock, the release notes, and + UPGRADE.md (which points users who need a frozen format at the user-defined page + tier). Shipping an imperfect llms.txt is judged better than shipping none. +- **Deviation — site URL is required** (unlike robots.txt, which deliberately is not + gated on one). `hasLlmsTxt()` requires `Hyde::hasSiteUrl()`, putting llms.txt in the + sitemap/RSS camp: the file's entire payload is links, and relative links in a file + fetched by an arbitrary agent are a degraded product. Consequence: zero-config sites + without a base URL get no llms.txt, exactly as they get no sitemap. Under + `hyde serve` the realtime compiler overrides the site URL, so the page *is* served + locally (asserted by a `text/plain` serve test). +- **Deviation — sections are a page-class-keyed config map, not glob patterns.** The + epic (and the research doc) sketched `sections`/`exclude` arrays of route-key globs. + Implemented instead as `hyde.llms.sections`, mapping page class to section heading, + because that is the existing Hyde vocabulary for per-page-type configuration + (`hyde.source_directories`, `hyde.output_directories` are the same shape), whereas + Hyde has no glob-matching config anywhere. Matching is by `instanceof`, first match + wins in config order — the same semantics `PageCollection::getPages()` and + `RouteCollection::getRoutes()` already use — so a user's `GuidePage extends + MarkdownPage` inherits the `MarkdownPage` section instead of being silently dropped. + Map order is section order. Crucially, **a page type absent from the map is not + listed at all**, which folds the epic's separate "exclusions" requirement into the + same key: dropping `MarkdownPost::class` removes the blog from the file. That is one + mechanism instead of two, and it needs no configuration for the common case. +- **Deviation — `hyde.description` does not exist.** The epic assumed a site-level + description config key; there is none (only `hyde.rss.description`). Added + `hyde.llms.description`, mirroring the RSS key rather than inventing a global one, + which would have pulled in the `hyde.meta` description tag and page metadata + generation — a cross-cutting change that does not belong in this PR. It is nullable, + and the summary blockquote is omitted when unset (only the H1 is required by the + spec). A future consolidation into a global `hyde.description` remains open. +- **Link descriptions:** the `abstract` front matter added by #2523, falling back to + `description`. #2523 only added `abstract` to the docs *content* — there is no + framework schema support for it, and consistent with PR 4 (which did not add + `sitemap` to `PageSchema::PAGE_SCHEMA` either), neither `abstract` nor `llms` was + added to the schema; both are documented on the accessors that read them. Whitespace + in descriptions is collapsed to a single line, since a multi-line YAML block scalar + would otherwise emit a broken list item — this is *not* a "verbatim string" case like + the robots.txt disallow rules, where PR 6 correctly refused to normalize, because + here the value is prose embedded in a line-oriented format rather than an exact-match + rule value. Section-map entries are validated like the robots config (per-entry + `InvalidConfigurationException` naming the key and offending index). +- **Deviation — 404 pages are never listed.** An error page is not content, and every + real-world llms.txt excludes it. Filtered in the generator by identifier, mirroring + the `$identifier === '404'` special case `SitemapGenerator` already carries, and kept + as an overridable `isErrorPage()` predicate rather than an inline magic string. This + is a generator-level curation concern, not a page-level default, which is why it did + not go into `showInLlmsTxt()` (the sitemap precedent likewise keeps its 404 handling + in the generator). +- Everything else the epic left implicit held: `llms.txt` is within the D2 allowlist, + and the generated page self-excludes from its own listing (and the sitemap) through + the D3 resolved-output-path default. + ### PR 8 — Documentation & release notes - Document in-code virtual pages, `sitemap: false` front matter, robots/llms config, From 89d4db8f49044cf05b274d0b1b49dac3e75bf6b9 Mon Sep 17 00:00:00 2001 From: Emma De Silva Date: Tue, 14 Jul 2026 00:26:32 +0200 Subject: [PATCH 077/117] Guard the llms.txt section config against drifting from the generator default Co-Authored-By: Claude Opus 4.8 --- .../framework/tests/Unit/ConfigFileTest.php | 25 +++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/packages/framework/tests/Unit/ConfigFileTest.php b/packages/framework/tests/Unit/ConfigFileTest.php index 88636422d38..fe9976c16b8 100644 --- a/packages/framework/tests/Unit/ConfigFileTest.php +++ b/packages/framework/tests/Unit/ConfigFileTest.php @@ -6,6 +6,7 @@ use Hyde\Enums\Feature; use Hyde\Foundation\HydeCoreExtension; +use Hyde\Framework\Features\TextGenerators\LlmsTxtGenerator; use Hyde\Hyde; use Hyde\Pages\BladePage; use Hyde\Pages\DocumentationPage; @@ -13,6 +14,7 @@ use Hyde\Pages\MarkdownPage; use Hyde\Pages\MarkdownPost; use Hyde\Testing\UnitTestCase; +use ReflectionClass; /** * @see \Hyde\Framework\Testing\Unit\HydeConfigFilesAreMatchingTest @@ -81,6 +83,29 @@ public function testDefaultFeaturesArrayMatchesDefaultFeatures() ->toBe(Feature::cases()); } + public function testDefaultLlmsSectionsValuesMatchDeclaredValues() + { + expect($this->getConfig('llms')['sections'])->toBe([ + HtmlPage::class => 'Pages', + BladePage::class => 'Pages', + MarkdownPage::class => 'Pages', + DocumentationPage::class => 'Documentation', + MarkdownPost::class => 'Blog Posts', + ]); + } + + public function testDefaultLlmsSectionsMatchTheGeneratorFallbackUsedWhenTheOptionIsAbsent() + { + expect($this->getConfig('llms')['sections']) + ->toBe((new ReflectionClass(LlmsTxtGenerator::class))->getConstant('DEFAULT_SECTIONS')); + } + + public function testDefaultLlmsSectionsCoverAllCoreExtensionClasses() + { + expect(array_keys($this->getConfig('llms')['sections'])) + ->toEqualCanonicalizing(HydeCoreExtension::getPageClasses()); + } + protected function getConfig(string $option): mixed { return (require Hyde::vendorPath('config/hyde.php'))[$option]; From 8fa254e98ee7a84d142fefadf8626cc814026216 Mon Sep 17 00:00:00 2001 From: Emma De Silva Date: Tue, 14 Jul 2026 00:35:59 +0200 Subject: [PATCH 078/117] Derive llms.txt inclusion from sitemap inclusion instead of a new page method Removes HydePage::showInLlmsTxt() and its test contract entry. The generator now reads `llms` front matter defaulting to the page's sitemap inclusion, which already excludes generated non-HTML pages and redirects, keeping per-page control without adding public API to every page class. Co-Authored-By: Claude Opus 4.8 --- .../TextGenerators/LlmsTxtGenerator.php | 65 +++++-------- .../framework/src/Pages/Concerns/HydePage.php | 15 --- .../framework/src/Support/Models/Redirect.php | 5 - .../tests/Feature/LlmsTxtGeneratorTest.php | 94 +++++-------------- .../tests/Feature/LlmsTxtPageTest.php | 1 - .../framework/tests/Feature/RedirectTest.php | 5 - .../tests/Unit/Pages/BladePageUnitTest.php | 6 -- .../Unit/Pages/DocumentationPageUnitTest.php | 6 -- .../tests/Unit/Pages/HtmlPageUnitTest.php | 6 -- .../tests/Unit/Pages/InMemoryPageUnitTest.php | 15 --- .../tests/Unit/Pages/MarkdownPageUnitTest.php | 6 -- .../tests/Unit/Pages/MarkdownPostUnitTest.php | 6 -- .../src/Common/BaseHydePageUnitTest.php | 2 - 13 files changed, 44 insertions(+), 188 deletions(-) diff --git a/packages/framework/src/Framework/Features/TextGenerators/LlmsTxtGenerator.php b/packages/framework/src/Framework/Features/TextGenerators/LlmsTxtGenerator.php index dc0fd565212..052cedf9b60 100644 --- a/packages/framework/src/Framework/Features/TextGenerators/LlmsTxtGenerator.php +++ b/packages/framework/src/Framework/Features/TextGenerators/LlmsTxtGenerator.php @@ -14,15 +14,13 @@ use Hyde\Pages\Concerns\HydePage; use Hyde\Support\Models\Route; use Hyde\Foundation\Facades\Routes; -use Hyde\Framework\Exceptions\InvalidConfigurationException; use function array_fill_keys; use function array_filter; use function array_values; use function filled; -use function get_debug_type; +use function filter_var; use function implode; -use function is_a; use function is_string; use function preg_replace; use function sprintf; @@ -32,10 +30,12 @@ * Generates the contents for the llms.txt file. * * The file lists the site's content for AI services and agents, using the site name as the - * heading, the configured `hyde.llms.description` as the summary blockquote, and a section - * of links for each page type registered in the `hyde.llms.sections` config, in that order. + * heading, the configured `hyde.llms.description` as the summary blockquote, and the site's + * pages as Markdown links, grouped into a section for each page type. * - * Pages can opt out of the listing with `llms: false` front matter, and the `abstract` front + * Pages are listed when they are included in the sitemap, so a page excluded from the sitemap + * is left out of this file as well. Use `llms: false` front matter to leave out a single page + * regardless of its sitemap state, and `llms: true` to add one back. The `abstract` front * matter of a page, falling back to its `description`, is used as its link description. * * Note that llms.txt is an emerging standard which is still subject to change, so the format @@ -49,9 +49,12 @@ class LlmsTxtGenerator /** * The page types listed in the file, and the section heading each is listed under. * + * Sections are written in the order declared here, and pages of a type not listed + * here, like the virtual pages Hyde generates, are not added to the file. + * * @var array, string> */ - protected const DEFAULT_SECTIONS = [ + protected const SECTIONS = [ HtmlPage::class => 'Pages', BladePage::class => 'Pages', MarkdownPage::class => 'Pages', @@ -90,24 +93,22 @@ protected function getLines(): array } /** - * Group the site's routes into their configured sections, discarding empty ones. + * Group the site's listed routes into their sections, discarding the empty ones. * * @return array> */ protected function getSections(): array { - $map = $this->getSectionMap(); - - $sections = array_fill_keys(array_values($map), []); + $sections = array_fill_keys(array_values(static::SECTIONS), []); - Routes::all()->each(function (Route $route) use ($map, &$sections): void { + Routes::all()->each(function (Route $route) use (&$sections): void { $page = $route->getPage(); - if (! $page->showInLlmsTxt() || $this->isErrorPage($page)) { + if (! $this->shouldListPage($page)) { return; } - foreach ($map as $pageClass => $heading) { + foreach (static::SECTIONS as $pageClass => $heading) { if ($page instanceof $pageClass) { $sections[$heading][] = $route; @@ -120,40 +121,16 @@ protected function getSections(): array } /** - * @return array, string> - * - * @throws \Hyde\Framework\Exceptions\InvalidConfigurationException If an entry is not a page class mapped to a heading. + * Pages follow their sitemap inclusion unless they set the `llms` front matter key. + * Error pages are never listed, as they are not content. */ - protected function getSectionMap(): array + protected function shouldListPage(HydePage $page): bool { - /** @var array, string> $sections */ - $sections = Config::getArray('hyde.llms.sections', static::DEFAULT_SECTIONS); - - foreach ($sections as $pageClass => $heading) { - if (! is_string($pageClass) || ! is_a($pageClass, HydePage::class, true)) { - throw new InvalidConfigurationException(sprintf( - 'Invalid `hyde.llms.sections` entry at index [%s]: each key must be a page class extending %s.', - $pageClass, HydePage::class - ), 'hyde', 'sections'); - } - - if (! is_string($heading) || ! filled($heading)) { - throw new InvalidConfigurationException(sprintf( - 'Invalid `hyde.llms.sections` entry at index [%s]: each section heading must be a non-empty string, %s given.', - $pageClass, get_debug_type($heading) - ), 'hyde', 'sections'); - } + if ($page->getIdentifier() === '404') { + return false; } - return $sections; - } - - /** - * Error pages are not content, so they are never listed in the file. - */ - protected function isErrorPage(HydePage $page): bool - { - return $page->getIdentifier() === '404'; + return filter_var($page->matter('llms', $page->showInSitemap()), FILTER_VALIDATE_BOOLEAN); } protected function makeLink(Route $route): string diff --git a/packages/framework/src/Pages/Concerns/HydePage.php b/packages/framework/src/Pages/Concerns/HydePage.php index b0fe558c9d2..d03d88e02a6 100644 --- a/packages/framework/src/Pages/Concerns/HydePage.php +++ b/packages/framework/src/Pages/Concerns/HydePage.php @@ -421,21 +421,6 @@ public function showInSitemap(): bool return filter_var($this->matter('sitemap', str_ends_with($this->getOutputPath(), '.html')), FILTER_VALIDATE_BOOLEAN); } - /** - * Can the page be listed in the llms.txt file? - * - * It can be explicitly set in the front matter using the `llms` key, - * otherwise it defaults to true for pages compiled to HTML files, and - * false for pages compiled to non-HTML files like `robots.txt`. - * - * Note that only pages of a type registered in the `hyde.llms.sections` - * config are listed in the file, regardless of this value. - */ - public function showInLlmsTxt(): bool - { - return filter_var($this->matter('llms', str_ends_with($this->getOutputPath(), '.html')), FILTER_VALIDATE_BOOLEAN); - } - /** * Get the priority of the page in the navigation menu. */ diff --git a/packages/framework/src/Support/Models/Redirect.php b/packages/framework/src/Support/Models/Redirect.php index 9a3204e9112..d85baf1fa82 100644 --- a/packages/framework/src/Support/Models/Redirect.php +++ b/packages/framework/src/Support/Models/Redirect.php @@ -50,11 +50,6 @@ public function showInSitemap(): bool return false; } - public function showInLlmsTxt(): bool - { - return false; - } - /** * @throws \Hyde\Framework\Exceptions\InvalidConfigurationException If the path ends in a non-HTML output extension, * as meta refresh redirects only work for HTML pages. diff --git a/packages/framework/tests/Feature/LlmsTxtGeneratorTest.php b/packages/framework/tests/Feature/LlmsTxtGeneratorTest.php index 18ff53f6ecf..eaf21ee12f9 100644 --- a/packages/framework/tests/Feature/LlmsTxtGeneratorTest.php +++ b/packages/framework/tests/Feature/LlmsTxtGeneratorTest.php @@ -6,12 +6,9 @@ use Hyde\Testing\TestCase; use Hyde\Pages\MarkdownPage; -use Hyde\Pages\MarkdownPost; -use Hyde\Pages\DocumentationPage; use Hyde\Support\Models\Route; use Hyde\Support\Models\Redirect; use Hyde\Foundation\Facades\Routes; -use Hyde\Framework\Exceptions\InvalidConfigurationException; use Hyde\Framework\Features\TextGenerators\LlmsTxtGenerator; #[\PHPUnit\Framework\Attributes\CoversClass(\Hyde\Framework\Features\TextGenerators\LlmsTxtGenerator::class)] @@ -50,7 +47,7 @@ public function testOmitsSummaryBlockquoteWhenNoDescriptionIsConfigured() $this->assertStringStartsWith("# HydePHP\n\n## Pages", $this->generate()); } - public function testGroupsPagesIntoTheirConfiguredSections() + public function testGroupsPagesIntoSectionsByPageType() { $this->file('_pages/about.md', '# About'); $this->file('_docs/installation.md', '# Installation'); @@ -75,30 +72,6 @@ public function testGroupsPagesIntoTheirConfiguredSections() TXT, $this->generate()); } - public function testSectionsFollowTheOrderOfTheConfiguredMap() - { - config(['hyde.llms.sections' => [ - MarkdownPost::class => 'Blog Posts', - DocumentationPage::class => 'Documentation', - ]]); - - $this->file('_docs/installation.md', '# Installation'); - $this->file('_posts/hello-world.md', '# Hello World'); - - $this->assertSame(<<<'TXT' - # HydePHP - - ## Blog Posts - - - [Hello World](https://example.com/posts/hello-world.html) - - ## Documentation - - - [Installation](https://example.com/docs/installation.html) - - TXT, $this->generate()); - } - public function testEmptySectionsAreOmitted() { $this->assertStringNotContainsString('## Documentation', $this->generate()); @@ -143,6 +116,20 @@ public function testPagesCanOptOutUsingFrontMatter() $this->assertStringNotContainsString('Private', $this->generate()); } + public function testPagesExcludedFromTheSitemapAreNotListed() + { + $this->markdown('_pages/private.md', '# Private', ['sitemap' => false]); + + $this->assertStringNotContainsString('Private', $this->generate()); + } + + public function testPagesExcludedFromTheSitemapCanBeAddedBackUsingFrontMatter() + { + $this->markdown('_pages/private.md', '# Private', ['sitemap' => false, 'llms' => true]); + + $this->assertStringContainsString('- [Private](https://example.com/private.html)', $this->generate()); + } + public function testErrorPagesAreNotListed() { $this->assertStringNotContainsString('404', $this->generate()); @@ -157,26 +144,21 @@ public function testGeneratedNonHtmlPagesAreNotListed() $this->assertStringNotContainsString('sitemap.xml', $contents); } - public function testRedirectsAreNotListed() + public function testVirtualPagesLikeTheDocumentationSearchPageAreNotListed() { - config(['hyde.llms.sections' => [Redirect::class => 'Redirects']]); + $this->file('_docs/installation.md', '# Installation'); - Routes::addRoute(new Route(new Redirect('old-page', 'new-page'))); + $contents = $this->generate(); - $this->assertStringNotContainsString('old-page', $this->generate()); + $this->assertStringContainsString('- [Installation](https://example.com/docs/installation.html)', $contents); + $this->assertStringNotContainsString('docs/search', $contents); } - public function testPageTypesNotInTheSectionsConfigAreNotListed() + public function testRedirectsAreNotListed() { - config(['hyde.llms.sections' => [DocumentationPage::class => 'Documentation']]); - - $this->file('_posts/hello-world.md', '# Hello World'); - $this->file('_docs/installation.md', '# Installation'); - - $contents = $this->generate(); + Routes::addRoute(new Route(new Redirect('old-page', 'new-page'))); - $this->assertStringContainsString('Installation', $contents); - $this->assertStringNotContainsString('Hello World', $contents); + $this->assertStringNotContainsString('old-page', $this->generate()); } public function testCustomPageClassesAreListedUnderTheirParentClassSection() @@ -195,36 +177,6 @@ public function testPrettyUrlsAreUsedWhenEnabled() $this->assertStringContainsString('- [Installation](https://example.com/docs/installation)', $this->generate()); } - public function testSectionKeyThatIsNotAPageClassFailsWithConfigurationException() - { - config(['hyde.llms.sections' => ['NotAPageClass' => 'Pages']]); - - $this->expectException(InvalidConfigurationException::class); - $this->expectExceptionMessage('Invalid `hyde.llms.sections` entry at index [NotAPageClass]: each key must be a page class extending Hyde\Pages\Concerns\HydePage.'); - - $this->generate(); - } - - public function testNonStringSectionHeadingFailsWithConfigurationException() - { - config(['hyde.llms.sections' => [MarkdownPage::class => 123]]); - - $this->expectException(InvalidConfigurationException::class); - $this->expectExceptionMessage('Invalid `hyde.llms.sections` entry at index [Hyde\Pages\MarkdownPage]: each section heading must be a non-empty string, int given.'); - - $this->generate(); - } - - public function testEmptySectionHeadingFailsWithConfigurationException() - { - config(['hyde.llms.sections' => [MarkdownPage::class => '']]); - - $this->expectException(InvalidConfigurationException::class); - $this->expectExceptionMessage('Invalid `hyde.llms.sections` entry at index [Hyde\Pages\MarkdownPage]: each section heading must be a non-empty string, string given.'); - - $this->generate(); - } - protected function generate(): string { return (new LlmsTxtGenerator())->generate(); diff --git a/packages/framework/tests/Feature/LlmsTxtPageTest.php b/packages/framework/tests/Feature/LlmsTxtPageTest.php index da835ded6b8..84a53880e4c 100644 --- a/packages/framework/tests/Feature/LlmsTxtPageTest.php +++ b/packages/framework/tests/Feature/LlmsTxtPageTest.php @@ -71,7 +71,6 @@ public function testLlmsTxtPageIsHiddenFromNavigationAndExcludedFromTheSitemap() $this->assertFalse($page->showInNavigation()); $this->assertFalse($page->showInSitemap()); - $this->assertFalse($page->showInLlmsTxt()); } public function testLlmsTxtPageCompilesUsingTheLlmsTxtGenerator() diff --git a/packages/framework/tests/Feature/RedirectTest.php b/packages/framework/tests/Feature/RedirectTest.php index 73a08d73a07..9e4fcf35715 100644 --- a/packages/framework/tests/Feature/RedirectTest.php +++ b/packages/framework/tests/Feature/RedirectTest.php @@ -100,9 +100,4 @@ public function testRedirectsAreHiddenFromSitemaps() { $this->assertFalse((new Redirect('foo', 'bar'))->showInSitemap()); } - - public function testRedirectsAreHiddenFromLlmsTxt() - { - $this->assertFalse((new Redirect('foo', 'bar'))->showInLlmsTxt()); - } } diff --git a/packages/framework/tests/Unit/Pages/BladePageUnitTest.php b/packages/framework/tests/Unit/Pages/BladePageUnitTest.php index 0e21861795e..742b9d763dd 100644 --- a/packages/framework/tests/Unit/Pages/BladePageUnitTest.php +++ b/packages/framework/tests/Unit/Pages/BladePageUnitTest.php @@ -92,12 +92,6 @@ public function testShowInSitemap() $this->assertFalse((new BladePage('foo', ['sitemap' => false]))->showInSitemap()); } - public function testShowInLlmsTxt() - { - $this->assertTrue((new BladePage())->showInLlmsTxt()); - $this->assertFalse((new BladePage('foo', ['llms' => false]))->showInLlmsTxt()); - } - public function testNavigationMenuPriority() { $this->assertSame(999, (new BladePage())->navigationMenuPriority()); diff --git a/packages/framework/tests/Unit/Pages/DocumentationPageUnitTest.php b/packages/framework/tests/Unit/Pages/DocumentationPageUnitTest.php index bb8b8cc5f43..791ef32b70d 100644 --- a/packages/framework/tests/Unit/Pages/DocumentationPageUnitTest.php +++ b/packages/framework/tests/Unit/Pages/DocumentationPageUnitTest.php @@ -94,12 +94,6 @@ public function testShowInSitemap() $this->assertFalse((new DocumentationPage('foo', ['sitemap' => false]))->showInSitemap()); } - public function testShowInLlmsTxt() - { - $this->assertTrue((new DocumentationPage())->showInLlmsTxt()); - $this->assertFalse((new DocumentationPage('foo', ['llms' => false]))->showInLlmsTxt()); - } - public function testNavigationMenuPriority() { $this->assertSame(999, (new DocumentationPage())->navigationMenuPriority()); diff --git a/packages/framework/tests/Unit/Pages/HtmlPageUnitTest.php b/packages/framework/tests/Unit/Pages/HtmlPageUnitTest.php index 4b9b4a0ed01..d8bab24b266 100644 --- a/packages/framework/tests/Unit/Pages/HtmlPageUnitTest.php +++ b/packages/framework/tests/Unit/Pages/HtmlPageUnitTest.php @@ -128,12 +128,6 @@ public function testShowInSitemap() $this->assertFalse((new HtmlPage('foo', ['sitemap' => false]))->showInSitemap()); } - public function testShowInLlmsTxt() - { - $this->assertTrue((new HtmlPage())->showInLlmsTxt()); - $this->assertFalse((new HtmlPage('foo', ['llms' => false]))->showInLlmsTxt()); - } - public function testNavigationMenuPriority() { $this->assertSame(999, (new HtmlPage())->navigationMenuPriority()); diff --git a/packages/framework/tests/Unit/Pages/InMemoryPageUnitTest.php b/packages/framework/tests/Unit/Pages/InMemoryPageUnitTest.php index d2fdcd6923b..17b77ae2524 100644 --- a/packages/framework/tests/Unit/Pages/InMemoryPageUnitTest.php +++ b/packages/framework/tests/Unit/Pages/InMemoryPageUnitTest.php @@ -141,21 +141,6 @@ public function testShowInSitemapIsFalseForPagesWithNonHtmlOutputPaths() $this->assertTrue((new InMemoryPage('robots.txt', ['sitemap' => true]))->showInSitemap()); } - public function testShowInLlmsTxt() - { - $this->assertTrue((new InMemoryPage('foo'))->showInLlmsTxt()); - $this->assertFalse((new InMemoryPage('foo', ['llms' => false]))->showInLlmsTxt()); - } - - public function testShowInLlmsTxtIsFalseForPagesWithNonHtmlOutputPaths() - { - $this->assertFalse((new InMemoryPage('robots.txt'))->showInLlmsTxt()); - $this->assertFalse((new InMemoryPage('data.json'))->showInLlmsTxt()); - $this->assertFalse((new InMemoryPage('custom.xml'))->showInLlmsTxt()); - - $this->assertTrue((new InMemoryPage('robots.txt', ['llms' => true]))->showInLlmsTxt()); - } - public function testNavigationMenuPriority() { $this->assertSame(999, (new InMemoryPage('foo'))->navigationMenuPriority()); diff --git a/packages/framework/tests/Unit/Pages/MarkdownPageUnitTest.php b/packages/framework/tests/Unit/Pages/MarkdownPageUnitTest.php index 5b1d16ac6d7..8b335b43845 100644 --- a/packages/framework/tests/Unit/Pages/MarkdownPageUnitTest.php +++ b/packages/framework/tests/Unit/Pages/MarkdownPageUnitTest.php @@ -130,12 +130,6 @@ public function testShowInSitemap() $this->assertFalse((new MarkdownPage('foo', ['sitemap' => false]))->showInSitemap()); } - public function testShowInLlmsTxt() - { - $this->assertTrue((new MarkdownPage())->showInLlmsTxt()); - $this->assertFalse((new MarkdownPage('foo', ['llms' => false]))->showInLlmsTxt()); - } - public function testNavigationMenuPriority() { $this->assertSame(999, (new MarkdownPage())->navigationMenuPriority()); diff --git a/packages/framework/tests/Unit/Pages/MarkdownPostUnitTest.php b/packages/framework/tests/Unit/Pages/MarkdownPostUnitTest.php index f5665cca813..c8c30195dbe 100644 --- a/packages/framework/tests/Unit/Pages/MarkdownPostUnitTest.php +++ b/packages/framework/tests/Unit/Pages/MarkdownPostUnitTest.php @@ -130,12 +130,6 @@ public function testShowInSitemap() $this->assertFalse((new MarkdownPost('foo', ['sitemap' => false]))->showInSitemap()); } - public function testShowInLlmsTxt() - { - $this->assertTrue((new MarkdownPost())->showInLlmsTxt()); - $this->assertFalse((new MarkdownPost('foo', ['llms' => false]))->showInLlmsTxt()); - } - public function testNavigationMenuPriority() { $this->assertSame(10, (new MarkdownPost())->navigationMenuPriority()); diff --git a/packages/testing/src/Common/BaseHydePageUnitTest.php b/packages/testing/src/Common/BaseHydePageUnitTest.php index ea892e812fb..47b3791e329 100644 --- a/packages/testing/src/Common/BaseHydePageUnitTest.php +++ b/packages/testing/src/Common/BaseHydePageUnitTest.php @@ -94,8 +94,6 @@ abstract public function testShowInNavigation(); abstract public function testShowInSitemap(); - abstract public function testShowInLlmsTxt(); - abstract public function testGetSourcePath(); abstract public function testGetLink(); From 00ad993a4ffd99cb8ccae43eb71a4226a8958ae2 Mon Sep 17 00:00:00 2001 From: Emma De Silva Date: Tue, 14 Jul 2026 00:35:59 +0200 Subject: [PATCH 079/117] Move the llms.txt sections from config into the generator The page types Hyde ships are known to the framework, so grouping them needed no user input to be correct. Dropping the config removes its validation, exception path, and drift guard, and leaves the llms config at two keys. Custom sections remain available by overriding the generator and rebinding it in the container. Co-Authored-By: Claude Opus 4.8 --- EPIC_NON_HTML_PAGES.md | 86 ++++++++++++------- HYDEPHP_V3_PLANNING.md | 2 +- UPGRADE.md | 12 +-- config/hyde.php | 31 ++----- packages/framework/config/hyde.php | 31 ++----- .../framework/tests/Unit/ConfigFileTest.php | 25 ------ 6 files changed, 76 insertions(+), 111 deletions(-) diff --git a/EPIC_NON_HTML_PAGES.md b/EPIC_NON_HTML_PAGES.md index 6ced7052dc8..174d23b8a4d 100644 --- a/EPIC_NON_HTML_PAGES.md +++ b/EPIC_NON_HTML_PAGES.md @@ -183,17 +183,22 @@ a standalone feature in its own right. > listing redirects in a sitemap is an SEO anti-pattern, so an opt-in would only > be a trap. -> **Extended (PR 7):** the same policy shape was reused for llms.txt. -> `HydePage::showInLlmsTxt()` reads the `llms` front matter key with the identical -> resolved-output-path default (so generated non-HTML pages self-exclude), `Redirect` -> overrides it to `false` unconditionally, and it joined the `BaseHydePageUnitTest` -> contract next to `showInSitemap()`. The two methods are deliberately separate -> rather than one generic "machine index" flag: a page's presence in a search-engine -> sitemap and its presence in an AI-facing content index are different editorial -> decisions, and users will want `sitemap: true` with `llms: false` (or the reverse). -> Note the layering — `showInLlmsTxt()` is the *per-page* opt-out, while the -> `hyde.llms.sections` config decides which page *types* are listed at all (PR 7); -> a page is listed only if both allow it. +> **Reused, not duplicated (PR 7):** llms.txt inclusion is *derived from* +> `showInSitemap()` rather than getting a parallel `showInLlmsTxt()` page method. An +> initial PR 7 implementation added the mirror method (plus a `Redirect` override, a +> `BaseHydePageUnitTest` contract entry, and six page unit test implementations) and it +> was cut in review as unearned surface: `showInSitemap()` already answers the exact +> question llms.txt needs — "is this page part of the machine-readable index of my +> site" — and its resolved-output-path default already excludes every generated +> non-HTML page and redirect for free. The `LlmsTxtGenerator` instead reads +> `matter('llms', $page->showInSitemap())`, so users keep per-page control in both +> directions (`llms: false` to drop a sitemapped page, `llms: true` to add back one +> that is `sitemap: false`) with zero new public API on `HydePage`. +> The coupling is a deliberate, documented rule ("a page excluded from the sitemap is +> excluded from llms.txt"), and it is the *less* surprising default: a user who hides +> a page from search engines does not expect it advertised to AI agents. If independent +> control ever proves necessary, promoting the front matter key to a real +> `showInLlmsTxt()` method later is additive and non-breaking. ### D4: Generators become container-resolved pages; generator actions stay @@ -620,19 +625,25 @@ Implementation notes (branch `v3/non-html-pages-llms-txt`): without a base URL get no llms.txt, exactly as they get no sitemap. Under `hyde serve` the realtime compiler overrides the site URL, so the page *is* served locally (asserted by a `text/plain` serve test). -- **Deviation — sections are a page-class-keyed config map, not glob patterns.** The - epic (and the research doc) sketched `sections`/`exclude` arrays of route-key globs. - Implemented instead as `hyde.llms.sections`, mapping page class to section heading, - because that is the existing Hyde vocabulary for per-page-type configuration - (`hyde.source_directories`, `hyde.output_directories` are the same shape), whereas - Hyde has no glob-matching config anywhere. Matching is by `instanceof`, first match - wins in config order — the same semantics `PageCollection::getPages()` and - `RouteCollection::getRoutes()` already use — so a user's `GuidePage extends - MarkdownPage` inherits the `MarkdownPage` section instead of being silently dropped. - Map order is section order. Crucially, **a page type absent from the map is not - listed at all**, which folds the epic's separate "exclusions" requirement into the - same key: dropping `MarkdownPost::class` removes the blog from the file. That is one - mechanism instead of two, and it needs no configuration for the common case. +- **Deviation — there is no section configuration at all.** The epic (and the research + doc) asked for "config for section grouping/exclusions", sketched as route-key globs. + An initial implementation shipped a `hyde.llms.sections` map of page class to section + heading; it was cut in review. Hyde already *knows* its page types, so grouping needs + no user input to be correct, and the config bought only heading renames and bulk + exclusion — rare needs, paid for by every user in config-file surface (a five-entry + array, entry validation, an exception path, and a comment explaining that omitting a + class silently drops those pages, which is a trap the framework did not previously + have). The section map now lives as a `protected const SECTIONS` on the generator: + page classes are matched with `instanceof` in declaration order — the same semantics + `PageCollection::getPages()` and `RouteCollection::getRoutes()` use — so a user's + `GuidePage extends MarkdownPage` lands in the `Pages` section, while every + `InMemoryPage` descendant (the generated pages, redirects, and the documentation + search page) is absent from the map and therefore never listed. Users who genuinely + need different sections have the D4 tier already advertised for exactly this: + override the generator and rebind it in the container. The config surface is now two + keys, `enabled` and `description`, matching the size of the `rss` and `robots` blocks. + *Design rule this records: a configuration option must be justified against the + container-rebind tier that already exists, not merely be useful in principle.* - **Deviation — `hyde.description` does not exist.** The epic assumed a site-level description config key; there is none (only `hyde.rss.description`). Added `hyde.llms.description`, mirroring the RSS key rather than inventing a global one, @@ -649,19 +660,32 @@ Implementation notes (branch `v3/non-html-pages-llms-txt`): would otherwise emit a broken list item — this is *not* a "verbatim string" case like the robots.txt disallow rules, where PR 6 correctly refused to normalize, because here the value is prose embedded in a line-oriented format rather than an exact-match - rule value. Section-map entries are validated like the robots config (per-entry - `InvalidConfigurationException` naming the key and offending index). + rule value. With the sections config gone, no llms config entry needs validation: both + remaining keys are scalars read through the typed `Config` accessors. - **Deviation — 404 pages are never listed.** An error page is not content, and every real-world llms.txt excludes it. Filtered in the generator by identifier, mirroring - the `$identifier === '404'` special case `SitemapGenerator` already carries, and kept - as an overridable `isErrorPage()` predicate rather than an inline magic string. This - is a generator-level curation concern, not a page-level default, which is why it did - not go into `showInLlmsTxt()` (the sitemap precedent likewise keeps its 404 handling - in the generator). + the `$identifier === '404'` special case `SitemapGenerator` already carries. This is a + generator-level curation concern rather than a page-level default (the sitemap + precedent likewise keeps its 404 handling in the generator), and it is the reason the + sitemap-derived inclusion rule is not a bare alias for `showInSitemap()`. - Everything else the epic left implicit held: `llms.txt` is within the D2 allowlist, and the generated page self-excludes from its own listing (and the sitemap) through the D3 resolved-output-path default. +> **Scope correction (post-implementation review).** The first cut of this PR was +> overbuilt for the value delivered, and two pieces were cut back before merge: the +> `hyde.llms.sections` config (see the sections deviation above) and the +> `HydePage::showInLlmsTxt()` page method (see the D3 "Reused, not duplicated" note). +> Between them they added a public method to every page class, an entry in the +> `BaseHydePageUnitTest` contract with six implementations, a config array with its own +> validation and exception path, and a config comment long enough to advertise that the +> option was not simple. All of it served needs that the existing `showInSitemap()` +> front matter and the D4 container-rebind tier already served. The feature's user-facing +> capability is unchanged; only the surface shrank. Worth carrying into PR 8 and any +> future generated-page work: **the D4 rebind tier is the default answer for +> customization, and a new config key or page-model method has to beat it, not merely +> be useful.** + ### PR 8 — Documentation & release notes - Document in-code virtual pages, `sitemap: false` front matter, robots/llms config, diff --git a/HYDEPHP_V3_PLANNING.md b/HYDEPHP_V3_PLANNING.md index fcb95ee8cc3..9c401841cc6 100644 --- a/HYDEPHP_V3_PLANNING.md +++ b/HYDEPHP_V3_PLANNING.md @@ -28,7 +28,7 @@ Having this document in code lets us know the devlopment state at any given poin - Pages can now control their own sitemap inclusion. Set `sitemap: false` in a page's front matter to exclude it from the generated `sitemap.xml`, or override the new `HydePage::showInSitemap()` method in custom page classes. Pages compiled to non-HTML output files (like `robots.txt`) are excluded by default, and `sitemap: true` front matter opts such a page back in. - The sitemap and RSS feed are now first-class pages instead of post-build side effects: when the respective feature is enabled, `sitemap.xml` and the RSS feed (`feed.xml`, or the configured `hyde.rss.filename`) are registered as routes, so they are served by `hyde serve`, listed in `route:list`, included in the build manifest, and compiled through the standard site build. The output can be customized by rebinding the `SitemapGenerator` or `RssFeedGenerator` class in the service container, and registering a user-defined page with the same route key (from a service provider, booting callback, or extension) replaces the generated page entirely. - Hyde now generates a `robots.txt` file for the site out of the box. The default output allows all crawlers, and links to the sitemap when that feature is enabled. Rule values listed in the new `hyde.robots.disallow` configuration array are written verbatim as `Disallow` rules (so wildcard patterns are supported), and the file can be disabled entirely with `hyde.robots.enabled`. The page is wired like the sitemap and RSS feed: it is a real route (served by `hyde serve`, listed in `route:list`, included in the build manifest), the output can be customized by rebinding the `RobotsTxtGenerator` class in the service container, and a user-defined `robots.txt` page replaces the generated one entirely. -- Hyde now generates an [`llms.txt`](https://llmstxt.org/) file for the site out of the box, so that AI services and agents can discover your content without crawling your rendered HTML. The file uses the site name as its heading, the optional `hyde.llms.description` as its summary blockquote, and lists your pages as Markdown links grouped into sections. The new `hyde.llms.sections` configuration array maps each page type to the heading it is listed under, in the order the sections appear (page types not listed there are left out of the file entirely), each link uses the page's `abstract` front matter, falling back to its `description`, and individual pages can opt out with `llms: false` front matter. The feature requires a site base URL since the file needs absolute links, and can be disabled with `hyde.llms.enabled`. The page is wired like the sitemap, RSS feed, and robots.txt: it is a real route (served by `hyde serve`, listed in `route:list`, included in the build manifest), the output can be customized by rebinding the `LlmsTxtGenerator` class in the service container, and a user-defined `llms.txt` page replaces the generated one entirely. +- Hyde now generates an [`llms.txt`](https://llmstxt.org/) file for the site out of the box, so that AI services and agents can discover your content without crawling your rendered HTML. The file uses the site name as its heading, the optional `hyde.llms.description` as its summary blockquote, and lists your pages as Markdown links, grouped into a section for each page type (Pages, Documentation, and Blog Posts). Each link is described by the page's `abstract` front matter, falling back to its `description`. Pages follow their sitemap inclusion, so a page excluded from the sitemap is left out of this file as well, and `llms: false` front matter leaves out a single page regardless. The feature requires a site base URL since the file needs absolute links, and can be disabled with `hyde.llms.enabled`. The page is wired like the sitemap, RSS feed, and robots.txt: it is a real route (served by `hyde serve`, listed in `route:list`, included in the build manifest), the output can be customized by rebinding the `LlmsTxtGenerator` class in the service container, and a user-defined `llms.txt` page replaces the generated one entirely. Please note that llms.txt is an emerging standard which is still subject to change, and we are unable to make a backwards compatibility promise while implementing against a moving specification. We expect to change the format of the generated file in minor and patch releases as the standard evolves. We still think that shipping this is better than nothing, assuming you want AI services to read your site — and if you would rather they did not, set `hyde.llms.enabled` to `false` to skip the file. diff --git a/UPGRADE.md b/UPGRADE.md index c63e36ebb77..45db7d19ef2 100644 --- a/UPGRADE.md +++ b/UPGRADE.md @@ -358,12 +358,12 @@ sites without one are unaffected. The file only links to pages you already publi sitemap does not, but it is a deliberate invitation for AI services to read your site. That is a choice worth making consciously: if you would rather not extend that invitation, set `hyde.llms.enabled` to `false`. -If you do keep it, the defaults need no configuration. Pages are grouped into sections by page type through the -`hyde.llms.sections` configuration array, and each link is described by the page's `abstract` front matter, -falling back to its `description`, so filling those in improves the file. Individual pages can be left out with -`llms: false` front matter, and page types can be left out by removing them from the sections array. As with the -sitemap and robots.txt, you can replace the file wholesale by registering your own `llms.txt` page, or adjust -the output by rebinding the `LlmsTxtGenerator` class in the service container. +If you do keep it, it needs no configuration. Pages are grouped into a section per page type, and each link is +described by the page's `abstract` front matter, falling back to its `description`, so filling those in improves +the file. Pages follow their sitemap inclusion, so anything already carrying `sitemap: false` stays out of this +file too, and `llms: false` front matter leaves out a single page regardless. As with the sitemap and robots.txt, +you can replace the file wholesale by registering your own `llms.txt` page, or adjust the output by rebinding the +`LlmsTxtGenerator` class in the service container. Be aware that llms.txt is an emerging standard which is still subject to change. We cannot make a backwards compatibility promise for the generated output while the specification is still moving, and we expect to change diff --git a/config/hyde.php b/config/hyde.php index 5af644f95d6..67510e555f3 100644 --- a/config/hyde.php +++ b/config/hyde.php @@ -164,22 +164,15 @@ | Llms.txt Generation |-------------------------------------------------------------------------- | - | When enabled, an llms.txt file indexing your site is generated when you - | compile your static site, so that AI services and agents can discover - | your content without having to crawl through your rendered HTML. + | When enabled, an llms.txt file listing your pages will be generated when + | you compile your static site, helping AI services and agents find your + | content. Set this to false if you would rather they did not. | - | The file only links to pages that are already published, so it surfaces - | nothing your sitemap does not. Still, if you would rather not help AI - | services read your site, set 'enabled' to false to skip the file. - | - | This feature requires that a site base URL has been set, as the file - | needs absolute links. Pages can opt out using `llms: false` in their - | front matter, and the abstract or description front matter of each - | page is used as its link description. + | This feature requires that a site base URL has been set. | - | Please note that llms.txt is an emerging standard which is still subject - | to change. We may therefore need to change the format of the generated - | file in future minor and patch releases in order to follow the spec. + | Note that llms.txt is an emerging standard which is still subject to + | change, so we may need to change the format of the generated file + | in future minor and patch releases in order to follow the spec. | */ @@ -189,16 +182,6 @@ // An optional summary of your site, added as the introductory blockquote. 'description' => null, - - // The page types to list in the file, and the heading each is listed under. - // Page types that are not listed here are not added to the file at all. - 'sections' => [ - \Hyde\Pages\HtmlPage::class => 'Pages', - \Hyde\Pages\BladePage::class => 'Pages', - \Hyde\Pages\MarkdownPage::class => 'Pages', - \Hyde\Pages\DocumentationPage::class => 'Documentation', - \Hyde\Pages\MarkdownPost::class => 'Blog Posts', - ], ], /* diff --git a/packages/framework/config/hyde.php b/packages/framework/config/hyde.php index 5af644f95d6..67510e555f3 100644 --- a/packages/framework/config/hyde.php +++ b/packages/framework/config/hyde.php @@ -164,22 +164,15 @@ | Llms.txt Generation |-------------------------------------------------------------------------- | - | When enabled, an llms.txt file indexing your site is generated when you - | compile your static site, so that AI services and agents can discover - | your content without having to crawl through your rendered HTML. + | When enabled, an llms.txt file listing your pages will be generated when + | you compile your static site, helping AI services and agents find your + | content. Set this to false if you would rather they did not. | - | The file only links to pages that are already published, so it surfaces - | nothing your sitemap does not. Still, if you would rather not help AI - | services read your site, set 'enabled' to false to skip the file. - | - | This feature requires that a site base URL has been set, as the file - | needs absolute links. Pages can opt out using `llms: false` in their - | front matter, and the abstract or description front matter of each - | page is used as its link description. + | This feature requires that a site base URL has been set. | - | Please note that llms.txt is an emerging standard which is still subject - | to change. We may therefore need to change the format of the generated - | file in future minor and patch releases in order to follow the spec. + | Note that llms.txt is an emerging standard which is still subject to + | change, so we may need to change the format of the generated file + | in future minor and patch releases in order to follow the spec. | */ @@ -189,16 +182,6 @@ // An optional summary of your site, added as the introductory blockquote. 'description' => null, - - // The page types to list in the file, and the heading each is listed under. - // Page types that are not listed here are not added to the file at all. - 'sections' => [ - \Hyde\Pages\HtmlPage::class => 'Pages', - \Hyde\Pages\BladePage::class => 'Pages', - \Hyde\Pages\MarkdownPage::class => 'Pages', - \Hyde\Pages\DocumentationPage::class => 'Documentation', - \Hyde\Pages\MarkdownPost::class => 'Blog Posts', - ], ], /* diff --git a/packages/framework/tests/Unit/ConfigFileTest.php b/packages/framework/tests/Unit/ConfigFileTest.php index fe9976c16b8..88636422d38 100644 --- a/packages/framework/tests/Unit/ConfigFileTest.php +++ b/packages/framework/tests/Unit/ConfigFileTest.php @@ -6,7 +6,6 @@ use Hyde\Enums\Feature; use Hyde\Foundation\HydeCoreExtension; -use Hyde\Framework\Features\TextGenerators\LlmsTxtGenerator; use Hyde\Hyde; use Hyde\Pages\BladePage; use Hyde\Pages\DocumentationPage; @@ -14,7 +13,6 @@ use Hyde\Pages\MarkdownPage; use Hyde\Pages\MarkdownPost; use Hyde\Testing\UnitTestCase; -use ReflectionClass; /** * @see \Hyde\Framework\Testing\Unit\HydeConfigFilesAreMatchingTest @@ -83,29 +81,6 @@ public function testDefaultFeaturesArrayMatchesDefaultFeatures() ->toBe(Feature::cases()); } - public function testDefaultLlmsSectionsValuesMatchDeclaredValues() - { - expect($this->getConfig('llms')['sections'])->toBe([ - HtmlPage::class => 'Pages', - BladePage::class => 'Pages', - MarkdownPage::class => 'Pages', - DocumentationPage::class => 'Documentation', - MarkdownPost::class => 'Blog Posts', - ]); - } - - public function testDefaultLlmsSectionsMatchTheGeneratorFallbackUsedWhenTheOptionIsAbsent() - { - expect($this->getConfig('llms')['sections']) - ->toBe((new ReflectionClass(LlmsTxtGenerator::class))->getConstant('DEFAULT_SECTIONS')); - } - - public function testDefaultLlmsSectionsCoverAllCoreExtensionClasses() - { - expect(array_keys($this->getConfig('llms')['sections'])) - ->toEqualCanonicalizing(HydeCoreExtension::getPageClasses()); - } - protected function getConfig(string $option): mixed { return (require Hyde::vendorPath('config/hyde.php'))[$option]; From 0ad05378c61c7e7abbeb3d47333fef102fd572fa Mon Sep 17 00:00:00 2001 From: Emma De Silva Date: Tue, 14 Jul 2026 00:53:35 +0200 Subject: [PATCH 080/117] List pages in llms.txt based on their sitemap inclusion MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drops the `llms` front matter key introduced alongside the generator. Leaving a page out of llms.txt does not stop AI services from reading it, as crawler access is governed by robots.txt, so the key could only ever mean "curate my index" — which is what `sitemap: false` already means. Front matter is public API we support for the life of the major version, and reintroducing the key later stays additive. Co-Authored-By: Claude Opus 4.8 --- .../TextGenerators/LlmsTxtGenerator.php | 76 +++++++++++-------- .../tests/Feature/LlmsTxtGeneratorTest.php | 36 +++++---- 2 files changed, 67 insertions(+), 45 deletions(-) diff --git a/packages/framework/src/Framework/Features/TextGenerators/LlmsTxtGenerator.php b/packages/framework/src/Framework/Features/TextGenerators/LlmsTxtGenerator.php index 052cedf9b60..4a49ce4e9ca 100644 --- a/packages/framework/src/Framework/Features/TextGenerators/LlmsTxtGenerator.php +++ b/packages/framework/src/Framework/Features/TextGenerators/LlmsTxtGenerator.php @@ -15,11 +15,11 @@ use Hyde\Support\Models\Route; use Hyde\Foundation\Facades\Routes; +use function addcslashes; use function array_fill_keys; use function array_filter; use function array_values; use function filled; -use function filter_var; use function implode; use function is_string; use function preg_replace; @@ -33,10 +33,14 @@ * heading, the configured `hyde.llms.description` as the summary blockquote, and the site's * pages as Markdown links, grouped into a section for each page type. * - * Pages are listed when they are included in the sitemap, so a page excluded from the sitemap - * is left out of this file as well. Use `llms: false` front matter to leave out a single page - * regardless of its sitemap state, and `llms: true` to add one back. The `abstract` front - * matter of a page, falling back to its `description`, is used as its link description. + * A page is listed when it is included in the sitemap, as both files are machine-readable + * indexes of the site's published pages, meaning `sitemap: false` front matter leaves a page + * out of both. The `abstract` front matter of a page, falling back to its `description`, is + * used as its link description. + * + * Sections are written in the order they are declared in, and the pages within each section + * are written in route order, which is the same order the sitemap lists and the build compiles + * them in, so numerically prefixed source files keep their intended reading order. * * Note that llms.txt is an emerging standard which is still subject to change, so the format * of the generated file may change in future minor and patch releases to follow the spec. @@ -46,25 +50,28 @@ */ class LlmsTxtGenerator { + public function generate(): string + { + return implode("\n", $this->getLines())."\n"; + } + /** * The page types listed in the file, and the section heading each is listed under. * - * Sections are written in the order declared here, and pages of a type not listed - * here, like the virtual pages Hyde generates, are not added to the file. + * Pages of a type not listed here, like the virtual pages Hyde generates, are not + * added to the file. Override this to group your own page types into sections. * - * @var array, string> + * @return array, string> */ - protected const SECTIONS = [ - HtmlPage::class => 'Pages', - BladePage::class => 'Pages', - MarkdownPage::class => 'Pages', - DocumentationPage::class => 'Documentation', - MarkdownPost::class => 'Blog Posts', - ]; - - public function generate(): string + protected function sections(): array { - return implode("\n", $this->getLines())."\n"; + return [ + HtmlPage::class => 'Pages', + BladePage::class => 'Pages', + MarkdownPage::class => 'Pages', + DocumentationPage::class => 'Documentation', + MarkdownPost::class => 'Blog Posts', + ]; } /** @return array */ @@ -99,45 +106,43 @@ protected function getLines(): array */ protected function getSections(): array { - $sections = array_fill_keys(array_values(static::SECTIONS), []); + $sections = $this->sections(); - Routes::all()->each(function (Route $route) use (&$sections): void { + $grouped = array_fill_keys(array_values($sections), []); + + Routes::all()->each(function (Route $route) use ($sections, &$grouped): void { $page = $route->getPage(); if (! $this->shouldListPage($page)) { return; } - foreach (static::SECTIONS as $pageClass => $heading) { + foreach ($sections as $pageClass => $heading) { if ($page instanceof $pageClass) { - $sections[$heading][] = $route; + $grouped[$heading][] = $route; return; } } }); - return array_filter($sections); + return array_filter($grouped); } /** - * Pages follow their sitemap inclusion unless they set the `llms` front matter key. - * Error pages are never listed, as they are not content. + * Pages are listed when they are included in the sitemap, apart from error pages, + * which are never listed as they are not content. */ protected function shouldListPage(HydePage $page): bool { - if ($page->getIdentifier() === '404') { - return false; - } - - return filter_var($page->matter('llms', $page->showInSitemap()), FILTER_VALIDATE_BOOLEAN); + return $page->showInSitemap() && $page->getIdentifier() !== '404'; } protected function makeLink(Route $route): string { $page = $route->getPage(); - $link = sprintf('- [%s](%s)', $page->title, Hyde::url($route->getOutputPath())); + $link = sprintf('- [%s](%s)', $this->escapeLinkLabel($page->title), Hyde::url($route->getOutputPath())); $description = $this->getPageDescription($page); @@ -155,6 +160,15 @@ protected function getPageDescription(HydePage $page): ?string return $this->normalizeText($description); } + /** + * Escape the characters that would otherwise terminate the Markdown link label, + * so that a title like "Arrays [Advanced]" does not emit a malformed link. + */ + protected function escapeLinkLabel(string $label): string + { + return addcslashes($this->normalizeText($label), '[]\\'); + } + /** * Collapse whitespace so that multi-line front matter cannot break the line-based file format. */ diff --git a/packages/framework/tests/Feature/LlmsTxtGeneratorTest.php b/packages/framework/tests/Feature/LlmsTxtGeneratorTest.php index eaf21ee12f9..79783c93c9f 100644 --- a/packages/framework/tests/Feature/LlmsTxtGeneratorTest.php +++ b/packages/framework/tests/Feature/LlmsTxtGeneratorTest.php @@ -109,13 +109,6 @@ public function testMultiLineDescriptionsAreCollapsedToASingleLine() $this->assertStringContainsString('- [Installation](https://example.com/docs/installation.html): First line. Second line.', $this->generate()); } - public function testPagesCanOptOutUsingFrontMatter() - { - $this->markdown('_pages/private.md', '# Private', ['llms' => false]); - - $this->assertStringNotContainsString('Private', $this->generate()); - } - public function testPagesExcludedFromTheSitemapAreNotListed() { $this->markdown('_pages/private.md', '# Private', ['sitemap' => false]); @@ -123,13 +116,6 @@ public function testPagesExcludedFromTheSitemapAreNotListed() $this->assertStringNotContainsString('Private', $this->generate()); } - public function testPagesExcludedFromTheSitemapCanBeAddedBackUsingFrontMatter() - { - $this->markdown('_pages/private.md', '# Private', ['sitemap' => false, 'llms' => true]); - - $this->assertStringContainsString('- [Private](https://example.com/private.html)', $this->generate()); - } - public function testErrorPagesAreNotListed() { $this->assertStringNotContainsString('404', $this->generate()); @@ -168,6 +154,28 @@ public function testCustomPageClassesAreListedUnderTheirParentClassSection() $this->assertStringContainsString("## Pages\n\n- [Index](https://example.com/index.html)\n- [Custom](https://example.com/custom.html)", $this->generate()); } + public function testPagesAreListedInRouteOrderSoNumericPrefixesKeepTheirReadingOrder() + { + $this->file('_docs/02-usage.md', '# Usage'); + $this->file('_docs/01-installation.md', '# Installation'); + $this->file('_docs/03-advanced.md', '# Advanced'); + + $this->assertStringContainsString(<<<'TXT' + ## Documentation + + - [Installation](https://example.com/docs/installation.html) + - [Usage](https://example.com/docs/usage.html) + - [Advanced](https://example.com/docs/advanced.html) + TXT, $this->generate()); + } + + public function testMarkdownSyntaxInPageTitlesIsEscapedInLinkLabels() + { + $this->markdown('_docs/arrays.md', '# Arrays', ['title' => 'Arrays [Advanced]']); + + $this->assertStringContainsString('- [Arrays \[Advanced\]](https://example.com/docs/arrays.html)', $this->generate()); + } + public function testPrettyUrlsAreUsedWhenEnabled() { config(['hyde.pretty_urls' => true]); From 180506c5497d2b3adb39d49854ee3b281477515d Mon Sep 17 00:00:00 2001 From: Emma De Silva Date: Tue, 14 Jul 2026 00:53:35 +0200 Subject: [PATCH 081/117] Escape Markdown syntax in llms.txt link labels and pin the page ordering A page titled "Arrays [Advanced]" previously emitted a malformed Markdown link. Also documents and tests the ordering contract: sections in declaration order, pages in route order, which keeps numerically prefixed docs in their reading order. Makes the section map a method so generator overrides can compute it at runtime. Co-Authored-By: Claude Opus 4.8 --- EPIC_NON_HTML_PAGES.md | 110 +++++++++++++++++++++++++++++------------ HYDEPHP_V3_PLANNING.md | 2 +- UPGRADE.md | 23 +++++---- 3 files changed, 92 insertions(+), 43 deletions(-) diff --git a/EPIC_NON_HTML_PAGES.md b/EPIC_NON_HTML_PAGES.md index 174d23b8a4d..78f0f577f27 100644 --- a/EPIC_NON_HTML_PAGES.md +++ b/EPIC_NON_HTML_PAGES.md @@ -183,22 +183,31 @@ a standalone feature in its own right. > listing redirects in a sitemap is an SEO anti-pattern, so an opt-in would only > be a trap. -> **Reused, not duplicated (PR 7):** llms.txt inclusion is *derived from* -> `showInSitemap()` rather than getting a parallel `showInLlmsTxt()` page method. An -> initial PR 7 implementation added the mirror method (plus a `Redirect` override, a -> `BaseHydePageUnitTest` contract entry, and six page unit test implementations) and it -> was cut in review as unearned surface: `showInSitemap()` already answers the exact -> question llms.txt needs — "is this page part of the machine-readable index of my -> site" — and its resolved-output-path default already excludes every generated -> non-HTML page and redirect for free. The `LlmsTxtGenerator` instead reads -> `matter('llms', $page->showInSitemap())`, so users keep per-page control in both -> directions (`llms: false` to drop a sitemapped page, `llms: true` to add back one -> that is `sitemap: false`) with zero new public API on `HydePage`. -> The coupling is a deliberate, documented rule ("a page excluded from the sitemap is -> excluded from llms.txt"), and it is the *less* surprising default: a user who hides -> a page from search engines does not expect it advertised to AI agents. If independent -> control ever proves necessary, promoting the front matter key to a real -> `showInLlmsTxt()` method later is additive and non-breaking. +> **Reused, not duplicated (PR 7): llms.txt inclusion *is* sitemap inclusion.** No new +> page method and no new front matter key were added. `LlmsTxtGenerator::shouldListPage()` +> is simply `$page->showInSitemap() && $page->getIdentifier() !== '404'`. +> +> This landed in two cuts. The first PR 7 implementation added a mirror +> `HydePage::showInLlmsTxt()` (plus a `Redirect` override, a `BaseHydePageUnitTest` +> contract entry, and six page unit test implementations); that was cut because +> `showInSitemap()` already answers the exact question llms.txt asks — "is this page part +> of the machine-readable index of my site" — and its resolved-output-path default already +> excludes every generated non-HTML page and redirect for free. The interim version kept an +> `llms` front matter key (`matter('llms', $page->showInSitemap())`) to preserve decoupling; +> that was cut too, on the grounds that **front matter is public API we must support +> forever, so it has to earn its place.** The decisive argument is that llms.txt is not a +> control plane: omitting a page from it does not stop any AI service from reading that +> page (only `robots.txt` speaks to crawler access), so `llms: false` could never mean +> "hide this from AI" — it could only mean "curate my index", which is precisely what +> `sitemap: false` already means. A second key with near-identical semantics would mostly +> generate the question "which one do I use?". +> +> The coupling is therefore a feature, not a compromise, and it is the *less* surprising +> default: a user who hides a page from search engines does not expect it advertised to AI +> agents. Should a concrete need for decoupling appear, reintroducing `llms:` front matter +> (or promoting it to a `showInLlmsTxt()` method) is additive and non-breaking — so waiting +> for that evidence costs nothing, while shipping the key speculatively costs us the +> support burden forever. ### D4: Generators become container-resolved pages; generator actions stay @@ -633,7 +642,7 @@ Implementation notes (branch `v3/non-html-pages-llms-txt`): exclusion — rare needs, paid for by every user in config-file surface (a five-entry array, entry validation, an exception path, and a comment explaining that omitting a class silently drops those pages, which is a trap the framework did not previously - have). The section map now lives as a `protected const SECTIONS` on the generator: + have). The section map now lives as a `protected sections()` method on the generator: page classes are matched with `instanceof` in declaration order — the same semantics `PageCollection::getPages()` and `RouteCollection::getRoutes()` use — so a user's `GuidePage extends MarkdownPage` lands in the `Pages` section, while every @@ -642,20 +651,47 @@ Implementation notes (branch `v3/non-html-pages-llms-txt`): need different sections have the D4 tier already advertised for exactly this: override the generator and rebind it in the container. The config surface is now two keys, `enabled` and `description`, matching the size of the `rss` and `robots` blocks. + A method rather than a constant because overriding the generator *is* the advertised + customization tier, and a method lets an override compute its sections from config, + installed extensions, or runtime state, which a constant expression cannot. *Design rule this records: a configuration option must be justified against the container-rebind tier that already exists, not merely be useful in principle.* +- **Page ordering is route order, deliberately.** Sections are emitted in the order + `sections()` declares them, and pages within a section in route-collection order — + the same order the sitemap lists and the build compiles them in. This is a chosen and + tested contract, not an accident of discovery: `FileFinder` sorts its results by path, + so the order is deterministic and platform-independent, and because Hyde strips + numeric filename prefixes from route keys while still discovering by path, a + `01-installation.md` / `02-usage.md` docs set lands in the file in its intended reading + order with clean URLs. Navigation priority was considered as the ordering key and + rejected: it would couple the file to navigation config, and it is meaningless for the + blog posts and nav-hidden pages that make up much of the listing. - **Deviation — `hyde.description` does not exist.** The epic assumed a site-level description config key; there is none (only `hyde.rss.description`). Added `hyde.llms.description`, mirroring the RSS key rather than inventing a global one, which would have pulled in the `hyde.meta` description tag and page metadata generation — a cross-cutting change that does not belong in this PR. It is nullable, and the summary blockquote is omitted when unset (only the H1 is required by the - spec). A future consolidation into a global `hyde.description` remains open. + spec). + *Follow-up recorded (out of scope): site identity metadata is fragmenting.* The site + name, base URL, language, and now two separate descriptions (`hyde.rss.description` + and `hyde.llms.description`) all describe the same site identity from different config + keys. A coherent site-metadata object — name, canonical URL, description, language, + author/organization — with feature-specific overrides would consolidate them. That is + its own architectural change, not scope for this epic; this PR deliberately mirrored + the existing RSS key rather than pre-empting that design. +- **Markdown-significant characters in titles are escaped.** A page titled + `Arrays [Advanced]` would otherwise emit `- [Arrays [Advanced]](url)`, a malformed + link. `escapeLinkLabel()` escapes `[`, `]`, and `\` in the label. Link *descriptions* + are not escaped: they are prose trailing the link rather than delimiter-sensitive + syntax. - **Link descriptions:** the `abstract` front matter added by #2523, falling back to `description`. #2523 only added `abstract` to the docs *content* — there is no framework schema support for it, and consistent with PR 4 (which did not add - `sitemap` to `PageSchema::PAGE_SCHEMA` either), neither `abstract` nor `llms` was - added to the schema; both are documented on the accessors that read them. Whitespace + `sitemap` to `PageSchema::PAGE_SCHEMA` either), `abstract` was not added to the + schema; it is documented on the generator that reads it. Note that this PR adds **no + new front matter keys at all** — it only consumes `abstract`, `description`, and + `sitemap`, which all already existed. Whitespace in descriptions is collapsed to a single line, since a multi-line YAML block scalar would otherwise emit a broken list item — this is *not* a "verbatim string" case like the robots.txt disallow rules, where PR 6 correctly refused to normalize, because @@ -673,18 +709,28 @@ Implementation notes (branch `v3/non-html-pages-llms-txt`): the D3 resolved-output-path default. > **Scope correction (post-implementation review).** The first cut of this PR was -> overbuilt for the value delivered, and two pieces were cut back before merge: the -> `hyde.llms.sections` config (see the sections deviation above) and the -> `HydePage::showInLlmsTxt()` page method (see the D3 "Reused, not duplicated" note). -> Between them they added a public method to every page class, an entry in the -> `BaseHydePageUnitTest` contract with six implementations, a config array with its own -> validation and exception path, and a config comment long enough to advertise that the -> option was not simple. All of it served needs that the existing `showInSitemap()` -> front matter and the D4 container-rebind tier already served. The feature's user-facing -> capability is unchanged; only the surface shrank. Worth carrying into PR 8 and any -> future generated-page work: **the D4 rebind tier is the default answer for -> customization, and a new config key or page-model method has to beat it, not merely -> be useful.** +> overbuilt for the value delivered, and three pieces were cut back before merge: the +> `hyde.llms.sections` config (see the sections deviation above), the +> `HydePage::showInLlmsTxt()` page method, and the `llms` front matter key that briefly +> replaced it (both in the D3 "Reused, not duplicated" note). Between them they added a +> public method to every page class, an entry in the `BaseHydePageUnitTest` contract with +> six implementations, a front matter key we would have to support for the life of v3, a +> config array with its own validation and exception path, and a config comment long +> enough to advertise that the option was not simple. All of it served needs that the +> existing `sitemap: false` front matter and the D4 container-rebind tier already served. +> The feature's user-facing capability is materially unchanged; only the surface shrank. +> The final shape adds **no new front matter, no page-model API, and two scalar config +> keys.** +> +> Three rules worth carrying into PR 8 and any future generated-page work: +> 1. **The D4 rebind tier is the default answer for customization.** A new config key or +> page-model method has to beat it, not merely be useful. +> 2. **Front matter is forever.** A key we introduce is public API we must support and +> document for the life of the major version, so a speculative one is a real liability. +> Adding a key later is additive and non-breaking, which makes "wait for the evidence" +> the cheap option and "ship it just in case" the expensive one. +> 3. **A long explanatory comment in a config stub is a design smell,** not diligence. If +> an option needs paragraphs to explain, the option is usually the problem. ### PR 8 — Documentation & release notes diff --git a/HYDEPHP_V3_PLANNING.md b/HYDEPHP_V3_PLANNING.md index 9c401841cc6..8d4ec0bc8d4 100644 --- a/HYDEPHP_V3_PLANNING.md +++ b/HYDEPHP_V3_PLANNING.md @@ -28,7 +28,7 @@ Having this document in code lets us know the devlopment state at any given poin - Pages can now control their own sitemap inclusion. Set `sitemap: false` in a page's front matter to exclude it from the generated `sitemap.xml`, or override the new `HydePage::showInSitemap()` method in custom page classes. Pages compiled to non-HTML output files (like `robots.txt`) are excluded by default, and `sitemap: true` front matter opts such a page back in. - The sitemap and RSS feed are now first-class pages instead of post-build side effects: when the respective feature is enabled, `sitemap.xml` and the RSS feed (`feed.xml`, or the configured `hyde.rss.filename`) are registered as routes, so they are served by `hyde serve`, listed in `route:list`, included in the build manifest, and compiled through the standard site build. The output can be customized by rebinding the `SitemapGenerator` or `RssFeedGenerator` class in the service container, and registering a user-defined page with the same route key (from a service provider, booting callback, or extension) replaces the generated page entirely. - Hyde now generates a `robots.txt` file for the site out of the box. The default output allows all crawlers, and links to the sitemap when that feature is enabled. Rule values listed in the new `hyde.robots.disallow` configuration array are written verbatim as `Disallow` rules (so wildcard patterns are supported), and the file can be disabled entirely with `hyde.robots.enabled`. The page is wired like the sitemap and RSS feed: it is a real route (served by `hyde serve`, listed in `route:list`, included in the build manifest), the output can be customized by rebinding the `RobotsTxtGenerator` class in the service container, and a user-defined `robots.txt` page replaces the generated one entirely. -- Hyde now generates an [`llms.txt`](https://llmstxt.org/) file for the site out of the box, so that AI services and agents can discover your content without crawling your rendered HTML. The file uses the site name as its heading, the optional `hyde.llms.description` as its summary blockquote, and lists your pages as Markdown links, grouped into a section for each page type (Pages, Documentation, and Blog Posts). Each link is described by the page's `abstract` front matter, falling back to its `description`. Pages follow their sitemap inclusion, so a page excluded from the sitemap is left out of this file as well, and `llms: false` front matter leaves out a single page regardless. The feature requires a site base URL since the file needs absolute links, and can be disabled with `hyde.llms.enabled`. The page is wired like the sitemap, RSS feed, and robots.txt: it is a real route (served by `hyde serve`, listed in `route:list`, included in the build manifest), the output can be customized by rebinding the `LlmsTxtGenerator` class in the service container, and a user-defined `llms.txt` page replaces the generated one entirely. +- Hyde now generates an [`llms.txt`](https://llmstxt.org/) file for the site out of the box, so that AI services and agents can discover your content without crawling your rendered HTML. The file uses the site name as its heading, the optional `hyde.llms.description` as its summary blockquote, and lists your pages as Markdown links, grouped into a section for each page type (Pages, Documentation, and Blog Posts). Each link is described by the page's `abstract` front matter, falling back to its `description`, and pages are listed in the same order the sitemap lists them in, so numerically prefixed source files keep their intended reading order. A page is listed when it is included in the sitemap, as both files are machine-readable indexes of your published pages, so `sitemap: false` front matter leaves a page out of both and no new front matter key is introduced. The file indexes only material you already publish and grants no access to anything private. It requires a site base URL since it needs absolute links, and can be disabled with `hyde.llms.enabled`. The page is wired like the sitemap, RSS feed, and robots.txt: it is a real route (served by `hyde serve`, listed in `route:list`, included in the build manifest), the output can be customized by rebinding the `LlmsTxtGenerator` class in the service container, and a user-defined `llms.txt` page replaces the generated one entirely. Please note that llms.txt is an emerging standard which is still subject to change, and we are unable to make a backwards compatibility promise while implementing against a moving specification. We expect to change the format of the generated file in minor and patch releases as the standard evolves. We still think that shipping this is better than nothing, assuming you want AI services to read your site — and if you would rather they did not, set `hyde.llms.enabled` to `false` to skip the file. diff --git a/UPGRADE.md b/UPGRADE.md index 45db7d19ef2..6199d89cdf1 100644 --- a/UPGRADE.md +++ b/UPGRADE.md @@ -354,16 +354,19 @@ above). Crawl rules can be added to the `hyde.robots.disallow` configuration arr Hyde now generates an [`llms.txt`](https://llmstxt.org/) file by default, indexing your site's content for AI services and agents. It requires a site base URL, since the file links to your pages with absolute URLs, so -sites without one are unaffected. The file only links to pages you already publish, and it lists nothing your -sitemap does not, but it is a deliberate invitation for AI services to read your site. That is a choice worth -making consciously: if you would rather not extend that invitation, set `hyde.llms.enabled` to `false`. - -If you do keep it, it needs no configuration. Pages are grouped into a section per page type, and each link is -described by the page's `abstract` front matter, falling back to its `description`, so filling those in improves -the file. Pages follow their sitemap inclusion, so anything already carrying `sitemap: false` stays out of this -file too, and `llms: false` front matter leaves out a single page regardless. As with the sitemap and robots.txt, -you can replace the file wholesale by registering your own `llms.txt` page, or adjust the output by rebinding the -`LlmsTxtGenerator` class in the service container. +sites without one are unaffected. The file indexes only material you already publish — it lists nothing your +sitemap does not, and grants no access to anything private — but it is a deliberate invitation for AI services +to read your site. That is a choice worth making consciously: if you would rather not extend that invitation, +set `hyde.llms.enabled` to `false`. Note that leaving pages out of this file does not stop AI crawlers from +reading them; crawler access is governed by your `robots.txt`, not by llms.txt. + +If you do keep it, it needs no configuration. Pages are grouped into a section per page type and listed in the +same order as your sitemap, and each link is described by the page's `abstract` front matter, falling back to +its `description`, so filling those in improves the file. A page is listed when it is included in the sitemap, +so anything already carrying `sitemap: false` stays out of this file too — there is no separate front matter key +to learn. As with the sitemap and robots.txt, you can replace the file wholesale by registering your own +`llms.txt` page, or adjust the sections and output by extending the `LlmsTxtGenerator` class and rebinding it in +the service container. Be aware that llms.txt is an emerging standard which is still subject to change. We cannot make a backwards compatibility promise for the generated output while the specification is still moving, and we expect to change From 82bcb71d8245bda34a176e3dfb13ae0134ce3d2a Mon Sep 17 00:00:00 2001 From: Emma De Silva Date: Tue, 14 Jul 2026 01:03:27 +0200 Subject: [PATCH 082/117] Cover the generator override for pages excluded from the sitemap Pins the documented answer to the one case the sitemap-derived rule cannot express: a page kept out of the sitemap for SEO reasons that should still reach an agent index. Co-Authored-By: Claude Opus 4.8 --- EPIC_NON_HTML_PAGES.md | 12 ++++++++++-- .../tests/Feature/LlmsTxtGeneratorTest.php | 16 ++++++++++++++++ 2 files changed, 26 insertions(+), 2 deletions(-) diff --git a/EPIC_NON_HTML_PAGES.md b/EPIC_NON_HTML_PAGES.md index 78f0f577f27..e0c09c1e29d 100644 --- a/EPIC_NON_HTML_PAGES.md +++ b/EPIC_NON_HTML_PAGES.md @@ -204,8 +204,16 @@ a standalone feature in its own right. > > The coupling is therefore a feature, not a compromise, and it is the *less* surprising > default: a user who hides a page from search engines does not expect it advertised to AI -> agents. Should a concrete need for decoupling appear, reintroducing `llms:` front matter -> (or promoting it to a `showInLlmsTxt()` method) is additive and non-breaking — so waiting +> agents. +> +> The known cost is the converse case: a page kept out of the sitemap for SEO reasons +> (thin content, a duplicate, a paginated archive) that would still be useful to an agent. +> That site is not stuck today — overriding `shouldListPage()` on the generator and +> rebinding it is exactly the D4 tier, and it is a three-line override. The trade is +> deliberate: the edge case pays at the generator tier instead of every site paying for a +> second front matter key. Should the case turn out to be common, reintroducing `llms:` +> front matter (or promoting it to a `showInLlmsTxt()` method) is additive and +> non-breaking — so waiting > for that evidence costs nothing, while shipping the key speculatively costs us the > support burden forever. diff --git a/packages/framework/tests/Feature/LlmsTxtGeneratorTest.php b/packages/framework/tests/Feature/LlmsTxtGeneratorTest.php index 79783c93c9f..8311b210f83 100644 --- a/packages/framework/tests/Feature/LlmsTxtGeneratorTest.php +++ b/packages/framework/tests/Feature/LlmsTxtGeneratorTest.php @@ -6,6 +6,7 @@ use Hyde\Testing\TestCase; use Hyde\Pages\MarkdownPage; +use Hyde\Pages\Concerns\HydePage; use Hyde\Support\Models\Route; use Hyde\Support\Models\Redirect; use Hyde\Foundation\Facades\Routes; @@ -185,6 +186,21 @@ public function testPrettyUrlsAreUsedWhenEnabled() $this->assertStringContainsString('- [Installation](https://example.com/docs/installation)', $this->generate()); } + public function testGeneratorOverrideCanListPagesThatAreExcludedFromTheSitemap() + { + $this->markdown('_pages/thin.md', '# Thin Page', ['sitemap' => false]); + + $generator = new class extends LlmsTxtGenerator + { + protected function shouldListPage(HydePage $page): bool + { + return $page->getIdentifier() !== '404'; + } + }; + + $this->assertStringContainsString('- [Thin Page](https://example.com/thin.html)', $generator->generate()); + } + protected function generate(): string { return (new LlmsTxtGenerator())->generate(); From ef8651ca45de4a34d295a42acdb032ad2583201d Mon Sep 17 00:00:00 2001 From: Emma De Silva Date: Tue, 14 Jul 2026 01:13:25 +0200 Subject: [PATCH 083/117] Improve test clarity --- .../framework/tests/Feature/LlmsTxtGeneratorTest.php | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/packages/framework/tests/Feature/LlmsTxtGeneratorTest.php b/packages/framework/tests/Feature/LlmsTxtGeneratorTest.php index 8311b210f83..1b698627878 100644 --- a/packages/framework/tests/Feature/LlmsTxtGeneratorTest.php +++ b/packages/framework/tests/Feature/LlmsTxtGeneratorTest.php @@ -186,19 +186,23 @@ public function testPrettyUrlsAreUsedWhenEnabled() $this->assertStringContainsString('- [Installation](https://example.com/docs/installation)', $this->generate()); } - public function testGeneratorOverrideCanListPagesThatAreExcludedFromTheSitemap() + public function testGeneratorOverrideCanListAPageThatIsExcludedFromTheSitemap() { $this->markdown('_pages/thin.md', '# Thin Page', ['sitemap' => false]); + $this->markdown('_pages/private.md', '# Private Page', ['sitemap' => false]); $generator = new class extends LlmsTxtGenerator { protected function shouldListPage(HydePage $page): bool { - return $page->getIdentifier() !== '404'; + return parent::shouldListPage($page) || $page->getIdentifier() === 'thin'; } }; - $this->assertStringContainsString('- [Thin Page](https://example.com/thin.html)', $generator->generate()); + $contents = $generator->generate(); + + $this->assertStringContainsString('- [Thin Page](https://example.com/thin.html)', $contents); + $this->assertStringNotContainsString('Private Page', $contents); } protected function generate(): string From 067af6745ac1f0a2e40e46700fbb8f9beaa80fc0 Mon Sep 17 00:00:00 2001 From: Emma De Silva Date: Tue, 14 Jul 2026 02:14:17 +0200 Subject: [PATCH 084/117] Revert "Create LLMS_TXT_RESEARCH.md" This reverts commit 57b5c7441135b99a01f199e25b4ea1e625fd46bb. --- LLMS_TXT_RESEARCH.md | 315 ------------------------------------------- 1 file changed, 315 deletions(-) delete mode 100644 LLMS_TXT_RESEARCH.md diff --git a/LLMS_TXT_RESEARCH.md b/LLMS_TXT_RESEARCH.md deleted file mode 100644 index 67ace780f16..00000000000 --- a/LLMS_TXT_RESEARCH.md +++ /dev/null @@ -1,315 +0,0 @@ -# llms.txt and HydePHP - -## Executive summary - -`llms.txt` is an emerging, non-IETF proposal for publishing a small, curated, Markdown file at `/llms.txt` so language models and AI agents can find the most important material on a site without trawling full HTML navigation, scripts, and boilerplate. The proposal was published by Jeremy Howard on 3 September 2024 and is maintained publicly through the AnswerDotAI GitHub repository and llmstxt.org, with the authors explicitly inviting community input rather than claiming formal standards status. citeturn5view0turn6view0turn7view0 - -For a HydePHP site, `llms.txt` is relevant because Hyde sites are often documentation-heavy and static by design: exactly the kind of content where a concise, plain-text, agent-friendly index is useful. The uploaded HydePHP v3 planning document is especially relevant: it says there is currently “no easy way” to add plain-text files like `robots.txt` or `llms.txt`, and it proposes first-class non-HTML pages plus a generated `llms.txt` feature in the v3 branch. That document also shows Hyde’s direction of travel: route-native non-HTML output, generator-backed pages, and user override paths. fileciteturn0file0L11-L18 fileciteturn0file0L29-L31 fileciteturn0file0L515-L529 - -The practical conclusion is straightforward. For today’s typical static Hyde deployment, the safest approach is a build-time generated root file, ideally from site metadata and per-page front matter. For a future Hyde v3-style implementation, a first-class `llms.txt` page is cleaner because it participates in routing, build manifests, local serve, and extension points. A fully dynamic endpoint is possible, but it is a poor fit for “typical static hosting” unless you deliberately add an edge/serverless layer. fileciteturn0file0L5-L7 fileciteturn0file0L48-L55 - -`llms.txt` should not be confused with a control plane. It does **not** replace `robots.txt`, access control, authentication, or contractual/licensing terms. The llmstxt.org proposal frames it as an inference-time aid, while `robots.txt` remains an advisory crawler-access mechanism and `security.txt` remains a separate RFC-backed disclosure-contact format. If you care about blocking or monetising AI crawlers, you still need crawler controls, logs, and enforcement outside `llms.txt`. citeturn7view0turn12view0turn13view0turn20view0 - -## What llms.txt is and where it comes from - -The official llmstxt.org proposal describes `llms.txt` as a Markdown file placed at `/llms.txt` to “provide information to help LLMs use a website at inference time”. Its rationale is that LLMs increasingly rely on website information, but full websites are often too large and noisy for context windows, whereas a single concise, curated file can point them to the right materials. citeturn5view0turn7view0 - -The primary sources worth bookmarking are the llmstxt.org proposal, the public AnswerDotAI GitHub repository, the `llms-txt` parser/CLI documentation, and the HydePHP planning note you supplied. The llmstxt.org page names Jeremy Howard as author and 3 September 2024 as publication date, while the site’s “Next steps” section says the specification is open for community input and points to the GitHub repository and Discord for discussion. citeturn5view0turn7view0turn6view0 - -Two clarifications matter: - -First, `llms.txt` is a **proposal** and an emerging convention, not a formal web standard. The proposal language is explicit, and there is no RFC or W3C Recommendation defining it. By contrast, `robots.txt` is standardised in RFC 9309 and `security.txt` is defined in RFC 9116. citeturn5view0turn12view0turn25view0 - -Second, the proposal is intentionally narrow. It does not prescribe how an agent must process the file; it says processing depends on the application. That means adoption can be useful even before universal support exists, but it also means interoperability is partly conventional rather than guaranteed. citeturn6view0turn7view0 - -For HydePHP specifically, the uploaded v3 draft is unusually aligned with the proposal. It explicitly calls out `robots.txt` and `llms.txt` as target outputs, says non-HTML pages should become first-class pages rather than post-build side effects, and sketches a generated `llms.txt` action with route links, page titles, and descriptions/abstracts. It also emphasises that the default enable/disable decision should be deliberate because some users will have privacy or OPSEC concerns. fileciteturn0file0L5-L7 fileciteturn0file0L29-L31 fileciteturn0file0L515-L529 - -## Format, syntax, and how it differs from robots.txt and security.txt - -The llmstxt.org format is Markdown-based. The proposal says a conforming file is normally located at `/llms.txt` and contains, in order: an optional BOM, a single H1 with the project/site name, a blockquote summary, optional free-form non-heading detail sections, and then zero or more H2 sections containing lists of links. Each list item contains a required Markdown link and may optionally add a colon and short description. The only required element is the H1. An H2 section named `Optional` has special semantics: agents may skip it when they need a shorter context. citeturn7view0 - -The proposal also recommends, where useful, that pages expose clean Markdown versions by appending `.md` to the page URL, because Markdown is easier for models to consume than HTML. This is important for documentation sites, but it is a recommendation, not a hard dependency. In practice, implementations vary: Cloudflare heavily uses Markdown-addressable pages and even tells agents to prefer the Markdown version or `Accept: text/markdown`, whereas Vercel’s `llms.txt` points at ordinary documentation URLs and separately advertises `llms-full.txt`. citeturn6view0turn20view0turn20view1turn15view0 - -The following comparison summarises the practical differences. It is derived from the llmstxt.org proposal, RFC 9309, RFC 9116, and Google’s robots guidance. citeturn7view0turn12view0turn13view0turn25view0turn13view2 - -| File | Primary purpose | Status | Typical location | Structure | Enforcement | -|---|---|---|---|---|---| -| `llms.txt` | Curated guidance and content discovery for LLMs/agents | Proposal / emerging convention | `/llms.txt` | Markdown with H1, blockquote, H2 link sections | None by itself | -| `robots.txt` | Advisory crawler access rules | IETF Proposed Standard, RFC 9309 | `/robots.txt` | Line-oriented rules such as `User-agent`, `Allow`, `Disallow` | Voluntary crawler compliance | -| `security.txt` | Vulnerability disclosure contacts and policy | IETF RFC 9116 informational RFC | `/.well-known/security.txt` preferred; root allowed for legacy compatibility | Field/value records such as `Contact`, `Expires`, `Canonical` | Informational, but formally specified | - -A few practical consequences flow from this comparison. - -`robots.txt` is about crawler access, not explanation. Google says it tells search engine crawlers which URLs they can access, mainly to manage crawl load, and also warns that it is **not** a mechanism for keeping a page out of Google by itself. RFC 9309 likewise says the rules are not a form of access authorisation. citeturn13view0turn13view1turn12view0 - -`security.txt` is about security disclosure contacts, not AI guidance. RFC 9116 requires a machine-parsable text file, mandates an `Expires` field, recommends HTTPS, defines specific fields such as `Contact`, `Canonical`, and `Preferred-Languages`, and assigns a well-known URI. citeturn13view2turn13view3turn13view4turn25view3turn25view4 - -`llms.txt` is closer to a curated documentation index than to either control file. It is unusual in using Markdown instead of a classic structured format because the proposal expects the file itself to be read directly by language models and agents. citeturn7view0 - -An illustrative `llms.txt` for a Hyde site might look like this: - -```txt -# Example HydePHP Site - -> Official documentation and articles for Example HydePHP Site. Public content only. Prefer the Docs and Reference sections before browsing the wider site. - -This file is a curated guide for language models and AI agents. Link descriptions are authoritative summaries maintained by the site team. - -## Docs -- [Getting started](https://example.com/docs/getting-started.md): Installation, local development, and first build. -- [Content authoring](https://example.com/docs/authoring.md): Markdown, front matter, Blade, and assets. -- [Deployment](https://example.com/docs/deployment.md): Static hosting, CDN, cache invalidation, and previews. - -## Reference -- [Configuration](https://example.com/docs/configuration.md): Site configuration keys and defaults. -- [Extensions](https://example.com/docs/extensions.md): Custom generators, hooks, and extension points. - -## Blog -- [Release notes](https://example.com/blog/releases): Product changes and upgrade notes. -- [Architecture notes](https://example.com/blog/architecture): Design decisions and implementation rationale. - -## Optional -- [About](https://example.com/about): Project overview and maintainers. -- [Privacy policy](https://example.com/privacy): Public privacy notice. -``` - -That sample follows the official ordering and the special `Optional` convention from llmstxt.org. citeturn7view0 - -## Adoption, common use cases, and implications - -Adoption is now real enough to matter for documentation tooling, even though the standard remains informal. The llmstxt.org site itself lists integrations including a Python parser/CLI, a JavaScript implementation, a VitePress plugin, a Docusaurus plugin, a Drupal recipe, and a PHP library. Public directories also list many deployed `llms.txt` files and many `llms-full.txt` companions across documentation sites and commercial domains. citeturn5view0turn7view1turn22view2turn22view3turn22view4turn22view0turn19view0turn19view2 - -Among major companies and platforms, the strongest primary-source examples are documentation ecosystems: - -Cloudflare publishes a top-level documentation `llms.txt`, many product-level `llms.txt` files, and corresponding `llms-full.txt` resources. Its docs also explicitly instruct agents to fetch Markdown instead of HTML and to use `llms.txt` as the documentation index. citeturn14view0turn20view0turn20view1turn26view3 - -Stripe serves `docs.stripe.com/llms.txt`, and its content is clearly agent-oriented: it includes operational guidance such as checking package registries for current versions rather than trusting memorised version numbers. That is a useful signal that `llms.txt` is already being used for more than a passive site map. citeturn14view1 - -Vercel serves `vercel.com/llms.txt` and prominently points to a `llms-full.txt` file containing its full documentation content. Anthropic’s developer documentation also exposes a root `llms.txt`, and Meta’s Horizon developer docs publish a large documentation index in `llms.txt` form. citeturn15view0turn26view2turn15view1turn24view0 - -The most common use cases are therefore documentation discovery, curated agent context, and easier retrieval for AI assistants. The llmstxt.org proposal itself emphasises developer documentation, software APIs, e-commerce/product explanation, legislation, personal websites, and institutional information. citeturn5view0turn6view0 - -The security, privacy, and legal picture is more modest than the marketing around the concept sometimes suggests. - -`llms.txt` is public by design. It should therefore contain only public URLs and public summaries; do not place secrets, unpublished materials, admin paths, preview URLs, or anything whose mere disclosure would be sensitive. This is an inference from the proposal’s public-root-file design and the general limitations shared with advisory text files. citeturn7view0turn12view0turn13view0 - -It is not an enforcement mechanism. If your aim is “do not crawl this” or “do not train on this”, `llms.txt` alone is too weak. The llmstxt.org proposal frames the file mainly as an inference-time aid; RFC 9309 and Google both make clear that even `robots.txt` is only advisory and not authorisation or robust secrecy. Cloudflare’s AI Crawl Control documentation reinforces this by offering separate monitoring, allow/block policies, robots compliance tracking, and even monetisation for AI crawling. citeturn7view0turn12view0turn13view0turn20view0 - -Legally, because no jurisdiction was specified, the safest general position is this: treat `llms.txt` as notice and guidance, not as a substitute for terms, licences, authentication, rate limits, or contractual controls. If you need stronger legal signalling, put the real terms in your existing legal documents and link them as public resources; if you need stronger technical control, use robots directives, auth, WAF/CDN controls, logging, and bot management. That conclusion is an inference from the proposal’s non-standard status and the enforcement limits of comparable files. citeturn5view0turn12view0turn25view0turn20view0 - -## HydePHP implementation strategy - -The exact HydePHP version was unspecified, so the right recommendation depends on whether you are working with current static-site workflows or the v3 draft direction in the uploaded planning note. That note says the v3 branch wants non-HTML outputs like `robots.txt`, `llms.txt`, sitemap, RSS, and JSON pages to become first-class pages in routing and the build pipeline, and it proposes a generated `llms.txt` feature with route grouping and exclusions. fileciteturn0file0L1-L7 fileciteturn0file0L19-L31 fileciteturn0file0L515-L529 - -```mermaid -flowchart LR - A[Hyde content and front matter] --> B[llms metadata selection] - B --> C[Generator or Blade template] - C --> D[/llms.txt in build output] - D --> E[Static host or CDN] - E --> F[LLM or agent fetches /llms.txt] - F --> G[Follows curated links] - G --> H[Markdown pages if available] -``` - -The key architectural choice is when generation happens. - -A build-time file is the best default for a typical Hyde deployment because it works everywhere Hyde does: local builds, GitHub Pages, Netlify, Cloudflare Pages, S3-style static hosting, and CDN-only stacks. A route-native page is cleaner, but only if your Hyde version actually supports non-HTML pages cleanly. The uploaded draft suggests Hyde v3 aims to make that the canonical approach. fileciteturn0file0L11-L18 fileciteturn0file0L48-L55 - -The following table compares the realistic options for HydePHP. It synthesises the llmstxt.org requirements with the HydePHP planning note and the static-hosting assumption. citeturn7view0 fileciteturn0file0L11-L18 fileciteturn0file0L184-L200 - -| Option | Static-hosting fit | Main advantages | Main drawbacks | Best use | -|---|---|---|---|---| -| Hand-maintained root file | Excellent | Simple, no framework coupling | Easy to drift out of date | Small brochure/blog sites | -| Build-time generated file | Excellent | Deterministic, CI-friendly, no runtime needed | Requires custom generator logic | Best default for most Hyde sites | -| Metadata-driven Blade/template at build | Excellent | Editors can steer sections/descriptions via front matter | Needs template + generation step | Docs/blog sites with rich metadata | -| Route-native generated page | Good if Hyde version supports non-HTML pages | Clean routing, local serve, extension hooks, easier override story | Depends on Hyde version/branch capability | Future-facing Hyde v3 style | -| Dynamic endpoint | Poor for typical static hosting | Always current, easy runtime filtering | Adds runtime infrastructure, cache complexity, less “static” | Only when you already run edge/serverless/PHP | - -A sensible application-level config file might look like this. This is **illustrative**, not current Hyde core API: - -```php - env('HYDE_LLMS_ENABLED', true), - - 'title' => env('APP_NAME', 'Example HydePHP Site'), - 'summary' => config('hyde.description', ''), - 'details' => 'Public documentation and articles for this site. Prefer Docs and Reference before wider browsing.', - 'base_url' => config('hyde.url'), - - 'prefer_markdown_urls' => true, - - 'sections' => [ - 'Docs' => ['docs/*'], - 'Reference' => ['reference/*'], - 'Blog' => ['posts/*'], - 'Project' => ['about', 'changelog'], - ], - - 'exclude' => [ - '404', - 'search.json', - 'sitemap.xml', - 'feed.xml', - 'robots.txt', - 'llms.txt', - 'media/*', - 'drafts/*', - ], - - 'optional' => [ - 'privacy', - 'terms', - 'contact', - ], -]; -``` - -A metadata-driven Blade template is a good middle path because it lets maintainers curate output without hand-editing the final text: - -```php -{{-- resources/views/llms-txt.blade.php --}} -# {{ $title }} - -> {{ $summary }} - -@if (!empty($details)) -{{ $details }} -@endif - -@foreach ($sections as $sectionName => $links) -## {{ $sectionName }} - -@foreach ($links as $link) -- [{{ $link['title'] }}]({{ $link['url'] }})@if (!empty($link['description'])): {{ $link['description'] }}@endif -@endforeach - -@endforeach -``` - -A simple generator service can then render that template and write the final file during build: - -```php - $config['title'], - 'summary' => $config['summary'], - 'details' => $config['details'] ?? '', - 'sections' => $sections, - ]); - } -} -``` - -If your Hyde version still treats non-HTML outputs as awkward post-build artefacts, write `_site/llms.txt` after the page collection has been built. If Hyde v3 lands as described in the planning note, the cleaner design is a first-class page that compiles lazily and resolves its generator from the container, matching the draft’s approach for generated non-HTML pages. fileciteturn0file0L15-L27 fileciteturn0file0L184-L200 - -A future-facing v3-style sketch would look roughly like this: - -```php -generateFromSite(); - } -} -``` - -And registration would ideally happen through Hyde’s boot/extension mechanism, because the planning note explicitly points to `Hyde::kernel()->booting()` callbacks and `HydeExtension` as the user-land extension points, and says user-defined pages should beat framework-generated ones when route keys collide. fileciteturn0file0L53-L57 fileciteturn0file0L223-L254 - -For a dynamic endpoint, only use it if you knowingly accept non-static infrastructure. An illustrative PHP route might be: - -```php -generateFromSite(); - - return response($content, 200, [ - 'Content-Type' => 'text/plain; charset=utf-8', - 'Cache-Control' => 'public, max-age=300', - ]); -}); -``` - -That approach is technically fine, but it is not a natural fit for the assumed “typical static hosting” environment. - -## Deployment, validation, interoperability, and recommended defaults - -Operationally, the most important serving rule is simple: publish a UTF-8 plain-text file at the root path `/llms.txt`. The official proposal targets that location, and major implementations from Cloudflare, Stripe, Vercel, Anthropic, and Meta all expose plain-text root or documentation-index files that way. Hyde’s v3 note is also helpful here because it says the realtime compiler already maps `.txt`, `.xml`, and `.json` output paths to correct content types. citeturn7view0turn14view0turn14view1turn14view2turn15view1turn24view0 fileciteturn0file0L48-L49 - -On static hosting and CDNs, treat `llms.txt` like any other generated public artefact: publish it at the origin root, cache it normally, and purge/invalidate it whenever docs or key pages change. Because the file is small and low-risk, short-to-moderate cache TTLs are usually more useful than aggressive long-lived caching; the value of the file depends on freshness. That is an implementation recommendation rather than a standard requirement. - -Interoperability is currently best thought of in layers. - -At the broadest layer, old browsers and legacy crawlers are unaffected: `llms.txt` is just another public text file, so publishing it is backwards-compatible by default. citeturn7view0 - -At the agent layer, support is uneven but improving. Some vendors and docs platforms clearly use it operationally, and tooling exists to parse it, generate context bundles, and validate structure. The official Python tooling parses the file and can expand it into XML context for models such as Claude; the PHP library supports creating, reading, and validating files; and ecosystem plugins generate both `llms.txt` and, in some cases, `llms-full.txt`. citeturn7view1turn22view0turn22view2turn22view3 - -At the control layer, do not assume compliance. If you need to manage AI crawlers, pair `llms.txt` with `robots.txt`, logs, and enforcement. Cloudflare’s AI Crawl Control is a good illustration: it separately tracks AI crawler activity, allows/block rules, monitors robots compliance, and can monetise crawl access. citeturn20view0turn20view1 - -For HydePHP sites, the recommended default content is conservative: - -Use the site/project name as the H1. Use the Hyde description as the summary. Provide one short explanatory paragraph if needed. Then publish a small number of sections such as `Docs`, `Reference`, `Blog`, and `Optional`. Within those sections, prefer stable, canonical pages with strong titles and strong descriptions. Exclude search indexes, feeds, sitemaps, tag archives, pagination artefacts, media files, drafts, previews, and machine-only outputs. The HydePHP planning note explicitly associates `llms.txt` generation with page titles and documentation abstracts, which is exactly the right default. citeturn7view0turn6view0 fileciteturn0file0L519-L524 - -A good maintainer checklist is therefore: - -- Confirm that `/llms.txt` exists at the site root and is served as plain text. citeturn7view0turn14view0turn14view1 -- Keep exactly one H1 and use the canonical project/site name. citeturn7view0 -- Include a short summary blockquote and concise link descriptions. citeturn7view0 -- Prefer Markdown or other clean text targets where available; otherwise use stable canonical URLs. citeturn6view0turn20view0turn15view0 -- Exclude private, draft, preview, search-index, feed, sitemap, and other non-user-facing outputs. fileciteturn0file0L148-L166 -- Pair the file with `robots.txt`, access control, and bot management where policy matters. citeturn13view0turn20view0 -- Rebuild and purge caches whenever key content changes. -- Validate the structure in CI before deployment. citeturn22view0turn7view1 - -For automated tests, I would add both content tests and behaviour tests. The llmstxt.org guidance explicitly recommends expanding the file into an LLM context file and then testing models against real questions. The official parser/CLI and the PHP library make that easy. citeturn7view0turn7view1turn22view0 - -A practical PHPUnit-style test might be: - -```php -assertFileExists($path); - - $txt = file_get_contents($path); - $this->assertStringStartsWith('# ', $txt); - $this->assertStringContainsString('## Docs', $txt); - $this->assertStringNotContainsString('/drafts/', $txt); - $this->assertStringNotContainsString('search.json', $txt); - - $parsed = (new LlmsTxt())->parse($path); - $this->assertTrue($parsed->validate()); - } -} -``` - -I would also add a link-check test that every URL listed in `llms.txt` resolves successfully after build, plus a snapshot/regression test to catch accidental churn in headings, section names, or excluded paths. For a higher-level test, run the generated file through `llms_txt2ctx` and verify that a model or parser can answer a few deterministic site questions from the expanded context. citeturn7view1turn22view0 \ No newline at end of file From 3e3a3a9badbb189b91057a98a15e9e3db8089983 Mon Sep 17 00:00:00 2001 From: Emma De Silva Date: Tue, 14 Jul 2026 03:26:56 +0200 Subject: [PATCH 085/117] Add exact-path in-memory file pages --- .../DocumentationSearchIndex.php | 2 +- .../Features/TextGenerators/LlmsTxtPage.php | 2 +- .../Features/TextGenerators/RobotsTxtPage.php | 2 +- .../Features/XmlGenerators/RssFeedPage.php | 12 +-- .../Features/XmlGenerators/SitemapPage.php | 2 +- packages/framework/src/Pages/InMemoryPage.php | 51 +++++----- .../framework/src/Support/Models/Redirect.php | 9 -- .../Commands/BuildRssFeedCommandTest.php | 2 +- .../Commands/BuildSitemapCommandTest.php | 4 +- .../tests/Feature/LlmsTxtPageTest.php | 4 +- .../tests/Feature/NonHtmlPageOutputTest.php | 37 +++++-- .../framework/tests/Feature/RedirectTest.php | 21 +--- .../tests/Feature/RobotsTxtPageTest.php | 4 +- .../tests/Feature/RssFeedPageTest.php | 4 +- .../Feature/Services/SitemapServiceTest.php | 4 +- .../tests/Feature/SitemapPageTest.php | 4 +- .../tests/Unit/Pages/InMemoryPageTest.php | 98 +++++++------------ .../tests/Unit/Pages/InMemoryPageUnitTest.php | 16 ++- 18 files changed, 120 insertions(+), 158 deletions(-) diff --git a/packages/framework/src/Framework/Features/Documentation/DocumentationSearchIndex.php b/packages/framework/src/Framework/Features/Documentation/DocumentationSearchIndex.php index bde991b7e74..fc595b61fc9 100644 --- a/packages/framework/src/Framework/Features/Documentation/DocumentationSearchIndex.php +++ b/packages/framework/src/Framework/Features/Documentation/DocumentationSearchIndex.php @@ -26,7 +26,7 @@ public function __construct(?DocumentationVersion $version = null) parent::__construct(static::routeKey($version), [ 'navigation' => ['hidden' => true], - ]); + ], exactOutputPath: true); } public function compile(): string diff --git a/packages/framework/src/Framework/Features/TextGenerators/LlmsTxtPage.php b/packages/framework/src/Framework/Features/TextGenerators/LlmsTxtPage.php index 9d10ec260f9..782d0946bb9 100644 --- a/packages/framework/src/Framework/Features/TextGenerators/LlmsTxtPage.php +++ b/packages/framework/src/Framework/Features/TextGenerators/LlmsTxtPage.php @@ -21,7 +21,7 @@ public function __construct() { parent::__construct(static::routeKey(), [ 'navigation' => ['hidden' => true], - ]); + ], exactOutputPath: true); } public function compile(): string diff --git a/packages/framework/src/Framework/Features/TextGenerators/RobotsTxtPage.php b/packages/framework/src/Framework/Features/TextGenerators/RobotsTxtPage.php index 62653a35f5c..0e68f158378 100644 --- a/packages/framework/src/Framework/Features/TextGenerators/RobotsTxtPage.php +++ b/packages/framework/src/Framework/Features/TextGenerators/RobotsTxtPage.php @@ -21,7 +21,7 @@ public function __construct() { parent::__construct(static::routeKey(), [ 'navigation' => ['hidden' => true], - ]); + ], exactOutputPath: true); } public function compile(): string diff --git a/packages/framework/src/Framework/Features/XmlGenerators/RssFeedPage.php b/packages/framework/src/Framework/Features/XmlGenerators/RssFeedPage.php index e61b8b131b8..658565a73a4 100644 --- a/packages/framework/src/Framework/Features/XmlGenerators/RssFeedPage.php +++ b/packages/framework/src/Framework/Features/XmlGenerators/RssFeedPage.php @@ -21,7 +21,7 @@ public function __construct() { parent::__construct(static::routeKey(), [ 'navigation' => ['hidden' => true], - ]); + ], exactOutputPath: true); } public function compile(): string @@ -36,14 +36,4 @@ public static function routeKey(): string { return RssFeedGenerator::getFilename(); } - - /** - * The identifier is the user-configured `hyde.rss.filename` and is always used - * verbatim as the output path, regardless of its extension, so filenames like - * `feed.rss` outside the default recognized extensions keep working. - */ - protected static function identifierHasExplicitOutputExtension(string $identifier): bool - { - return true; - } } diff --git a/packages/framework/src/Framework/Features/XmlGenerators/SitemapPage.php b/packages/framework/src/Framework/Features/XmlGenerators/SitemapPage.php index 075e79cac23..87673073977 100644 --- a/packages/framework/src/Framework/Features/XmlGenerators/SitemapPage.php +++ b/packages/framework/src/Framework/Features/XmlGenerators/SitemapPage.php @@ -21,7 +21,7 @@ public function __construct() { parent::__construct(static::routeKey(), [ 'navigation' => ['hidden' => true], - ]); + ], exactOutputPath: true); } public function compile(): string diff --git a/packages/framework/src/Pages/InMemoryPage.php b/packages/framework/src/Pages/InMemoryPage.php index 63a7c0c7f7b..a3b0b32965c 100644 --- a/packages/framework/src/Pages/InMemoryPage.php +++ b/packages/framework/src/Pages/InMemoryPage.php @@ -8,10 +8,10 @@ use Hyde\Framework\Actions\AnonymousViewCompiler; use Hyde\Markdown\Models\FrontMatter; use Hyde\Pages\Concerns\HydePage; -use Hyde\Support\Models\RouteKey; use Illuminate\Support\Facades\View; use InvalidArgumentException; +use function Hyde\unslash; use function str_ends_with; /** @@ -36,8 +36,6 @@ class InMemoryPage extends HydePage public static string $outputDirectory; public static string $sourceExtension; - protected const EXPLICIT_OUTPUT_EXTENSIONS = ['.json', '.txt', '.xml']; - /** * The literal page contents, or a closure that generates them at compile time. * @@ -46,6 +44,7 @@ class InMemoryPage extends HydePage * @var string|(Closure(): string)|(Closure(static): string) */ protected string|Closure $contents; + protected readonly bool $exactOutputPath; /** * The Blade view key or Blade file path. @@ -68,6 +67,20 @@ public static function make( return new static($identifier, $matter, $contents, $view); } + /** + * Create an in-memory page whose identifier is used as the exact output path. + * + * @param string|(Closure(): string)|(Closure(static): string)|null $contents + */ + public static function file( + string $outputPath, + FrontMatter|array $matter = [], + string|Closure|null $contents = null, + ?string $view = null, + ): static { + return new static($outputPath, $matter, $contents, $view, exactOutputPath: true); + } + /** * Create a new in-memory (virtual) page instance. * @@ -79,7 +92,6 @@ public static function make( * * View values ending in `.blade.php` are treated as Blade file paths. Other values are treated * as registered Laravel view keys. - * Identifiers ending in `.json`, `.txt`, or `.xml` retain that extension in the output path. * * @param string $identifier * @param FrontMatter|array $matter @@ -93,7 +105,10 @@ public function __construct( FrontMatter|array $matter = [], string|Closure|null $contents = null, ?string $view = null, + bool $exactOutputPath = false, ) { + $this->exactOutputPath = $exactOutputPath; + parent::__construct($identifier, $matter); $view = $view === '' ? null : $view; @@ -109,33 +124,15 @@ public function __construct( } /** - * Qualify a page identifier into a target output file path, relative to the _site output directory. - * - * If the identifier ends in a recognized non-HTML extension (`.json`, `.txt`, or `.xml` by default), - * it is treated as an explicit output path and no HTML extension is appended, so an identifier - * of "robots.txt" saves the page to "_site/robots.txt". - */ - public static function outputPath(string $identifier): string - { - if (static::identifierHasExplicitOutputExtension($identifier)) { - return (string) RouteKey::fromPage(static::class, $identifier); - } - - return parent::outputPath($identifier); - } - - /** - * Determine whether the identifier ends with an explicit output extension. + * Get the path where the compiled page will be saved. */ - protected static function identifierHasExplicitOutputExtension(string $identifier): bool + public function getOutputPath(): string { - foreach (static::EXPLICIT_OUTPUT_EXTENSIONS as $extension) { - if (str_ends_with($identifier, $extension)) { - return true; - } + if ($this->exactOutputPath) { + return unslash($this->identifier); } - return false; + return parent::getOutputPath(); } /** diff --git a/packages/framework/src/Support/Models/Redirect.php b/packages/framework/src/Support/Models/Redirect.php index d85baf1fa82..94aaa5b1ed7 100644 --- a/packages/framework/src/Support/Models/Redirect.php +++ b/packages/framework/src/Support/Models/Redirect.php @@ -6,7 +6,6 @@ use Hyde\Pages\InMemoryPage; use Hyde\Markdown\Models\FrontMatter; -use Hyde\Framework\Exceptions\InvalidConfigurationException; use Illuminate\Support\Facades\View; use function str_ends_with; @@ -50,20 +49,12 @@ public function showInSitemap(): bool return false; } - /** - * @throws \Hyde\Framework\Exceptions\InvalidConfigurationException If the path ends in a non-HTML output extension, - * as meta refresh redirects only work for HTML pages. - */ protected function normalizePath(string $path): string { if (str_ends_with($path, '.html')) { return substr($path, 0, -5); } - if (static::identifierHasExplicitOutputExtension($path)) { - throw new InvalidConfigurationException("Invalid redirect source path [$path]: redirects use HTML meta refresh tags, which cannot work for files served as non-HTML content."); - } - return $path; } } diff --git a/packages/framework/tests/Feature/Commands/BuildRssFeedCommandTest.php b/packages/framework/tests/Feature/Commands/BuildRssFeedCommandTest.php index 9015f696290..5d44aa1884e 100644 --- a/packages/framework/tests/Feature/Commands/BuildRssFeedCommandTest.php +++ b/packages/framework/tests/Feature/Commands/BuildRssFeedCommandTest.php @@ -88,7 +88,7 @@ public function testCommandBuildsUserDefinedFeedPageEvenWhenRssFeatureConditions $this->cleanUpWhenDone('_site/feed.xml'); Hyde::kernel()->booting(function (HydeKernel $kernel): void { - $kernel->pages()->addPage(new InMemoryPage('feed.xml', contents: '')); + $kernel->pages()->addPage(InMemoryPage::file('feed.xml', contents: '')); }); $this->artisan('build:rss')->assertExitCode(0); diff --git a/packages/framework/tests/Feature/Commands/BuildSitemapCommandTest.php b/packages/framework/tests/Feature/Commands/BuildSitemapCommandTest.php index f8acb2b7a95..3822aa4e38e 100644 --- a/packages/framework/tests/Feature/Commands/BuildSitemapCommandTest.php +++ b/packages/framework/tests/Feature/Commands/BuildSitemapCommandTest.php @@ -60,7 +60,7 @@ public function testCommandBuildsUserDefinedSitemapPageWhenOneIsRegistered() $this->cleanUpWhenDone('_site/sitemap.xml'); Hyde::kernel()->booting(function (HydeKernel $kernel): void { - $kernel->pages()->addPage(new InMemoryPage('sitemap.xml', contents: '')); + $kernel->pages()->addPage(InMemoryPage::file('sitemap.xml', contents: '')); }); $this->artisan('build:sitemap')->assertExitCode(0); @@ -76,7 +76,7 @@ public function testCommandBuildsUserDefinedSitemapPageEvenWhenSitemapFeatureIsD $this->cleanUpWhenDone('_site/sitemap.xml'); Hyde::kernel()->booting(function (HydeKernel $kernel): void { - $kernel->pages()->addPage(new InMemoryPage('sitemap.xml', contents: '')); + $kernel->pages()->addPage(InMemoryPage::file('sitemap.xml', contents: '')); }); $this->artisan('build:sitemap')->assertExitCode(0); diff --git a/packages/framework/tests/Feature/LlmsTxtPageTest.php b/packages/framework/tests/Feature/LlmsTxtPageTest.php index 84a53880e4c..1b3b65ebc80 100644 --- a/packages/framework/tests/Feature/LlmsTxtPageTest.php +++ b/packages/framework/tests/Feature/LlmsTxtPageTest.php @@ -128,7 +128,7 @@ public function testLlmsTxtRouteIsIncludedInTheRouteList() public function testUserPageRegisteredInBootingCallbackSuppressesTheGeneratedLlmsTxtPage() { Hyde::kernel()->booting(function (HydeKernel $kernel): void { - $kernel->pages()->addPage(new InMemoryPage('llms.txt', contents: 'user defined llms')); + $kernel->pages()->addPage(InMemoryPage::file('llms.txt', contents: 'user defined llms')); }); $page = Routes::get('llms.txt')->getPage(); @@ -160,6 +160,6 @@ class LlmsTxtPageTestExtension extends HydeExtension { public function discoverPages(PageCollection $collection): void { - $collection->addPage(new InMemoryPage('llms.txt', contents: 'extension defined llms')); + $collection->addPage(InMemoryPage::file('llms.txt', contents: 'extension defined llms')); } } diff --git a/packages/framework/tests/Feature/NonHtmlPageOutputTest.php b/packages/framework/tests/Feature/NonHtmlPageOutputTest.php index 7cde07439ea..cd45080b81f 100644 --- a/packages/framework/tests/Feature/NonHtmlPageOutputTest.php +++ b/packages/framework/tests/Feature/NonHtmlPageOutputTest.php @@ -15,9 +15,9 @@ use Hyde\Framework\Actions\StaticPageBuilder; /** - * Feature test for compiling in-memory pages with non-HTML output file extensions, + * Feature test for compiling in-memory pages to exact output paths, * covering the user path of registering pages like robots.txt in code and having - * them compiled to their declared output paths by the standard build process. + * them compiled to their specified output paths by the standard build process. * * @see \Hyde\Framework\Testing\Unit\Pages\InMemoryPageTest */ @@ -35,7 +35,7 @@ protected function tearDown(): void public function testBuildCommandCompilesInMemoryPageWithTxtExtensionToDeclaredOutputPath() { Hyde::kernel()->booting(function (HydeKernel $kernel): void { - $kernel->pages()->addPage(new InMemoryPage('robots.txt', contents: "User-agent: *\nAllow: /")); + $kernel->pages()->addPage(InMemoryPage::file('robots.txt', contents: "User-agent: *\nAllow: /")); }); $this->artisan('build')->assertExitCode(0); @@ -48,7 +48,7 @@ public function testBuildCommandCompilesInMemoryPageWithTxtExtensionToDeclaredOu public function testBuildCommandCompilesInMemoryPageWithJsonExtensionToDeclaredOutputPath() { Hyde::kernel()->booting(function (HydeKernel $kernel): void { - $kernel->pages()->addPage(new InMemoryPage('data.json', contents: '{"foo": "bar"}')); + $kernel->pages()->addPage(InMemoryPage::file('data.json', contents: '{"foo": "bar"}')); }); $this->artisan('build')->assertExitCode(0); @@ -60,7 +60,7 @@ public function testBuildCommandCompilesInMemoryPageWithJsonExtensionToDeclaredO public function testBuildCommandCompilesInMemoryPageWithXmlExtensionToDeclaredOutputPath() { Hyde::kernel()->booting(function (HydeKernel $kernel): void { - $kernel->pages()->addPage(new InMemoryPage('custom.xml', contents: '')); + $kernel->pages()->addPage(InMemoryPage::file('custom.xml', contents: '')); }); $this->artisan('build')->assertExitCode(0); @@ -72,7 +72,7 @@ public function testBuildCommandCompilesInMemoryPageWithXmlExtensionToDeclaredOu public function testBuildCommandCompilesInMemoryPageWithDeclaredExtensionInNestedPath() { Hyde::kernel()->booting(function (HydeKernel $kernel): void { - $kernel->pages()->addPage(new InMemoryPage('foo/bar.txt', contents: 'baz')); + $kernel->pages()->addPage(InMemoryPage::file('foo/bar.txt', contents: 'baz')); }); $this->artisan('build')->assertExitCode(0); @@ -81,10 +81,27 @@ public function testBuildCommandCompilesInMemoryPageWithDeclaredExtensionInNeste $this->assertSame('baz', file_get_contents(Hyde::path('_site/foo/bar.txt'))); } + public function testBuildCommandCompilesArbitraryExactOutputPathsWithoutExtensionInference() + { + Hyde::kernel()->booting(function (HydeKernel $kernel): void { + $kernel->pages()->addPage(InMemoryPage::file('site.webmanifest', contents: 'manifest')); + $kernel->pages()->addPage(InMemoryPage::file('sitemap.xsl', contents: 'stylesheet')); + $kernel->pages()->addPage(InMemoryPage::file('downloads/data.csv', contents: 'csv')); + $kernel->pages()->addPage(InMemoryPage::file('feed', contents: 'feed')); + }); + + $this->artisan('build')->assertExitCode(0); + + $this->assertSame('manifest', file_get_contents(Hyde::path('_site/site.webmanifest'))); + $this->assertSame('stylesheet', file_get_contents(Hyde::path('_site/sitemap.xsl'))); + $this->assertSame('csv', file_get_contents(Hyde::path('_site/downloads/data.csv'))); + $this->assertSame('feed', file_get_contents(Hyde::path('_site/feed'))); + } + public function testInMemoryPageWithDeclaredExtensionIsRegisteredAsRoute() { Hyde::kernel()->booting(function (HydeKernel $kernel): void { - $kernel->pages()->addPage(new InMemoryPage('robots.txt', contents: 'User-agent: *')); + $kernel->pages()->addPage(InMemoryPage::file('robots.txt', contents: 'User-agent: *')); }); $this->assertTrue(Routes::exists('robots.txt')); @@ -93,7 +110,7 @@ public function testInMemoryPageWithDeclaredExtensionIsRegisteredAsRoute() public function testStaticPageBuilderCompilesInMemoryPageWithDeclaredExtension() { - StaticPageBuilder::handle(new InMemoryPage('llms.txt', contents: '# Hello World')); + StaticPageBuilder::handle(InMemoryPage::file('llms.txt', contents: '# Hello World')); $this->assertFileExists(Hyde::path('_site/llms.txt')); $this->assertSame('# Hello World', file_get_contents(Hyde::path('_site/llms.txt'))); @@ -103,7 +120,7 @@ public function testInMemoryPageWithDeclaredExtensionCanCompileUsingView() { $this->file('_pages/robots.blade.php', 'User-agent: {{ $agent }}'); - StaticPageBuilder::handle(new InMemoryPage('robots.txt', ['agent' => '*'], view: 'robots')); + StaticPageBuilder::handle(InMemoryPage::file('robots.txt', ['agent' => '*'], view: 'robots')); $this->assertFileExists(Hyde::path('_site/robots.txt')); $this->assertSame('User-agent: *', file_get_contents(Hyde::path('_site/robots.txt'))); @@ -114,7 +131,7 @@ public function testBuildCommandExcludesNonHtmlPagesFromTheSitemap() $this->withSiteUrl(); Hyde::kernel()->booting(function (HydeKernel $kernel): void { - $kernel->pages()->addPage(new InMemoryPage('robots.txt', contents: 'User-agent: *')); + $kernel->pages()->addPage(InMemoryPage::file('robots.txt', contents: 'User-agent: *')); }); $this->artisan('build')->assertExitCode(0); diff --git a/packages/framework/tests/Feature/RedirectTest.php b/packages/framework/tests/Feature/RedirectTest.php index 9e4fcf35715..46b8a67c7d7 100644 --- a/packages/framework/tests/Feature/RedirectTest.php +++ b/packages/framework/tests/Feature/RedirectTest.php @@ -8,10 +8,8 @@ use Hyde\Foundation\HydeKernel; use Hyde\Framework\Actions\StaticPageBuilder; use Hyde\Hyde; -use Hyde\Framework\Exceptions\InvalidConfigurationException; use Hyde\Support\Models\Redirect; use Hyde\Testing\TestCase; -use RuntimeException; #[\PHPUnit\Framework\Attributes\CoversClass(\Hyde\Support\Models\Redirect::class)] class RedirectTest extends TestCase @@ -66,23 +64,12 @@ public function testConfiguredRedirectsAreRegisteredWithTheKernelAndBuiltWithThe Filesystem::unlink('_site/foo.html'); } - public function testRedirectWithNonHtmlSourcePathIsRejected() + public function testDottedRedirectPathUsesHtmlPageSemantics() { - $this->expectException(InvalidConfigurationException::class); - $this->expectExceptionMessage('Invalid redirect source path [legacy.json]: redirects use HTML meta refresh tags'); + $redirect = new Redirect('legacy.json', 'new-location'); - new Redirect('legacy.json', 'new-location'); - } - - public function testConfiguredRedirectWithNonHtmlSourcePathIsRejected() - { - config(['hyde.redirects' => ['legacy.txt' => 'new-location']]); - HydeKernel::setInstance(new HydeKernel(Hyde::path())); - - $this->expectException(RuntimeException::class); - $this->expectExceptionMessage('Invalid redirect source path [legacy.txt]'); - - Hyde::pages(); + $this->assertSame('legacy.json', $redirect->getRouteKey()); + $this->assertSame('legacy.json.html', $redirect->getOutputPath()); } public function testRedirectsCannotWriteOutsideTheBuildPipeline() diff --git a/packages/framework/tests/Feature/RobotsTxtPageTest.php b/packages/framework/tests/Feature/RobotsTxtPageTest.php index c30f14d92f2..00a66ce3f6b 100644 --- a/packages/framework/tests/Feature/RobotsTxtPageTest.php +++ b/packages/framework/tests/Feature/RobotsTxtPageTest.php @@ -127,7 +127,7 @@ public function testRobotsTxtRouteIsIncludedInTheRouteList() public function testUserPageRegisteredInBootingCallbackSuppressesTheGeneratedRobotsTxtPage() { Hyde::kernel()->booting(function (HydeKernel $kernel): void { - $kernel->pages()->addPage(new InMemoryPage('robots.txt', contents: 'user defined robots')); + $kernel->pages()->addPage(InMemoryPage::file('robots.txt', contents: 'user defined robots')); }); $page = Routes::get('robots.txt')->getPage(); @@ -159,6 +159,6 @@ class RobotsTxtPageTestExtension extends HydeExtension { public function discoverPages(PageCollection $collection): void { - $collection->addPage(new InMemoryPage('robots.txt', contents: 'extension defined robots')); + $collection->addPage(InMemoryPage::file('robots.txt', contents: 'extension defined robots')); } } diff --git a/packages/framework/tests/Feature/RssFeedPageTest.php b/packages/framework/tests/Feature/RssFeedPageTest.php index b743b9f8a5f..a7d7853896f 100644 --- a/packages/framework/tests/Feature/RssFeedPageTest.php +++ b/packages/framework/tests/Feature/RssFeedPageTest.php @@ -163,7 +163,7 @@ public function testFeedRouteIsIncludedInTheRouteList() public function testUserPageRegisteredInBootingCallbackSuppressesTheGeneratedFeedPage() { Hyde::kernel()->booting(function (HydeKernel $kernel): void { - $kernel->pages()->addPage(new InMemoryPage('feed.xml', contents: 'user defined feed')); + $kernel->pages()->addPage(InMemoryPage::file('feed.xml', contents: 'user defined feed')); }); $page = Routes::get('feed.xml')->getPage(); @@ -195,6 +195,6 @@ class RssFeedPageTestExtension extends HydeExtension { public function discoverPages(PageCollection $collection): void { - $collection->addPage(new InMemoryPage('feed.xml', contents: 'extension defined feed')); + $collection->addPage(InMemoryPage::file('feed.xml', contents: 'extension defined feed')); } } diff --git a/packages/framework/tests/Feature/Services/SitemapServiceTest.php b/packages/framework/tests/Feature/Services/SitemapServiceTest.php index 9d9e867f2b0..35358e1c73b 100644 --- a/packages/framework/tests/Feature/Services/SitemapServiceTest.php +++ b/packages/framework/tests/Feature/Services/SitemapServiceTest.php @@ -135,7 +135,7 @@ public function testGenerateDoesNotAddPagesWithSitemapFrontMatterSetToQuotedFals public function testGenerateDoesNotAddPagesWithNonHtmlOutputPaths() { - Routes::addRoute(new Route(new InMemoryPage('robots.txt'))); + Routes::addRoute(new Route(InMemoryPage::file('robots.txt'))); $service = new SitemapGenerator(); $service->generate(); @@ -146,7 +146,7 @@ public function testGenerateDoesNotAddPagesWithNonHtmlOutputPaths() public function testGenerateAddsNonHtmlPagesWithSitemapFrontMatterSetToTrue() { - Routes::addRoute(new Route(new InMemoryPage('robots.txt', ['sitemap' => true]))); + Routes::addRoute(new Route(InMemoryPage::file('robots.txt', ['sitemap' => true]))); $service = new SitemapGenerator(); $service->generate(); diff --git a/packages/framework/tests/Feature/SitemapPageTest.php b/packages/framework/tests/Feature/SitemapPageTest.php index 5969a0a4d22..873146887b1 100644 --- a/packages/framework/tests/Feature/SitemapPageTest.php +++ b/packages/framework/tests/Feature/SitemapPageTest.php @@ -141,7 +141,7 @@ public function testUserPageRegisteredInBootingCallbackSuppressesTheGeneratedSit $this->withSiteUrl(); Hyde::kernel()->booting(function (HydeKernel $kernel): void { - $kernel->pages()->addPage(new InMemoryPage('sitemap.xml', contents: 'user defined sitemap')); + $kernel->pages()->addPage(InMemoryPage::file('sitemap.xml', contents: 'user defined sitemap')); }); $page = Routes::get('sitemap.xml')->getPage(); @@ -175,6 +175,6 @@ class SitemapPageTestExtension extends HydeExtension { public function discoverPages(PageCollection $collection): void { - $collection->addPage(new InMemoryPage('sitemap.xml', contents: 'extension defined sitemap')); + $collection->addPage(InMemoryPage::file('sitemap.xml', contents: 'extension defined sitemap')); } } diff --git a/packages/framework/tests/Unit/Pages/InMemoryPageTest.php b/packages/framework/tests/Unit/Pages/InMemoryPageTest.php index 24502bb217b..012c6438094 100644 --- a/packages/framework/tests/Unit/Pages/InMemoryPageTest.php +++ b/packages/framework/tests/Unit/Pages/InMemoryPageTest.php @@ -32,6 +32,12 @@ public function testCanMakePageWithLiteralContents() $this->assertSame('bar', $page->getContents()); } + public function testFileWithContentsString() + { + $this->assertInstanceOf(InMemoryPage::class, InMemoryPage::file('robots.txt', contents: 'bar')); + $this->assertSame('robots.txt', InMemoryPage::file('robots.txt', contents: 'bar')->getOutputPath()); + } + public function testGetContentsReturnsLiteralContents() { $this->assertSame('bar', (new InMemoryPage('foo', contents: 'bar'))->getContents()); @@ -397,99 +403,65 @@ public function getContents(): string $this->assertSame('view', $page->compile()); $this->assertSame(0, $page->invocations); } -} - -class InMemoryPageContentTestPage extends InMemoryPage -{ - protected string $contentPrefix = 'subclass:'; - - public function contentPrefix(): string - { - return $this->contentPrefix; - } - - public function testIdentifierCanDeclareTxtOutputExtension() - { - $this->assertSame('robots.txt', InMemoryPage::outputPath('robots.txt')); - } - - public function testIdentifierCanDeclareJsonOutputExtension() - { - $this->assertSame('data.json', InMemoryPage::outputPath('data.json')); - } - - public function testIdentifierCanDeclareXmlOutputExtension() - { - $this->assertSame('sitemap.xml', InMemoryPage::outputPath('sitemap.xml')); - } - public function testIdentifierCanDeclareOutputExtensionForNestedPages() - { - $this->assertSame('docs/search.json', InMemoryPage::outputPath('docs/search.json')); - } - - public function testIdentifierWithoutExtensionGetsHtmlOutputExtension() + public function testOutputPathUsesNormalHtmlPageSemantics() { $this->assertSame('foo.html', InMemoryPage::outputPath('foo')); - } - - public function testIdentifierWithUnrecognizedExtensionGetsHtmlOutputExtension() - { + $this->assertSame('robots.txt.html', InMemoryPage::outputPath('robots.txt')); + $this->assertSame('data.json.html', InMemoryPage::outputPath('data.json')); + $this->assertSame('sitemap.xml.html', InMemoryPage::outputPath('sitemap.xml')); + $this->assertSame('docs/search.json.html', InMemoryPage::outputPath('docs/search.json')); $this->assertSame('foo.md.html', InMemoryPage::outputPath('foo.md')); - } - - public function testIdentifierWithHtmlExtensionGetsHtmlOutputExtensionAppended() - { $this->assertSame('foo.html.html', InMemoryPage::outputPath('foo.html')); - } - - public function testDottedIdentifierIsNotMistakenForDeclaredOutputExtension() - { $this->assertSame('docs/1.x.html', InMemoryPage::outputPath('docs/1.x')); } - public function testSubclassCanCustomizeExplicitOutputExtensions() - { - $this->assertSame('data.csv', CsvInMemoryPage::outputPath('data.csv')); - $this->assertSame('robots.txt.html', CsvInMemoryPage::outputPath('robots.txt')); - } - - public function testGetOutputPathForIdentifierWithDeclaredOutputExtension() + public function testFileUsesAnyOutputPathExactly() { - $this->assertSame('robots.txt', (new InMemoryPage('robots.txt'))->getOutputPath()); + $this->assertSame('robots.txt', InMemoryPage::file('robots.txt')->getOutputPath()); + $this->assertSame('site.webmanifest', InMemoryPage::file('site.webmanifest')->getOutputPath()); + $this->assertSame('sitemap.xsl', InMemoryPage::file('sitemap.xsl')->getOutputPath()); + $this->assertSame('downloads/data.csv', InMemoryPage::file('downloads/data.csv')->getOutputPath()); + $this->assertSame('feed', InMemoryPage::file('feed')->getOutputPath()); } - public function testGetRouteKeyForIdentifierWithDeclaredOutputExtension() + public function testGetRouteKeyForFile() { - $this->assertSame('robots.txt', (new InMemoryPage('robots.txt'))->getRouteKey()); + $this->assertSame('robots.txt', InMemoryPage::file('robots.txt')->getRouteKey()); + $this->assertSame('feed', InMemoryPage::file('feed')->getRouteKey()); } - public function testGetLinkForIdentifierWithDeclaredOutputExtension() + public function testGetLinkForFile() { - $this->assertSame('robots.txt', (new InMemoryPage('robots.txt'))->getLink()); + $this->assertSame('robots.txt', InMemoryPage::file('robots.txt')->getLink()); } - public function testGetLinkForIdentifierWithDeclaredOutputExtensionIsNotAffectedByPrettyUrls() + public function testGetLinkForFileIsNotAffectedByPrettyUrls() { config(['hyde.pretty_urls' => true]); - $this->assertSame('robots.txt', (new InMemoryPage('robots.txt'))->getLink()); + $this->assertSame('robots.txt', InMemoryPage::file('robots.txt')->getLink()); } - public function testGetCanonicalUrlForIdentifierWithDeclaredOutputExtension() + public function testGetCanonicalUrlForFile() { config(['hyde.url' => 'https://example.com']); - $this->assertSame('https://example.com/robots.txt', (new InMemoryPage('robots.txt'))->getCanonicalUrl()); + $this->assertSame('https://example.com/robots.txt', InMemoryPage::file('robots.txt')->getCanonicalUrl()); } - public function testCompiledContentsAreNotAffectedByDeclaredOutputExtension() + public function testCompiledContentsAreNotAffectedByExactOutputPath() { - $this->assertSame('User-agent: *', (new InMemoryPage('robots.txt', contents: 'User-agent: *'))->compile()); + $this->assertSame('User-agent: *', InMemoryPage::file('robots.txt', contents: 'User-agent: *')->compile()); } } -class CsvInMemoryPage extends InMemoryPage +class InMemoryPageContentTestPage extends InMemoryPage { - protected const EXPLICIT_OUTPUT_EXTENSIONS = ['.csv']; + protected string $contentPrefix = 'subclass:'; + + public function contentPrefix(): string + { + return $this->contentPrefix; + } } diff --git a/packages/framework/tests/Unit/Pages/InMemoryPageUnitTest.php b/packages/framework/tests/Unit/Pages/InMemoryPageUnitTest.php index 17b77ae2524..3b770e28c64 100644 --- a/packages/framework/tests/Unit/Pages/InMemoryPageUnitTest.php +++ b/packages/framework/tests/Unit/Pages/InMemoryPageUnitTest.php @@ -113,6 +113,14 @@ public function testMake() $this->assertEquals(InMemoryPage::make('foo'), new InMemoryPage('foo')); } + public function testFile() + { + $this->assertEquals( + InMemoryPage::file('robots.txt', contents: 'User-agent: *'), + new InMemoryPage('robots.txt', contents: 'User-agent: *', exactOutputPath: true) + ); + } + public function testMakeWithData() { $this->assertEquals( @@ -134,11 +142,11 @@ public function testShowInSitemap() public function testShowInSitemapIsFalseForPagesWithNonHtmlOutputPaths() { - $this->assertFalse((new InMemoryPage('robots.txt'))->showInSitemap()); - $this->assertFalse((new InMemoryPage('data.json'))->showInSitemap()); - $this->assertFalse((new InMemoryPage('custom.xml'))->showInSitemap()); + $this->assertFalse(InMemoryPage::file('robots.txt')->showInSitemap()); + $this->assertFalse(InMemoryPage::file('data.json')->showInSitemap()); + $this->assertFalse(InMemoryPage::file('custom.xml')->showInSitemap()); - $this->assertTrue((new InMemoryPage('robots.txt', ['sitemap' => true]))->showInSitemap()); + $this->assertTrue(InMemoryPage::file('robots.txt', ['sitemap' => true])->showInSitemap()); } public function testNavigationMenuPriority() From 8bde4eb937fc008b39dada79e9fcfbbbb65d15fd Mon Sep 17 00:00:00 2001 From: Emma De Silva Date: Tue, 14 Jul 2026 03:29:51 +0200 Subject: [PATCH 086/117] Document exact-path in-memory files --- EPIC_NON_HTML_PAGES.md | 137 ++++++------------ HYDEPHP_V3_PLANNING.md | 4 +- UPGRADE.md | 2 +- .../hyde-pages-api/in-memory-page-methods.md | 21 ++- docs/advanced-features/in-memory-pages.md | 17 ++- packages/framework/src/Pages/InMemoryPage.php | 1 + 6 files changed, 75 insertions(+), 107 deletions(-) diff --git a/EPIC_NON_HTML_PAGES.md b/EPIC_NON_HTML_PAGES.md index e0c09c1e29d..725651bf3f3 100644 --- a/EPIC_NON_HTML_PAGES.md +++ b/EPIC_NON_HTML_PAGES.md @@ -38,8 +38,8 @@ require every output format to have a matching filesystem-discovered page class. `docs/2.x/search.json`. There is no per-page sitemap exclusion mechanism at all. - **`hyde serve` does not serve `sitemap.xml` or the RSS feed**, because they only exist as post-build artifacts. -- **`InMemoryPage::make('robots.txt', contents: ...)` outputs `robots.txt.html`**, - making the natural "assemble it in code" escape hatch a trap. +- **`InMemoryPage` has no unambiguous exact-file construction mode**, making the + natural "assemble a non-HTML file in code" escape hatch depend on inference. - **The realtime compiler special-cases `search.json` by string suffix** instead of asking the route system. @@ -87,67 +87,24 @@ versioned docs route keys like `docs/1.x/index` would false-positive - `HydePage` gets `public static string $outputExtension = '.html'` (or an instance-level hook), and `outputPath()` uses it instead of the hardcoded `'.html'`. This removes the `getOutputPath()` override dance. -- For `InMemoryPage`, decide in the PR whether to (a) accept a small allowlist of - trailing extensions in the identifier (`.txt`, `.json`, `.xml`), or (b) add an - explicit constructor/`make()` parameter. Leaning (a) with allowlist since - `InMemoryPage::make('robots.txt', contents: $txt)` is the DX we actually want. - -> **Decided (PR 1):** option (a). The allowlist (`.json`, `.txt`, `.xml`) is the -> `InMemoryPage::EXPLICIT_OUTPUT_EXTENSIONS` constant, checked by -> `identifierHasExplicitOutputExtension()`; subclasses customize the recognized -> extensions by overriding the constant rather than the algorithm. -> `Redirect` rejects source paths ending in a recognized non-HTML extension with -> an `InvalidConfigurationException` (revised during PR 1 review from the earlier -> "inherit with documented limitation" decision): a meta-refresh redirect cannot -> work when the file is served as non-HTML, and neither the old unreachable -> double-extension output nor an as-is file delivers the advertised redirect, so -> the configuration fails fast instead of silently producing a broken page. - -> **Two output mechanisms, both intended (PR 1):** the output extension is *per-class -> static* (`$outputExtension`) for file-discovered custom page classes, and -> *per-instance identifier-encoded* for `InMemoryPage` (detected from the identifier -> via the allowlist). The latter is deliberate: output is not needed at discovery -> time the way source paths are, so it does not have to be static. This is what lets -> a single `InMemoryPage` class emit different extensions per instance — -> `new InMemoryPage('robots.txt')` and `make('data.json')` — without a subclass, which -> is exactly what the generated pages (D4) and the user `make()` escape hatch need. -> Consequence recorded for D3: an `InMemoryPage`'s *static* `outputExtension()` stays -> `.html` even when the instance compiles to `robots.txt`; the real output extension -> lives in the resolved output path. - -> **Open decision — the allowlist relocates the trap, it does not eliminate it.** -> The allowlist correctly avoids the `docs/1.x` false-positive that killed dot-inference, -> but it is conservative in the false-*negative* direction: an identifier the allowlist -> does not recognize still gets the `.html` suffix. So `make('site.webmanifest')` → -> `site.webmanifest.html` and `make('sitemap.xsl')` → `sitemap.xsl.html` — the exact -> `robots.txt.html` bug this decision fixed, one extension over, and both are plausible -> power-user cases (PWA manifest, sitemap stylesheet). The allowlist stays as the -> zero-config default, but it should not be the *only* path. Two mitigations, at least -> one required before PR 8: -> 1. **(Preferred) Keep D2 option (b) available alongside (a):** an explicit - > `make(..., outputExtension: '.xsl')` / constructor parameter that bypasses the - > allowlist for any extension. Allowlist for ergonomics, explicit param for - > everything else — this actually closes the trap instead of moving it. -> 2. **(Minimum) Document the `EXPLICIT_OUTPUT_EXTENSIONS` override clearly** so a user - > can subclass and extend the recognized list, and ensure the appended-`.html` - > output is at least discoverable/greppable rather than silently wrong. - > Decide in PR 5/6 whether the first-party generated files need any extension outside - > the allowlist (none currently do — all are `.txt`/`.xml`), which determines whether - > (1) is needed for the framework itself or only for the secondary audience. - > *(PR 5 part A: confirmed for `sitemap.xml` — within the allowlist. `feed.xml`, - > `robots.txt`, and `llms.txt` are too, so the framework itself will not need - > option (b); the remaining call in PR 8 is only for the power-user audience.)* - > *(PR 5 part B qualification: the RSS filename is user-configurable, and the old - > task wrote any `hyde.rss.filename` verbatim — so `RssFeedPage` overrides - > `identifierHasExplicitOutputExtension()` to always treat the configured filename - > as the literal output path, keeping `feed.rss` (or an extensionless name) - > working. This confirms the subclass override is a workable escape hatch for - > first-party pages, but does not settle option (b) for user-land `make()` - > callers, which remains the PR 8 call.)* - > *(PR 7: confirmed for `llms.txt` — within the allowlist, needing no override. - > All four first-party generated files have now landed inside the allowlist, so - > the framework never needed option (b); the PR 8 call is purely about the - > power-user `make()` audience.)* +- `InMemoryPage` has two unambiguous construction modes: `make()` uses normal HTML + page semantics, while `file()` treats its identifier as the exact output path. + +> **Final decision (before PR 8): explicit page versus file construction.** The +> allowlist introduced in PR 1 was removed after review because it still inferred +> output behavior and merely relocated the original trap: `.txt` was treated as a +> file while `.webmanifest` was not. `InMemoryPage::make('docs/1.x')` now always +> produces `docs/1.x.html`, and `InMemoryPage::file('robots.txt')` produces exactly +> `robots.txt`. The file mode also handles `site.webmanifest`, `sitemap.xsl`, nested +> `downloads/data.csv`, and extensionless `feed` paths without special cases. +> +> The two output mechanisms are now both declarative: file-discovered custom page +> classes use the per-class static `$outputExtension`, while individual virtual +> files use `InMemoryPage::file()`. First-party generated files opt into exact-path +> mode directly. In particular, `RssFeedPage` no longer overrides an inference hook; +> its configurable filename is inherently an exact output path. Redirects retain +> normal HTML semantics even when their route identifiers contain dots, so a redirect +> identifier of `legacy.json` compiles to `legacy.json.html`. ### D3: Sitemap inclusion becomes a page-level concern @@ -162,9 +119,9 @@ a standalone feature in its own right. > the "output is not `.html`" default MUST be derived from the page's *resolved > output path* (e.g. the extension of `getOutputPath()`), not from the static > `outputExtension()` accessor. For file-discovered custom pages the two agree, but -> per D2 an `InMemoryPage` encodes its extension in the identifier while its static +> per D2 an `InMemoryPage::file()` instance uses an exact output path while its static > `outputExtension()` stays `.html`. So a `robots.txt` / `sitemap.xml` / `llms.txt` -> `InMemoryPage` reports `.html` statically despite compiling to a non-HTML file. +> file page reports `.html` statically despite compiling to a non-HTML file. > Keying the non-HTML default off `getOutputPath()` makes all four generated pages > self-exclude correctly; keying it off `outputExtension()` would silently > re-introduce the exact `search.json` leak this epic exists to fix. This is the @@ -297,7 +254,7 @@ container → fully custom page in code. First-class non-HTML support is about a page's output path and participation in the route/build/serve lifecycle; it does not require a dedicated source-backed page class -for each file extension. `InMemoryPage::make('robots.txt', contents: ...)` already +for each file extension. `InMemoryPage::file('robots.txt', contents: ...)` already provides the full lifecycle integration and is a better fit for the dynamic content advanced users commonly need, while the planned generated robots and llms pages cover the common cases without any source file at all. @@ -320,8 +277,8 @@ Goal: any page class can emit a non-`.html` file without overriding `getOutputPa - Add `$outputExtension` (default `'.html'`) to `HydePage`; use it in `outputPath()` (`HydePage.php:211-214`). - Route keys follow D1; audit `RouteKey` and `Route` for assumptions. -- Let `InMemoryPage` respect a declared/allowlisted extension per D2, so - `InMemoryPage::make('robots.txt', contents: ...)` outputs `robots.txt`. +- Add explicit exact-path construction for `InMemoryPage` per D2, so + `InMemoryPage::file('robots.txt', contents: ...)` outputs `robots.txt`. - Refactor `DocumentationSearchIndex` to drop its `getOutputPath()` override. - Pure refactor for existing sites: no compiled-output changes. @@ -342,17 +299,12 @@ Implementation notes (branch `v3/non-html-pages-foundation`): "Upgrade script rules" for the release-time Rector script. - Non-HTML extension handling was placed in `RouteKey::fromPage()` (see D1 note) rather than only in `outputPath()`, so route keys and output paths cannot drift. -- One qualification to "no compiled-output changes": ordinary in-memory page - identifiers that already end in an allowlisted extension previously produced - double-extension output (`data.json.html`); they now compile to the declared - path as-is. `hyde.redirects` paths using those extensions are instead rejected - (see the D2 note), since their HTML meta-refresh content cannot work when served - as non-HTML. Both recorded in the v3 release notes as breaking changes, though - the old outputs were almost certainly never intended or relied upon. -- **Post-implementation review: no changes required.** The two output-extension - mechanisms now coexist and both are intended (see the D2 "two output mechanisms" - note) — per-class static for discovered classes, per-instance identifier-encoded - for `InMemoryPage`, so instance-based non-HTML output works without a subclass. +- **Revised before PR 8:** the original allowlist-based implementation was replaced + by `InMemoryPage::file()` (see the final D2 decision). `make()` and direct + construction consistently retain HTML output semantics, while exact-path file + instances work without an extension-specific subclass or inference. +- The two output-extension mechanisms coexist intentionally — per-class static for + discovered classes, per-instance exact-path construction for `InMemoryPage`. The one constraint this places downstream is recorded in the D3 implementation note: sitemap / non-HTML detection must read the resolved output path, not the static `outputExtension()` accessor. @@ -422,7 +374,7 @@ Implementation notes (branch `v3/non-html-pages-sitemap-inclusion-policy`): - Implemented exactly per D3 (see the D3 "Implemented" note for the front matter semantics and the `Redirect` refinement). `showInSitemap()` joined the `BaseHydePageUnitTest` contract; the InMemoryPage unit test covers the - identifier-encoded non-HTML default that the static-extension tests cannot. + exact-path non-HTML default that the static-extension tests cannot. - The non-HTML self-exclusion is verified end-to-end: a registered `robots.txt` `InMemoryPage` is built by the real `build` command and asserted absent from the built `sitemap.xml`, guarding the D3 resolved-output-path constraint @@ -457,10 +409,8 @@ Goal: `sitemap.xml` and `feed.xml` are routes — served by `hyde serve`, listed bound). D4's whole swappability tier is a lie if `app(SitemapGenerator::class)` can't be rebound. Add a test that rebinds the generator and asserts the page's compiled output changes. -- Confirm none of the first-party generated files (`sitemap.xml`, `feed.xml`, - `robots.txt`, `llms.txt`) need an extension outside the D2 allowlist — they don't - today, which settles the D2 "open decision" in favor of the framework not needing - option (b) for its own use (the power-user escape hatch is a separate call). +- Ensure all first-party generated files (`sitemap.xml`, `feed.xml`, `robots.txt`, + `llms.txt`) use the D2 exact-path mode, including the configurable RSS filename. - Register in `HydeCoreExtension::discoverPages()` behind `Features::hasSitemap()` / `Features::hasRss()`; remove `GenerateSitemap`/`GenerateRssFeed` from `BuildTaskService::registerFrameworkTasks()` (evaluate deprecation vs. removal — @@ -528,11 +478,9 @@ Implementation notes, part B (branch `v3/non-html-pages-convert-rss-feed`): `Features::hasRss()` with the D5 skip check, hidden from navigation, D3-excluded from the sitemap, and both user override paths verified end-to-end. - One divergence: the route key comes from `RssFeedGenerator::getFilename()` - (config `hyde.rss.filename`), and since the removed task wrote any configured - filename verbatim, `RssFeedPage` overrides `identifierHasExplicitOutputExtension()` - to always use the filename as the literal output path — `feed.rss` or an - extensionless name would otherwise regress to `.html`-suffixed output (see the - D2 part B qualification). + (config `hyde.rss.filename`). Since the removed task wrote any configured filename + verbatim, `RssFeedPage` uses the exact-path construction mode — `feed.rss` and an + extensionless name therefore work without an inference override. - `build:rss` builds the registered route's page like `build:sitemap`, and fails with the generic "feature is not enabled" error when the route is not registered (see the revised-in-review notes in the part A section — the old task's no-guard @@ -588,8 +536,7 @@ Implementation notes (branch `v3/non-html-pages-robots`): index, instead of surfacing as a PHP-level type error at build time. Later generated text pages copying this pattern (llms.txt) should keep both halves — verbatim strings, explicit validation. -- `robots.txt` is within the D2 allowlist, consistent with the PR 5 confirmation - that first-party generated files do not need option (b). +- `RobotsTxtPage` uses the D2 exact-path mode for `robots.txt`. - No `build:robots` command: the sitemap/RSS commands exist only as carry-overs of the removed post-build tasks; robots.txt never had one, and the standard build and realtime compiler (serve test asserts `text/plain`) cover the lifecycle. @@ -712,9 +659,9 @@ Implementation notes (branch `v3/non-html-pages-llms-txt`): generator-level curation concern rather than a page-level default (the sitemap precedent likewise keeps its 404 handling in the generator), and it is the reason the sitemap-derived inclusion rule is not a bare alias for `showInSitemap()`. -- Everything else the epic left implicit held: `llms.txt` is within the D2 allowlist, - and the generated page self-excludes from its own listing (and the sitemap) through - the D3 resolved-output-path default. +- Everything else the epic left implicit held: `LlmsTxtPage` uses the D2 exact-path + mode, and the generated page self-excludes from its own listing (and the sitemap) + through the D3 resolved-output-path default. > **Scope correction (post-implementation review).** The first cut of this PR was > overbuilt for the value delivered, and three pieces were cut back before merge: the @@ -760,4 +707,4 @@ Implementation notes (branch `v3/non-html-pages-llms-txt`): what PR 5 requires. - Reconsidering the page-type `Feature` enum cases (`HtmlPages`, `BladePages`, etc.) altogether — arguably redundant since not creating source files has the same - effect. Worth a separate v3 discussion; this epic simply doesn't add new ones. \ No newline at end of file + effect. Worth a separate v3 discussion; this epic simply doesn't add new ones. diff --git a/HYDEPHP_V3_PLANNING.md b/HYDEPHP_V3_PLANNING.md index 8d4ec0bc8d4..d5e77600639 100644 --- a/HYDEPHP_V3_PLANNING.md +++ b/HYDEPHP_V3_PLANNING.md @@ -24,7 +24,7 @@ Having this document in code lets us know the devlopment state at any given poin - 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)) - Added built-in terminal code blocks using the `terminal` fence language. Command prompts are styled for selection-free copying, and `terminal xml` supports four Symfony-style Console formatter tags. ([#2188](https://github.com/hydephp/develop/issues/2188), [#2485](https://github.com/hydephp/develop/issues/2485)) -- Pages can now compile to non-HTML output files. Page classes declare their output file extension through the new static `$outputExtension` property (defaulting to `.html`), and in-memory page identifiers can declare a `.json`, `.txt`, or `.xml` extension directly, so `InMemoryPage::make('robots.txt', contents: ...)` compiles to `_site/robots.txt` through the standard site build. Only the HTML extension is implicit in route keys: pages compiled to non-HTML files keep their extension in the route key, formalizing the convention already used by the documentation search index. +- Pages can now compile to non-HTML output files. Page classes declare their output file extension through the new static `$outputExtension` property (defaulting to `.html`), while `InMemoryPage::file('robots.txt', contents: ...)` creates a virtual file whose route key and output path are exactly `robots.txt`. The exact-path constructor supports any extension or no extension without inference; `InMemoryPage::make()` retains normal HTML page semantics. Only the HTML extension is implicit in route keys: pages compiled to non-HTML files keep their extension in the route key, formalizing the convention already used by the documentation search index. - Pages can now control their own sitemap inclusion. Set `sitemap: false` in a page's front matter to exclude it from the generated `sitemap.xml`, or override the new `HydePage::showInSitemap()` method in custom page classes. Pages compiled to non-HTML output files (like `robots.txt`) are excluded by default, and `sitemap: true` front matter opts such a page back in. - The sitemap and RSS feed are now first-class pages instead of post-build side effects: when the respective feature is enabled, `sitemap.xml` and the RSS feed (`feed.xml`, or the configured `hyde.rss.filename`) are registered as routes, so they are served by `hyde serve`, listed in `route:list`, included in the build manifest, and compiled through the standard site build. The output can be customized by rebinding the `SitemapGenerator` or `RssFeedGenerator` class in the service container, and registering a user-defined page with the same route key (from a service provider, booting callback, or extension) replaces the generated page entirely. - Hyde now generates a `robots.txt` file for the site out of the box. The default output allows all crawlers, and links to the sitemap when that feature is enabled. Rule values listed in the new `hyde.robots.disallow` configuration array are written verbatim as `Disallow` rules (so wildcard patterns are supported), and the file can be disabled entirely with `hyde.robots.enabled`. The page is wired like the sitemap and RSS feed: it is a real route (served by `hyde serve`, listed in `route:list`, included in the build manifest), the output can be customized by rebinding the `RobotsTxtGenerator` class in the service container, and a user-defined `robots.txt` page replaces the generated one entirely. @@ -52,8 +52,6 @@ Having this document in code lets us know the devlopment state at any given poin ### Breaking Changes - Renamed the static page class property `$fileExtension` to `$sourceExtension`, and the `fileExtension()` and `setFileExtension()` methods to `sourceExtension()` and `setSourceExtension()`. The old name was ambiguous now that page classes also declare an output extension through the new `$outputExtension` property, and the renamed pair makes the source/output distinction explicit. 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). Page discovery fails fast with an actionable exception for registered page classes that still use the old API, instead of silently skipping them during builds. -- In-memory page identifiers ending in `.json`, `.txt`, or `.xml` now compile to that path as-is instead of gaining a second `.html` extension. The old double-extension outputs (like `data.json.html`) were almost certainly never intended, so no real sites are expected to be affected. -- Redirect source paths declared in `hyde.redirects` ending in `.json`, `.txt`, or `.xml` are now rejected with an exception, since a meta refresh redirect cannot work for files served as non-HTML content. Previously such entries silently produced an unreachable `legacy.json.html` file, so no working configuration is affected. - 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 register a custom `sitemap.xml` page or rebind `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 register a custom page with the configured feed route key or rebind `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. A user-defined page registered under the feed route key is still built even when the feature conditions are not met. - Removed `Redirect::create()`, `Redirect::store()`, and the `Redirect` constructor's `showText` argument. Redirects must now be declared in `hyde.redirects`, keeping all generated output inside the kernel-owned build graph. Redirect routes are intrinsically excluded from navigation menus and sitemaps, and always include an accessible fallback link. diff --git a/UPGRADE.md b/UPGRADE.md index 6199d89cdf1..ef8629f522d 100644 --- a/UPGRADE.md +++ b/UPGRADE.md @@ -331,7 +331,7 @@ use Hyde\Hyde; use Hyde\Pages\InMemoryPage; Hyde::kernel()->booting(function ($kernel): void { - $kernel->pages()->addPage(new InMemoryPage('sitemap.xml', contents: $myXml)); + $kernel->pages()->addPage(InMemoryPage::file('sitemap.xml', contents: $myXml)); }); ``` diff --git a/docs/_data/partials/hyde-pages-api/in-memory-page-methods.md b/docs/_data/partials/hyde-pages-api/in-memory-page-methods.md index d8851ddffdf..435fa04412c 100644 --- a/docs/_data/partials/hyde-pages-api/in-memory-page-methods.md +++ b/docs/_data/partials/hyde-pages-api/in-memory-page-methods.md @@ -12,14 +12,13 @@ Static alias for the constructor. InMemoryPage::make(string|(Closure(): string)|(Closure(static):, Hyde\Markdown\Models\FrontMatter|array $matter, Closure|string|null $contents, string $view): static ``` -#### `outputPath()` +#### `file()` -Qualify a page identifier into a target output file path, relative to the _site output directory. - -If the identifier ends in a recognized non-HTML extension (`.json`, `.txt`, or `.xml` by default), it is treated as an explicit output path and no HTML extension is appended, so an identifier of "robots.txt" saves the page to "_site/robots.txt". +Create an in-memory page whose identifier is used as the exact output path. ```php -InMemoryPage::outputPath(string $identifier): string +/** @param string|(Closure(): string)|(Closure(static): string)|null $contents */ +InMemoryPage::file(string $outputPath, Hyde\Markdown\Models\FrontMatter|array $matter, Closure|string|null $contents, string $view): static ``` #### `__construct()` @@ -32,15 +31,23 @@ Contents and views cannot be used together. Omit both to create an empty page. A View values ending in `.blade.php` are treated as Blade file paths. Other values are treated as registered Laravel view keys. -Identifiers ending in `.json`, `.txt`, or `.xml` retain that extension in the output path. +- **Parameter $exactOutputPath:** Whether to use the identifier as the exact output path. Prefer the `file()` constructor for this mode. ```php /** @param string|(Closure(): string)|(Closure(static): string)|null $contents */ -$page = new InMemoryPage(string $identifier, FrontMatter|array $matter, string|(Closure(): string)|(Closure(static):, string|null $view): void +$page = new InMemoryPage(string $identifier, FrontMatter|array $matter, string|(Closure(): string)|(Closure(static):, string|null $view, bool $exactOutputPath): void ``` - **Throws:** InvalidArgumentException If both contents and a view are supplied. +#### `getOutputPath()` + +Get the path where the compiled page will be saved. + +```php +$page->getOutputPath(): string +``` + #### `getContents()` Get the literal contents or invoke the configured content closure. diff --git a/docs/advanced-features/in-memory-pages.md b/docs/advanced-features/in-memory-pages.md index 7ed8b458e53..9b4ce09ed1c 100644 --- a/docs/advanced-features/in-memory-pages.md +++ b/docs/advanced-features/in-memory-pages.md @@ -36,9 +36,24 @@ The constructor supports three content strategies: literal string contents, lazy Pass a string to the `$contents` parameter when the page contents are already available. Hyde saves the string literally. ```php -$page = new InMemoryPage('robots.txt', contents: "User-agent: *\n"); +$page = InMemoryPage::make('about', contents: 'Hello World!'); ``` +This uses normal HTML page semantics and writes the contents to `_site/about.html`. +Dots in the identifier do not change that behavior. + +To create a page at an exact output path, use the `file()` constructor: + +```php +$robots = InMemoryPage::file('robots.txt', contents: "User-agent: *\nAllow: /"); +$manifest = InMemoryPage::file('site.webmanifest', contents: $manifestJson); +$data = InMemoryPage::file('downloads/data.csv', contents: $csv); +``` + +The route key and output path are exactly the path passed to `file()`. The path may +use any extension or no extension at all; Hyde does not infer output behavior from +the filename. + Pass a closure when the contents should be generated lazily during compilation. The closure is invoked again for each compilation, which makes it useful for pages generated from the current application state. diff --git a/packages/framework/src/Pages/InMemoryPage.php b/packages/framework/src/Pages/InMemoryPage.php index a3b0b32965c..69326e59cb3 100644 --- a/packages/framework/src/Pages/InMemoryPage.php +++ b/packages/framework/src/Pages/InMemoryPage.php @@ -97,6 +97,7 @@ public static function file( * @param FrontMatter|array $matter * @param string|(Closure(): string)|(Closure(static): string)|null $contents * @param string|null $view + * @param bool $exactOutputPath Whether to use the identifier as the exact output path. Prefer the `file()` constructor for this mode. * * @throws InvalidArgumentException If both contents and a view are supplied. */ From cb322ddb03119ae89331b0eee73b22423d831493 Mon Sep 17 00:00:00 2001 From: Emma De Silva Date: Tue, 14 Jul 2026 03:54:47 +0200 Subject: [PATCH 087/117] Validate exact in-memory page paths --- packages/framework/src/Pages/InMemoryPage.php | 24 +++++++++++++++++++ .../tests/Unit/Pages/InMemoryPageTest.php | 23 ++++++++++++++++++ 2 files changed, 47 insertions(+) diff --git a/packages/framework/src/Pages/InMemoryPage.php b/packages/framework/src/Pages/InMemoryPage.php index 69326e59cb3..39afa90dbfe 100644 --- a/packages/framework/src/Pages/InMemoryPage.php +++ b/packages/framework/src/Pages/InMemoryPage.php @@ -12,7 +12,9 @@ use InvalidArgumentException; use function Hyde\unslash; +use function str_contains; use function str_ends_with; +use function str_starts_with; /** * Extendable class for in-memory (or virtual) Hyde pages that are not based on source files. @@ -108,6 +110,10 @@ public function __construct( ?string $view = null, bool $exactOutputPath = false, ) { + if ($exactOutputPath) { + $identifier = static::normalizeExactOutputPath($identifier); + } + $this->exactOutputPath = $exactOutputPath; parent::__construct($identifier, $matter); @@ -124,6 +130,24 @@ public function __construct( $this->view = $view ?? ''; } + protected static function normalizeExactOutputPath(string $path): string + { + if ( + $path === '' + || str_starts_with($path, '/') + || str_ends_with($path, '/') + || str_contains($path, '\\') + || preg_match('/^[A-Za-z]:/', $path) + || in_array('..', explode('/', $path), true) + ) { + throw new InvalidArgumentException( + "Invalid exact output path [$path]. The path must be a relative file path inside the site output directory." + ); + } + + return unslash($path); + } + /** * Get the path where the compiled page will be saved. */ diff --git a/packages/framework/tests/Unit/Pages/InMemoryPageTest.php b/packages/framework/tests/Unit/Pages/InMemoryPageTest.php index 012c6438094..b2af4212101 100644 --- a/packages/framework/tests/Unit/Pages/InMemoryPageTest.php +++ b/packages/framework/tests/Unit/Pages/InMemoryPageTest.php @@ -7,6 +7,7 @@ use Hyde\Pages\InMemoryPage; use Hyde\Testing\TestCase; use InvalidArgumentException; +use PHPUnit\Framework\Attributes\DataProvider; use RuntimeException; use TypeError; @@ -423,6 +424,28 @@ public function testFileUsesAnyOutputPathExactly() $this->assertSame('sitemap.xsl', InMemoryPage::file('sitemap.xsl')->getOutputPath()); $this->assertSame('downloads/data.csv', InMemoryPage::file('downloads/data.csv')->getOutputPath()); $this->assertSame('feed', InMemoryPage::file('feed')->getOutputPath()); + $this->assertSame('docs/1.x/search.json', InMemoryPage::file('docs/1.x/search.json')->getOutputPath()); + } + + #[DataProvider('invalidExactOutputPaths')] + public function testFileRejectsInvalidOutputPaths(string $path): void + { + $this->expectException(InvalidArgumentException::class); + + InMemoryPage::file($path); + } + + public static function invalidExactOutputPaths(): array + { + return [ + 'empty' => [''], + 'absolute' => ['/robots.txt'], + 'traversal' => ['../robots.txt'], + 'nested traversal' => ['foo/../../robots.txt'], + 'directory' => ['foo/'], + 'windows separator' => ['foo\\robots.txt'], + 'windows absolute' => ['C:\\robots.txt'], + ]; } public function testGetRouteKeyForFile() From d546ff8dfed7187b68a8f459bfcc106a21158ca0 Mon Sep 17 00:00:00 2001 From: Emma De Silva Date: Tue, 14 Jul 2026 03:55:15 +0200 Subject: [PATCH 088/117] Rename exact output path tests --- .../tests/Feature/NonHtmlPageOutputTest.php | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/packages/framework/tests/Feature/NonHtmlPageOutputTest.php b/packages/framework/tests/Feature/NonHtmlPageOutputTest.php index cd45080b81f..28e4dc372fd 100644 --- a/packages/framework/tests/Feature/NonHtmlPageOutputTest.php +++ b/packages/framework/tests/Feature/NonHtmlPageOutputTest.php @@ -32,7 +32,7 @@ protected function tearDown(): void parent::tearDown(); } - public function testBuildCommandCompilesInMemoryPageWithTxtExtensionToDeclaredOutputPath() + public function testBuildCommandCompilesExactTxtOutputPath() { Hyde::kernel()->booting(function (HydeKernel $kernel): void { $kernel->pages()->addPage(InMemoryPage::file('robots.txt', contents: "User-agent: *\nAllow: /")); @@ -45,7 +45,7 @@ public function testBuildCommandCompilesInMemoryPageWithTxtExtensionToDeclaredOu $this->assertFileDoesNotExist(Hyde::path('_site/robots.txt.html')); } - public function testBuildCommandCompilesInMemoryPageWithJsonExtensionToDeclaredOutputPath() + public function testBuildCommandCompilesExactJsonOutputPath() { Hyde::kernel()->booting(function (HydeKernel $kernel): void { $kernel->pages()->addPage(InMemoryPage::file('data.json', contents: '{"foo": "bar"}')); @@ -57,7 +57,7 @@ public function testBuildCommandCompilesInMemoryPageWithJsonExtensionToDeclaredO $this->assertSame('{"foo": "bar"}', file_get_contents(Hyde::path('_site/data.json'))); } - public function testBuildCommandCompilesInMemoryPageWithXmlExtensionToDeclaredOutputPath() + public function testBuildCommandCompilesExactXmlOutputPath() { Hyde::kernel()->booting(function (HydeKernel $kernel): void { $kernel->pages()->addPage(InMemoryPage::file('custom.xml', contents: '')); @@ -69,7 +69,7 @@ public function testBuildCommandCompilesInMemoryPageWithXmlExtensionToDeclaredOu $this->assertSame('', file_get_contents(Hyde::path('_site/custom.xml'))); } - public function testBuildCommandCompilesInMemoryPageWithDeclaredExtensionInNestedPath() + public function testBuildCommandCompilesNestedExactOutputPath() { Hyde::kernel()->booting(function (HydeKernel $kernel): void { $kernel->pages()->addPage(InMemoryPage::file('foo/bar.txt', contents: 'baz')); @@ -98,7 +98,7 @@ public function testBuildCommandCompilesArbitraryExactOutputPathsWithoutExtensio $this->assertSame('feed', file_get_contents(Hyde::path('_site/feed'))); } - public function testInMemoryPageWithDeclaredExtensionIsRegisteredAsRoute() + public function testExactOutputPageIsRegisteredAsRoute() { Hyde::kernel()->booting(function (HydeKernel $kernel): void { $kernel->pages()->addPage(InMemoryPage::file('robots.txt', contents: 'User-agent: *')); @@ -108,7 +108,7 @@ public function testInMemoryPageWithDeclaredExtensionIsRegisteredAsRoute() $this->assertSame('robots.txt', Routes::get('robots.txt')->getOutputPath()); } - public function testStaticPageBuilderCompilesInMemoryPageWithDeclaredExtension() + public function testStaticPageBuilderCompilesExactOutputPage() { StaticPageBuilder::handle(InMemoryPage::file('llms.txt', contents: '# Hello World')); @@ -116,7 +116,7 @@ public function testStaticPageBuilderCompilesInMemoryPageWithDeclaredExtension() $this->assertSame('# Hello World', file_get_contents(Hyde::path('_site/llms.txt'))); } - public function testInMemoryPageWithDeclaredExtensionCanCompileUsingView() + public function testExactOutputPageCanCompileUsingView() { $this->file('_pages/robots.blade.php', 'User-agent: {{ $agent }}'); @@ -126,7 +126,7 @@ public function testInMemoryPageWithDeclaredExtensionCanCompileUsingView() $this->assertSame('User-agent: *', file_get_contents(Hyde::path('_site/robots.txt'))); } - public function testBuildCommandExcludesNonHtmlPagesFromTheSitemap() + public function testBuildCommandExcludesExactNonHtmlPageFromSitemap() { $this->withSiteUrl(); From 6f444590e0bd3b16f15ff83d056d6fdf089b4d28 Mon Sep 17 00:00:00 2001 From: Emma De Silva Date: Tue, 14 Jul 2026 03:57:39 +0200 Subject: [PATCH 089/117] Document exact-path in-memory pages --- EPIC_NON_HTML_PAGES.md | 24 +++++++++---------- HYDEPHP_V3_PLANNING.md | 2 +- UPGRADE.md | 15 +++++++++++- .../hyde-pages-api/hyde-page-methods.md | 4 ++-- .../hyde-pages-api/in-memory-page-methods.md | 8 ++++++- docs/advanced-features/hyde-pages.md | 14 +++++++++++ docs/advanced-features/in-memory-pages.md | 23 ++++++++---------- .../framework/src/Pages/Concerns/HydePage.php | 2 +- packages/framework/src/Pages/InMemoryPage.php | 5 +++- 9 files changed, 65 insertions(+), 32 deletions(-) diff --git a/EPIC_NON_HTML_PAGES.md b/EPIC_NON_HTML_PAGES.md index 725651bf3f3..a2459a54d7e 100644 --- a/EPIC_NON_HTML_PAGES.md +++ b/EPIC_NON_HTML_PAGES.md @@ -70,25 +70,25 @@ it is proven in production, requires no realtime-compiler lookup changes (`PageRouter::normalizePath()` only strips `.html`), and lets `docs/search` (page) and `docs/search.json` (index) coexist as distinct routes, which they already do. -> **Implemented (PR 1):** `RouteKey::fromPage()` appends the page class's declared +> **Implemented (PR 1):** `RouteKey::fromPage()` appends the page class's configured > non-HTML output extension to the key (skipping it when the identifier already ends -> with it), so custom page classes declaring a non-HTML output extension are +> with it), so custom page classes configured with a non-HTML output extension are > D1-compliant out of the box and PR 2 can rely on "route key == request path with > only `.html` stripped" universally. -### D2: Output extension is declared, not inferred +### D2: Exact output paths are explicit, not inferred Do **not** infer "this identifier has an extension" from a dot in the identifier — versioned docs route keys like `docs/1.x/index` would false-positive -(`pathinfo('docs/1.x')['extension'] === 'x'`). Instead the extension is declared: +(`pathinfo('docs/1.x')['extension'] === 'x'`). Instead, output intent is explicit: -- File-discovered custom classes declare it statically, mirroring the existing - `HtmlPage::$sourceExtension` pattern. +- File-discovered custom classes configure their output extension statically, + mirroring the existing `HtmlPage::$sourceExtension` pattern. - `HydePage` gets `public static string $outputExtension = '.html'` (or an - instance-level hook), and `outputPath()` uses it instead of the hardcoded - `'.html'`. This removes the `getOutputPath()` override dance. + instance-level hook), and `outputPath()` uses it instead of the hardcoded `'.html'`. - `InMemoryPage` has two unambiguous construction modes: `make()` uses normal HTML - page semantics, while `file()` treats its identifier as the exact output path. + page semantics, while `file()` validates and uses its identifier as the exact + relative output path. > **Final decision (before PR 8): explicit page versus file construction.** The > allowlist introduced in PR 1 was removed after review because it still inferred @@ -270,7 +270,7 @@ an extension point. ## Work breakdown (planned PR sequence, in dependency order) -### PR 1 — Foundation: declared output extensions on `HydePage` ✅ Implemented +### PR 1 — Foundation: explicit output paths and page-class extensions ✅ Implemented Goal: any page class can emit a non-`.html` file without overriding `getOutputPath()`. @@ -279,7 +279,7 @@ Goal: any page class can emit a non-`.html` file without overriding `getOutputPa - Route keys follow D1; audit `RouteKey` and `Route` for assumptions. - Add explicit exact-path construction for `InMemoryPage` per D2, so `InMemoryPage::file('robots.txt', contents: ...)` outputs `robots.txt`. -- Refactor `DocumentationSearchIndex` to drop its `getOutputPath()` override. +- Refactor `DocumentationSearchIndex` to use the exact-path file-page mode. - Pure refactor for existing sites: no compiled-output changes. Implementation notes (branch `v3/non-html-pages-foundation`): @@ -297,7 +297,7 @@ Implementation notes (branch `v3/non-html-pages-foundation`): properties cannot alias each other without precedence/synchronization hacks. The mechanical migration is recorded in `HYDEPHP_V3_PLANNING.md` under "Upgrade script rules" for the release-time Rector script. -- Non-HTML extension handling was placed in `RouteKey::fromPage()` (see D1 note) +- Page-class output extension handling was placed in `RouteKey::fromPage()` (see D1 note) rather than only in `outputPath()`, so route keys and output paths cannot drift. - **Revised before PR 8:** the original allowlist-based implementation was replaced by `InMemoryPage::file()` (see the final D2 decision). `make()` and direct diff --git a/HYDEPHP_V3_PLANNING.md b/HYDEPHP_V3_PLANNING.md index d5e77600639..b45103f3e81 100644 --- a/HYDEPHP_V3_PLANNING.md +++ b/HYDEPHP_V3_PLANNING.md @@ -24,7 +24,7 @@ Having this document in code lets us know the devlopment state at any given poin - 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)) - Added built-in terminal code blocks using the `terminal` fence language. Command prompts are styled for selection-free copying, and `terminal xml` supports four Symfony-style Console formatter tags. ([#2188](https://github.com/hydephp/develop/issues/2188), [#2485](https://github.com/hydephp/develop/issues/2485)) -- Pages can now compile to non-HTML output files. Page classes declare their output file extension through the new static `$outputExtension` property (defaulting to `.html`), while `InMemoryPage::file('robots.txt', contents: ...)` creates a virtual file whose route key and output path are exactly `robots.txt`. The exact-path constructor supports any extension or no extension without inference; `InMemoryPage::make()` retains normal HTML page semantics. Only the HTML extension is implicit in route keys: pages compiled to non-HTML files keep their extension in the route key, formalizing the convention already used by the documentation search index. +- Added `InMemoryPage::file()` for creating virtual pages whose identifier is used as the exact output path, allowing files such as `robots.txt`, `site.webmanifest`, nested JSON files, and extensionless outputs without extension inference. `InMemoryPage::make()` retains normal HTML page semantics. Custom page classes can compile to non-HTML files by setting the new static `$outputExtension` property (defaulting to `.html`). Only the HTML extension is implicit in route keys: pages compiled to non-HTML files keep their extension in the route key, formalizing the convention already used by the documentation search index. - Pages can now control their own sitemap inclusion. Set `sitemap: false` in a page's front matter to exclude it from the generated `sitemap.xml`, or override the new `HydePage::showInSitemap()` method in custom page classes. Pages compiled to non-HTML output files (like `robots.txt`) are excluded by default, and `sitemap: true` front matter opts such a page back in. - The sitemap and RSS feed are now first-class pages instead of post-build side effects: when the respective feature is enabled, `sitemap.xml` and the RSS feed (`feed.xml`, or the configured `hyde.rss.filename`) are registered as routes, so they are served by `hyde serve`, listed in `route:list`, included in the build manifest, and compiled through the standard site build. The output can be customized by rebinding the `SitemapGenerator` or `RssFeedGenerator` class in the service container, and registering a user-defined page with the same route key (from a service provider, booting callback, or extension) replaces the generated page entirely. - Hyde now generates a `robots.txt` file for the site out of the box. The default output allows all crawlers, and links to the sitemap when that feature is enabled. Rule values listed in the new `hyde.robots.disallow` configuration array are written verbatim as `Disallow` rules (so wildcard patterns are supported), and the file can be disabled entirely with `hyde.robots.enabled`. The page is wired like the sitemap and RSS feed: it is a real route (served by `hyde serve`, listed in `route:list`, included in the build manifest), the output can be customized by rebinding the `RobotsTxtGenerator` class in the service container, and a user-defined `robots.txt` page replaces the generated one entirely. diff --git a/UPGRADE.md b/UPGRADE.md index ef8629f522d..d8182edebde 100644 --- a/UPGRADE.md +++ b/UPGRADE.md @@ -2,7 +2,20 @@ ## Overview -// +HydePHP v3 adds `InMemoryPage::file()` for creating virtual pages whose identifier is used as the exact output path, +allowing files such as `robots.txt`, `site.webmanifest`, nested JSON files, and extensionless outputs without extension +inference. Normal `InMemoryPage::make()` construction retains its historical HTML behavior: + +```php +InMemoryPage::make('about', contents: $html); +// _site/about.html + +InMemoryPage::make('robots.txt', contents: $text); +// _site/robots.txt.html + +InMemoryPage::file('robots.txt', contents: $text); +// _site/robots.txt +``` ## Before You Begin diff --git a/docs/_data/partials/hyde-pages-api/hyde-page-methods.md b/docs/_data/partials/hyde-pages-api/hyde-page-methods.md index aa84faf6e3c..2b1b770e02a 100644 --- a/docs/_data/partials/hyde-pages-api/hyde-page-methods.md +++ b/docs/_data/partials/hyde-pages-api/hyde-page-methods.md @@ -1,7 +1,7 @@
- + #### `make()` @@ -92,7 +92,7 @@ The value includes the leading dot, so it can be used directly as a file name su HydePage::outputExtension(): string ``` -- **Throws:** \InvalidArgumentException If the declared extension does not start with a dot or contains a path separator. +- **Throws:** \InvalidArgumentException If the output extension does not start with a dot or contains a path separator. #### `setSourceDirectory()` diff --git a/docs/_data/partials/hyde-pages-api/in-memory-page-methods.md b/docs/_data/partials/hyde-pages-api/in-memory-page-methods.md index 435fa04412c..141cb02548b 100644 --- a/docs/_data/partials/hyde-pages-api/in-memory-page-methods.md +++ b/docs/_data/partials/hyde-pages-api/in-memory-page-methods.md @@ -16,6 +16,10 @@ InMemoryPage::make(string|(Closure(): string)|(Closure(static):, Hyde\Markdown\M Create an in-memory page whose identifier is used as the exact output path. +The output path must be a relative file path contained within the site output directory. + +The output path must be a relative file path contained within the site output directory. + ```php /** @param string|(Closure(): string)|(Closure(static): string)|null $contents */ InMemoryPage::file(string $outputPath, Hyde\Markdown\Models\FrontMatter|array $matter, Closure|string|null $contents, string $view): static @@ -31,7 +35,9 @@ Contents and views cannot be used together. Omit both to create an empty page. A View values ending in `.blade.php` are treated as Blade file paths. Other values are treated as registered Laravel view keys. -- **Parameter $exactOutputPath:** Whether to use the identifier as the exact output path. Prefer the `file()` constructor for this mode. +Normal construction uses HTML page semantics; use the `file()` constructor to create an exact-path file page. + +- **Parameter $exactOutputPath:** Whether to validate and use the identifier as an exact output path. Prefer the `file()` constructor for this mode. ```php /** @param string|(Closure(): string)|(Closure(static): string)|null $contents */ diff --git a/docs/advanced-features/hyde-pages.md b/docs/advanced-features/hyde-pages.md index ea9db593c9a..42a89c0744a 100644 --- a/docs/advanced-features/hyde-pages.md +++ b/docs/advanced-features/hyde-pages.md @@ -161,6 +161,19 @@ autodiscovery, you may benefit from creating a custom page class instead, as tha You can learn more about the InMemoryPage class in the [InMemoryPage documentation](in-memory-pages). +Normal in-memory pages compile as HTML, regardless of dots in their identifiers. Use `file()` to opt into an exact output path: + +```php +InMemoryPage::make('about', contents: $html); +// _site/about.html + +InMemoryPage::make('robots.txt', contents: $text); +// _site/robots.txt.html + +InMemoryPage::file('robots.txt', contents: $text); +// _site/robots.txt +``` + ### Quick Reference | Class Name | Namespace | Source Code | API Docs | @@ -186,6 +199,7 @@ class InMemoryPage extends HydePage protected string|\Closure $contents; protected string $view; + protected readonly bool $exactOutputPath; } ``` diff --git a/docs/advanced-features/in-memory-pages.md b/docs/advanced-features/in-memory-pages.md index 9b4ce09ed1c..43aa68df1e6 100644 --- a/docs/advanced-features/in-memory-pages.md +++ b/docs/advanced-features/in-memory-pages.md @@ -34,25 +34,22 @@ To create an InMemoryPage, you need to instantiate it with the required paramete The constructor supports three content strategies: literal string contents, lazy closure contents, and Blade view rendering. Pass a string to the `$contents` parameter when the page contents are already available. Hyde saves the string literally. +Normal construction always uses HTML page semantics, even when the identifier contains a dot. Use `file()` when the +identifier should instead be used as the exact output path: ```php -$page = InMemoryPage::make('about', contents: 'Hello World!'); -``` - -This uses normal HTML page semantics and writes the contents to `_site/about.html`. -Dots in the identifier do not change that behavior. +InMemoryPage::make('about', contents: $html); +// _site/about.html -To create a page at an exact output path, use the `file()` constructor: +InMemoryPage::make('robots.txt', contents: $text); +// _site/robots.txt.html -```php -$robots = InMemoryPage::file('robots.txt', contents: "User-agent: *\nAllow: /"); -$manifest = InMemoryPage::file('site.webmanifest', contents: $manifestJson); -$data = InMemoryPage::file('downloads/data.csv', contents: $csv); +InMemoryPage::file('robots.txt', contents: $text); +// _site/robots.txt ``` -The route key and output path are exactly the path passed to `file()`. The path may -use any extension or no extension at all; Hyde does not infer output behavior from -the filename. +The route key and output path are exactly the relative file path passed to `file()`. This supports files such as +`site.webmanifest`, nested JSON files, and extensionless outputs without inferring behavior from the filename. Pass a closure when the contents should be generated lazily during compilation. The closure is invoked again for each compilation, which makes it useful for pages generated from the current application state. diff --git a/packages/framework/src/Pages/Concerns/HydePage.php b/packages/framework/src/Pages/Concerns/HydePage.php index d03d88e02a6..dbf08226941 100644 --- a/packages/framework/src/Pages/Concerns/HydePage.php +++ b/packages/framework/src/Pages/Concerns/HydePage.php @@ -184,7 +184,7 @@ public static function sourceExtension(): string * * The value includes the leading dot, so it can be used directly as a file name suffix. * - * @throws \InvalidArgumentException If the declared extension does not start with a dot or contains a path separator. + * @throws \InvalidArgumentException If the output extension does not start with a dot or contains a path separator. */ public static function outputExtension(): string { diff --git a/packages/framework/src/Pages/InMemoryPage.php b/packages/framework/src/Pages/InMemoryPage.php index 39afa90dbfe..32482e475b8 100644 --- a/packages/framework/src/Pages/InMemoryPage.php +++ b/packages/framework/src/Pages/InMemoryPage.php @@ -72,6 +72,8 @@ public static function make( /** * Create an in-memory page whose identifier is used as the exact output path. * + * The output path must be a relative file path contained within the site output directory. + * * @param string|(Closure(): string)|(Closure(static): string)|null $contents */ public static function file( @@ -91,6 +93,7 @@ public static function file( * * Contents and views cannot be used together. Omit both to create an empty page. * An empty view value is treated as no view. + * Normal construction uses HTML page semantics; use the `file()` constructor to create an exact-path file page. * * View values ending in `.blade.php` are treated as Blade file paths. Other values are treated * as registered Laravel view keys. @@ -99,7 +102,7 @@ public static function file( * @param FrontMatter|array $matter * @param string|(Closure(): string)|(Closure(static): string)|null $contents * @param string|null $view - * @param bool $exactOutputPath Whether to use the identifier as the exact output path. Prefer the `file()` constructor for this mode. + * @param bool $exactOutputPath Whether to validate and use the identifier as an exact output path. Prefer the `file()` constructor for this mode. * * @throws InvalidArgumentException If both contents and a view are supplied. */ From 644b5ae0d098182fb7f7b8e8262dd5a71cad27c0 Mon Sep 17 00:00:00 2001 From: Emma De Silva Date: Tue, 14 Jul 2026 12:55:58 +0200 Subject: [PATCH 090/117] Reject ambiguous exact output paths --- packages/framework/src/Pages/InMemoryPage.php | 6 +++++- packages/framework/tests/Unit/Pages/InMemoryPageTest.php | 4 ++++ 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/packages/framework/src/Pages/InMemoryPage.php b/packages/framework/src/Pages/InMemoryPage.php index 32482e475b8..a5066ea9732 100644 --- a/packages/framework/src/Pages/InMemoryPage.php +++ b/packages/framework/src/Pages/InMemoryPage.php @@ -135,13 +135,17 @@ public function __construct( protected static function normalizeExactOutputPath(string $path): string { + $segments = explode('/', $path); + if ( $path === '' || str_starts_with($path, '/') || str_ends_with($path, '/') || str_contains($path, '\\') || preg_match('/^[A-Za-z]:/', $path) - || in_array('..', explode('/', $path), true) + || in_array('', $segments, true) + || in_array('.', $segments, true) + || in_array('..', $segments, true) ) { throw new InvalidArgumentException( "Invalid exact output path [$path]. The path must be a relative file path inside the site output directory." diff --git a/packages/framework/tests/Unit/Pages/InMemoryPageTest.php b/packages/framework/tests/Unit/Pages/InMemoryPageTest.php index b2af4212101..32d39c8d479 100644 --- a/packages/framework/tests/Unit/Pages/InMemoryPageTest.php +++ b/packages/framework/tests/Unit/Pages/InMemoryPageTest.php @@ -445,6 +445,10 @@ public static function invalidExactOutputPaths(): array 'directory' => ['foo/'], 'windows separator' => ['foo\\robots.txt'], 'windows absolute' => ['C:\\robots.txt'], + 'dot' => ['.'], + 'leading dot segment' => ['./robots.txt'], + 'nested dot segment' => ['foo/./robots.txt'], + 'empty segment' => ['foo//robots.txt'], ]; } From cc76369254d8b5967ceaa278a386a32954613f62 Mon Sep 17 00:00:00 2001 From: Emma De Silva Date: Tue, 14 Jul 2026 20:35:31 +0200 Subject: [PATCH 091/117] Make in-memory output semantics class-level --- .../DocumentationSearchIndex.php | 4 +- .../Features/TextGenerators/LlmsTxtPage.php | 4 +- .../Features/TextGenerators/RobotsTxtPage.php | 4 +- .../Features/XmlGenerators/RssFeedPage.php | 11 ++- .../Features/XmlGenerators/SitemapPage.php | 4 +- packages/framework/src/Pages/InMemoryPage.php | 63 --------------- .../Commands/BuildRssFeedCommandTest.php | 5 +- .../Commands/BuildSitemapCommandTest.php | 10 ++- .../tests/Feature/LlmsTxtPageTest.php | 10 ++- .../tests/Feature/NonHtmlPageOutputTest.php | 74 +++++------------- .../tests/Feature/RobotsTxtPageTest.php | 10 ++- .../tests/Feature/RssFeedPageTest.php | 10 ++- .../Feature/Services/SitemapServiceTest.php | 10 ++- .../tests/Feature/SitemapPageTest.php | 10 ++- .../tests/Unit/Pages/InMemoryPageTest.php | 76 ++----------------- .../tests/Unit/Pages/InMemoryPageUnitTest.php | 17 ----- .../framework/tests/Unit/RouteKeyTest.php | 2 +- 17 files changed, 100 insertions(+), 224 deletions(-) diff --git a/packages/framework/src/Framework/Features/Documentation/DocumentationSearchIndex.php b/packages/framework/src/Framework/Features/Documentation/DocumentationSearchIndex.php index fc595b61fc9..f55bbd6c5b2 100644 --- a/packages/framework/src/Framework/Features/Documentation/DocumentationSearchIndex.php +++ b/packages/framework/src/Framework/Features/Documentation/DocumentationSearchIndex.php @@ -18,6 +18,8 @@ */ class DocumentationSearchIndex extends InMemoryPage { + public static string $outputExtension = '.json'; + protected readonly ?DocumentationVersion $version; public function __construct(?DocumentationVersion $version = null) @@ -26,7 +28,7 @@ public function __construct(?DocumentationVersion $version = null) parent::__construct(static::routeKey($version), [ 'navigation' => ['hidden' => true], - ], exactOutputPath: true); + ]); } public function compile(): string diff --git a/packages/framework/src/Framework/Features/TextGenerators/LlmsTxtPage.php b/packages/framework/src/Framework/Features/TextGenerators/LlmsTxtPage.php index 782d0946bb9..77949003d61 100644 --- a/packages/framework/src/Framework/Features/TextGenerators/LlmsTxtPage.php +++ b/packages/framework/src/Framework/Features/TextGenerators/LlmsTxtPage.php @@ -17,11 +17,13 @@ */ class LlmsTxtPage extends InMemoryPage { + public static string $outputExtension = '.txt'; + public function __construct() { parent::__construct(static::routeKey(), [ 'navigation' => ['hidden' => true], - ], exactOutputPath: true); + ]); } public function compile(): string diff --git a/packages/framework/src/Framework/Features/TextGenerators/RobotsTxtPage.php b/packages/framework/src/Framework/Features/TextGenerators/RobotsTxtPage.php index 0e68f158378..5dbe8040b70 100644 --- a/packages/framework/src/Framework/Features/TextGenerators/RobotsTxtPage.php +++ b/packages/framework/src/Framework/Features/TextGenerators/RobotsTxtPage.php @@ -17,11 +17,13 @@ */ class RobotsTxtPage extends InMemoryPage { + public static string $outputExtension = '.txt'; + public function __construct() { parent::__construct(static::routeKey(), [ 'navigation' => ['hidden' => true], - ], exactOutputPath: true); + ]); } public function compile(): string diff --git a/packages/framework/src/Framework/Features/XmlGenerators/RssFeedPage.php b/packages/framework/src/Framework/Features/XmlGenerators/RssFeedPage.php index 658565a73a4..a1488ad0f6d 100644 --- a/packages/framework/src/Framework/Features/XmlGenerators/RssFeedPage.php +++ b/packages/framework/src/Framework/Features/XmlGenerators/RssFeedPage.php @@ -7,6 +7,7 @@ use Hyde\Pages\InMemoryPage; use function app; +use function Hyde\unslash; /** * @internal This page is used to render the RSS feed for the site. @@ -21,7 +22,7 @@ public function __construct() { parent::__construct(static::routeKey(), [ 'navigation' => ['hidden' => true], - ], exactOutputPath: true); + ]); } public function compile(): string @@ -36,4 +37,12 @@ public static function routeKey(): string { return RssFeedGenerator::getFilename(); } + + /** + * Use the configured RSS filename as the output path. + */ + public static function outputPath(string $identifier): string + { + return unslash($identifier); + } } diff --git a/packages/framework/src/Framework/Features/XmlGenerators/SitemapPage.php b/packages/framework/src/Framework/Features/XmlGenerators/SitemapPage.php index 87673073977..ffc4a38ef77 100644 --- a/packages/framework/src/Framework/Features/XmlGenerators/SitemapPage.php +++ b/packages/framework/src/Framework/Features/XmlGenerators/SitemapPage.php @@ -17,11 +17,13 @@ */ class SitemapPage extends InMemoryPage { + public static string $outputExtension = '.xml'; + public function __construct() { parent::__construct(static::routeKey(), [ 'navigation' => ['hidden' => true], - ], exactOutputPath: true); + ]); } public function compile(): string diff --git a/packages/framework/src/Pages/InMemoryPage.php b/packages/framework/src/Pages/InMemoryPage.php index a5066ea9732..458b9a6ed7d 100644 --- a/packages/framework/src/Pages/InMemoryPage.php +++ b/packages/framework/src/Pages/InMemoryPage.php @@ -11,10 +11,7 @@ use Illuminate\Support\Facades\View; use InvalidArgumentException; -use function Hyde\unslash; -use function str_contains; use function str_ends_with; -use function str_starts_with; /** * Extendable class for in-memory (or virtual) Hyde pages that are not based on source files. @@ -46,7 +43,6 @@ class InMemoryPage extends HydePage * @var string|(Closure(): string)|(Closure(static): string) */ protected string|Closure $contents; - protected readonly bool $exactOutputPath; /** * The Blade view key or Blade file path. @@ -69,22 +65,6 @@ public static function make( return new static($identifier, $matter, $contents, $view); } - /** - * Create an in-memory page whose identifier is used as the exact output path. - * - * The output path must be a relative file path contained within the site output directory. - * - * @param string|(Closure(): string)|(Closure(static): string)|null $contents - */ - public static function file( - string $outputPath, - FrontMatter|array $matter = [], - string|Closure|null $contents = null, - ?string $view = null, - ): static { - return new static($outputPath, $matter, $contents, $view, exactOutputPath: true); - } - /** * Create a new in-memory (virtual) page instance. * @@ -93,7 +73,6 @@ public static function file( * * Contents and views cannot be used together. Omit both to create an empty page. * An empty view value is treated as no view. - * Normal construction uses HTML page semantics; use the `file()` constructor to create an exact-path file page. * * View values ending in `.blade.php` are treated as Blade file paths. Other values are treated * as registered Laravel view keys. @@ -102,7 +81,6 @@ public static function file( * @param FrontMatter|array $matter * @param string|(Closure(): string)|(Closure(static): string)|null $contents * @param string|null $view - * @param bool $exactOutputPath Whether to validate and use the identifier as an exact output path. Prefer the `file()` constructor for this mode. * * @throws InvalidArgumentException If both contents and a view are supplied. */ @@ -111,14 +89,7 @@ public function __construct( FrontMatter|array $matter = [], string|Closure|null $contents = null, ?string $view = null, - bool $exactOutputPath = false, ) { - if ($exactOutputPath) { - $identifier = static::normalizeExactOutputPath($identifier); - } - - $this->exactOutputPath = $exactOutputPath; - parent::__construct($identifier, $matter); $view = $view === '' ? null : $view; @@ -133,40 +104,6 @@ public function __construct( $this->view = $view ?? ''; } - protected static function normalizeExactOutputPath(string $path): string - { - $segments = explode('/', $path); - - if ( - $path === '' - || str_starts_with($path, '/') - || str_ends_with($path, '/') - || str_contains($path, '\\') - || preg_match('/^[A-Za-z]:/', $path) - || in_array('', $segments, true) - || in_array('.', $segments, true) - || in_array('..', $segments, true) - ) { - throw new InvalidArgumentException( - "Invalid exact output path [$path]. The path must be a relative file path inside the site output directory." - ); - } - - return unslash($path); - } - - /** - * Get the path where the compiled page will be saved. - */ - public function getOutputPath(): string - { - if ($this->exactOutputPath) { - return unslash($this->identifier); - } - - return parent::getOutputPath(); - } - /** * Get the literal contents or invoke the configured content closure. */ diff --git a/packages/framework/tests/Feature/Commands/BuildRssFeedCommandTest.php b/packages/framework/tests/Feature/Commands/BuildRssFeedCommandTest.php index 5d44aa1884e..0321cca5541 100644 --- a/packages/framework/tests/Feature/Commands/BuildRssFeedCommandTest.php +++ b/packages/framework/tests/Feature/Commands/BuildRssFeedCommandTest.php @@ -88,7 +88,10 @@ public function testCommandBuildsUserDefinedFeedPageEvenWhenRssFeatureConditions $this->cleanUpWhenDone('_site/feed.xml'); Hyde::kernel()->booting(function (HydeKernel $kernel): void { - $kernel->pages()->addPage(InMemoryPage::file('feed.xml', contents: '')); + $kernel->pages()->addPage(new class('feed.xml', contents: '') extends InMemoryPage + { + public static string $outputExtension = '.xml'; + }); }); $this->artisan('build:rss')->assertExitCode(0); diff --git a/packages/framework/tests/Feature/Commands/BuildSitemapCommandTest.php b/packages/framework/tests/Feature/Commands/BuildSitemapCommandTest.php index 3822aa4e38e..6c10895029d 100644 --- a/packages/framework/tests/Feature/Commands/BuildSitemapCommandTest.php +++ b/packages/framework/tests/Feature/Commands/BuildSitemapCommandTest.php @@ -60,7 +60,10 @@ public function testCommandBuildsUserDefinedSitemapPageWhenOneIsRegistered() $this->cleanUpWhenDone('_site/sitemap.xml'); Hyde::kernel()->booting(function (HydeKernel $kernel): void { - $kernel->pages()->addPage(InMemoryPage::file('sitemap.xml', contents: '')); + $kernel->pages()->addPage(new class('sitemap.xml', contents: '') extends InMemoryPage + { + public static string $outputExtension = '.xml'; + }); }); $this->artisan('build:sitemap')->assertExitCode(0); @@ -76,7 +79,10 @@ public function testCommandBuildsUserDefinedSitemapPageEvenWhenSitemapFeatureIsD $this->cleanUpWhenDone('_site/sitemap.xml'); Hyde::kernel()->booting(function (HydeKernel $kernel): void { - $kernel->pages()->addPage(InMemoryPage::file('sitemap.xml', contents: '')); + $kernel->pages()->addPage(new class('sitemap.xml', contents: '') extends InMemoryPage + { + public static string $outputExtension = '.xml'; + }); }); $this->artisan('build:sitemap')->assertExitCode(0); diff --git a/packages/framework/tests/Feature/LlmsTxtPageTest.php b/packages/framework/tests/Feature/LlmsTxtPageTest.php index 1b3b65ebc80..55f722ee813 100644 --- a/packages/framework/tests/Feature/LlmsTxtPageTest.php +++ b/packages/framework/tests/Feature/LlmsTxtPageTest.php @@ -128,7 +128,10 @@ public function testLlmsTxtRouteIsIncludedInTheRouteList() public function testUserPageRegisteredInBootingCallbackSuppressesTheGeneratedLlmsTxtPage() { Hyde::kernel()->booting(function (HydeKernel $kernel): void { - $kernel->pages()->addPage(InMemoryPage::file('llms.txt', contents: 'user defined llms')); + $kernel->pages()->addPage(new class('llms.txt', contents: 'user defined llms') extends InMemoryPage + { + public static string $outputExtension = '.txt'; + }); }); $page = Routes::get('llms.txt')->getPage(); @@ -160,6 +163,9 @@ class LlmsTxtPageTestExtension extends HydeExtension { public function discoverPages(PageCollection $collection): void { - $collection->addPage(InMemoryPage::file('llms.txt', contents: 'extension defined llms')); + $collection->addPage(new class('llms.txt', contents: 'extension defined llms') extends InMemoryPage + { + public static string $outputExtension = '.txt'; + }); } } diff --git a/packages/framework/tests/Feature/NonHtmlPageOutputTest.php b/packages/framework/tests/Feature/NonHtmlPageOutputTest.php index 28e4dc372fd..25e25c0b7d8 100644 --- a/packages/framework/tests/Feature/NonHtmlPageOutputTest.php +++ b/packages/framework/tests/Feature/NonHtmlPageOutputTest.php @@ -15,9 +15,7 @@ use Hyde\Framework\Actions\StaticPageBuilder; /** - * Feature test for compiling in-memory pages to exact output paths, - * covering the user path of registering pages like robots.txt in code and having - * them compiled to their specified output paths by the standard build process. + * Feature test for compiling custom page classes with non-HTML output extensions. * * @see \Hyde\Framework\Testing\Unit\Pages\InMemoryPageTest */ @@ -32,10 +30,10 @@ protected function tearDown(): void parent::tearDown(); } - public function testBuildCommandCompilesExactTxtOutputPath() + public function testBuildCommandCompilesInMemoryPageSubclassWithTxtOutputExtension() { Hyde::kernel()->booting(function (HydeKernel $kernel): void { - $kernel->pages()->addPage(InMemoryPage::file('robots.txt', contents: "User-agent: *\nAllow: /")); + $kernel->pages()->addPage(new InMemoryTxtTestPage('robots', contents: "User-agent: *\nAllow: /")); }); $this->artisan('build')->assertExitCode(0); @@ -45,34 +43,10 @@ public function testBuildCommandCompilesExactTxtOutputPath() $this->assertFileDoesNotExist(Hyde::path('_site/robots.txt.html')); } - public function testBuildCommandCompilesExactJsonOutputPath() + public function testBuildCommandCompilesNestedNonHtmlOutputPath() { Hyde::kernel()->booting(function (HydeKernel $kernel): void { - $kernel->pages()->addPage(InMemoryPage::file('data.json', contents: '{"foo": "bar"}')); - }); - - $this->artisan('build')->assertExitCode(0); - - $this->assertFileExists(Hyde::path('_site/data.json')); - $this->assertSame('{"foo": "bar"}', file_get_contents(Hyde::path('_site/data.json'))); - } - - public function testBuildCommandCompilesExactXmlOutputPath() - { - Hyde::kernel()->booting(function (HydeKernel $kernel): void { - $kernel->pages()->addPage(InMemoryPage::file('custom.xml', contents: '')); - }); - - $this->artisan('build')->assertExitCode(0); - - $this->assertFileExists(Hyde::path('_site/custom.xml')); - $this->assertSame('', file_get_contents(Hyde::path('_site/custom.xml'))); - } - - public function testBuildCommandCompilesNestedExactOutputPath() - { - Hyde::kernel()->booting(function (HydeKernel $kernel): void { - $kernel->pages()->addPage(InMemoryPage::file('foo/bar.txt', contents: 'baz')); + $kernel->pages()->addPage(new InMemoryTxtTestPage('foo/bar', contents: 'baz')); }); $this->artisan('build')->assertExitCode(0); @@ -81,57 +55,40 @@ public function testBuildCommandCompilesNestedExactOutputPath() $this->assertSame('baz', file_get_contents(Hyde::path('_site/foo/bar.txt'))); } - public function testBuildCommandCompilesArbitraryExactOutputPathsWithoutExtensionInference() - { - Hyde::kernel()->booting(function (HydeKernel $kernel): void { - $kernel->pages()->addPage(InMemoryPage::file('site.webmanifest', contents: 'manifest')); - $kernel->pages()->addPage(InMemoryPage::file('sitemap.xsl', contents: 'stylesheet')); - $kernel->pages()->addPage(InMemoryPage::file('downloads/data.csv', contents: 'csv')); - $kernel->pages()->addPage(InMemoryPage::file('feed', contents: 'feed')); - }); - - $this->artisan('build')->assertExitCode(0); - - $this->assertSame('manifest', file_get_contents(Hyde::path('_site/site.webmanifest'))); - $this->assertSame('stylesheet', file_get_contents(Hyde::path('_site/sitemap.xsl'))); - $this->assertSame('csv', file_get_contents(Hyde::path('_site/downloads/data.csv'))); - $this->assertSame('feed', file_get_contents(Hyde::path('_site/feed'))); - } - - public function testExactOutputPageIsRegisteredAsRoute() + public function testNonHtmlInMemoryPageIsRegisteredAsRoute() { Hyde::kernel()->booting(function (HydeKernel $kernel): void { - $kernel->pages()->addPage(InMemoryPage::file('robots.txt', contents: 'User-agent: *')); + $kernel->pages()->addPage(new InMemoryTxtTestPage('robots', contents: 'User-agent: *')); }); $this->assertTrue(Routes::exists('robots.txt')); $this->assertSame('robots.txt', Routes::get('robots.txt')->getOutputPath()); } - public function testStaticPageBuilderCompilesExactOutputPage() + public function testStaticPageBuilderCompilesNonHtmlInMemoryPage() { - StaticPageBuilder::handle(InMemoryPage::file('llms.txt', contents: '# Hello World')); + StaticPageBuilder::handle(new InMemoryTxtTestPage('llms', contents: '# Hello World')); $this->assertFileExists(Hyde::path('_site/llms.txt')); $this->assertSame('# Hello World', file_get_contents(Hyde::path('_site/llms.txt'))); } - public function testExactOutputPageCanCompileUsingView() + public function testNonHtmlInMemoryPageCanCompileUsingView() { $this->file('_pages/robots.blade.php', 'User-agent: {{ $agent }}'); - StaticPageBuilder::handle(InMemoryPage::file('robots.txt', ['agent' => '*'], view: 'robots')); + StaticPageBuilder::handle(new InMemoryTxtTestPage('robots', ['agent' => '*'], view: 'robots')); $this->assertFileExists(Hyde::path('_site/robots.txt')); $this->assertSame('User-agent: *', file_get_contents(Hyde::path('_site/robots.txt'))); } - public function testBuildCommandExcludesExactNonHtmlPageFromSitemap() + public function testBuildCommandExcludesNonHtmlInMemoryPageFromSitemap() { $this->withSiteUrl(); Hyde::kernel()->booting(function (HydeKernel $kernel): void { - $kernel->pages()->addPage(InMemoryPage::file('robots.txt', contents: 'User-agent: *')); + $kernel->pages()->addPage(new InMemoryTxtTestPage('robots', contents: 'User-agent: *')); }); $this->artisan('build')->assertExitCode(0); @@ -161,6 +118,11 @@ public function testBuildCommandCompilesDiscoverableCustomPageClassWithNonHtmlOu } } +class InMemoryTxtTestPage extends InMemoryPage +{ + public static string $outputExtension = '.txt'; +} + class DiscoverableNonHtmlTestPage extends HydePage { public static string $sourceDirectory = '_leaves'; diff --git a/packages/framework/tests/Feature/RobotsTxtPageTest.php b/packages/framework/tests/Feature/RobotsTxtPageTest.php index 00a66ce3f6b..f7b9435e05c 100644 --- a/packages/framework/tests/Feature/RobotsTxtPageTest.php +++ b/packages/framework/tests/Feature/RobotsTxtPageTest.php @@ -127,7 +127,10 @@ public function testRobotsTxtRouteIsIncludedInTheRouteList() public function testUserPageRegisteredInBootingCallbackSuppressesTheGeneratedRobotsTxtPage() { Hyde::kernel()->booting(function (HydeKernel $kernel): void { - $kernel->pages()->addPage(InMemoryPage::file('robots.txt', contents: 'user defined robots')); + $kernel->pages()->addPage(new class('robots.txt', contents: 'user defined robots') extends InMemoryPage + { + public static string $outputExtension = '.txt'; + }); }); $page = Routes::get('robots.txt')->getPage(); @@ -159,6 +162,9 @@ class RobotsTxtPageTestExtension extends HydeExtension { public function discoverPages(PageCollection $collection): void { - $collection->addPage(InMemoryPage::file('robots.txt', contents: 'extension defined robots')); + $collection->addPage(new class('robots.txt', contents: 'extension defined robots') extends InMemoryPage + { + public static string $outputExtension = '.txt'; + }); } } diff --git a/packages/framework/tests/Feature/RssFeedPageTest.php b/packages/framework/tests/Feature/RssFeedPageTest.php index a7d7853896f..a8efed9e178 100644 --- a/packages/framework/tests/Feature/RssFeedPageTest.php +++ b/packages/framework/tests/Feature/RssFeedPageTest.php @@ -163,7 +163,10 @@ public function testFeedRouteIsIncludedInTheRouteList() public function testUserPageRegisteredInBootingCallbackSuppressesTheGeneratedFeedPage() { Hyde::kernel()->booting(function (HydeKernel $kernel): void { - $kernel->pages()->addPage(InMemoryPage::file('feed.xml', contents: 'user defined feed')); + $kernel->pages()->addPage(new class('feed.xml', contents: 'user defined feed') extends InMemoryPage + { + public static string $outputExtension = '.xml'; + }); }); $page = Routes::get('feed.xml')->getPage(); @@ -195,6 +198,9 @@ class RssFeedPageTestExtension extends HydeExtension { public function discoverPages(PageCollection $collection): void { - $collection->addPage(InMemoryPage::file('feed.xml', contents: 'extension defined feed')); + $collection->addPage(new class('feed.xml', contents: 'extension defined feed') extends InMemoryPage + { + public static string $outputExtension = '.xml'; + }); } } diff --git a/packages/framework/tests/Feature/Services/SitemapServiceTest.php b/packages/framework/tests/Feature/Services/SitemapServiceTest.php index 35358e1c73b..b52951d4fa2 100644 --- a/packages/framework/tests/Feature/Services/SitemapServiceTest.php +++ b/packages/framework/tests/Feature/Services/SitemapServiceTest.php @@ -135,7 +135,10 @@ public function testGenerateDoesNotAddPagesWithSitemapFrontMatterSetToQuotedFals public function testGenerateDoesNotAddPagesWithNonHtmlOutputPaths() { - Routes::addRoute(new Route(InMemoryPage::file('robots.txt'))); + Routes::addRoute(new Route(new class('robots.txt') extends InMemoryPage + { + public static string $outputExtension = '.txt'; + })); $service = new SitemapGenerator(); $service->generate(); @@ -146,7 +149,10 @@ public function testGenerateDoesNotAddPagesWithNonHtmlOutputPaths() public function testGenerateAddsNonHtmlPagesWithSitemapFrontMatterSetToTrue() { - Routes::addRoute(new Route(InMemoryPage::file('robots.txt', ['sitemap' => true]))); + Routes::addRoute(new Route(new class('robots.txt', ['sitemap' => true]) extends InMemoryPage + { + public static string $outputExtension = '.txt'; + })); $service = new SitemapGenerator(); $service->generate(); diff --git a/packages/framework/tests/Feature/SitemapPageTest.php b/packages/framework/tests/Feature/SitemapPageTest.php index 873146887b1..8180aef9173 100644 --- a/packages/framework/tests/Feature/SitemapPageTest.php +++ b/packages/framework/tests/Feature/SitemapPageTest.php @@ -141,7 +141,10 @@ public function testUserPageRegisteredInBootingCallbackSuppressesTheGeneratedSit $this->withSiteUrl(); Hyde::kernel()->booting(function (HydeKernel $kernel): void { - $kernel->pages()->addPage(InMemoryPage::file('sitemap.xml', contents: 'user defined sitemap')); + $kernel->pages()->addPage(new class('sitemap.xml', contents: 'user defined sitemap') extends InMemoryPage + { + public static string $outputExtension = '.xml'; + }); }); $page = Routes::get('sitemap.xml')->getPage(); @@ -175,6 +178,9 @@ class SitemapPageTestExtension extends HydeExtension { public function discoverPages(PageCollection $collection): void { - $collection->addPage(InMemoryPage::file('sitemap.xml', contents: 'extension defined sitemap')); + $collection->addPage(new class('sitemap.xml', contents: 'extension defined sitemap') extends InMemoryPage + { + public static string $outputExtension = '.xml'; + }); } } diff --git a/packages/framework/tests/Unit/Pages/InMemoryPageTest.php b/packages/framework/tests/Unit/Pages/InMemoryPageTest.php index 32d39c8d479..7b5f41cf8f9 100644 --- a/packages/framework/tests/Unit/Pages/InMemoryPageTest.php +++ b/packages/framework/tests/Unit/Pages/InMemoryPageTest.php @@ -7,7 +7,6 @@ use Hyde\Pages\InMemoryPage; use Hyde\Testing\TestCase; use InvalidArgumentException; -use PHPUnit\Framework\Attributes\DataProvider; use RuntimeException; use TypeError; @@ -33,12 +32,6 @@ public function testCanMakePageWithLiteralContents() $this->assertSame('bar', $page->getContents()); } - public function testFileWithContentsString() - { - $this->assertInstanceOf(InMemoryPage::class, InMemoryPage::file('robots.txt', contents: 'bar')); - $this->assertSame('robots.txt', InMemoryPage::file('robots.txt', contents: 'bar')->getOutputPath()); - } - public function testGetContentsReturnsLiteralContents() { $this->assertSame('bar', (new InMemoryPage('foo', contents: 'bar'))->getContents()); @@ -417,69 +410,14 @@ public function testOutputPathUsesNormalHtmlPageSemantics() $this->assertSame('docs/1.x.html', InMemoryPage::outputPath('docs/1.x')); } - public function testFileUsesAnyOutputPathExactly() - { - $this->assertSame('robots.txt', InMemoryPage::file('robots.txt')->getOutputPath()); - $this->assertSame('site.webmanifest', InMemoryPage::file('site.webmanifest')->getOutputPath()); - $this->assertSame('sitemap.xsl', InMemoryPage::file('sitemap.xsl')->getOutputPath()); - $this->assertSame('downloads/data.csv', InMemoryPage::file('downloads/data.csv')->getOutputPath()); - $this->assertSame('feed', InMemoryPage::file('feed')->getOutputPath()); - $this->assertSame('docs/1.x/search.json', InMemoryPage::file('docs/1.x/search.json')->getOutputPath()); - } - - #[DataProvider('invalidExactOutputPaths')] - public function testFileRejectsInvalidOutputPaths(string $path): void + public function testStaticAndInstanceOutputPathsUseTheSameSemantics() { - $this->expectException(InvalidArgumentException::class); - - InMemoryPage::file($path); - } - - public static function invalidExactOutputPaths(): array - { - return [ - 'empty' => [''], - 'absolute' => ['/robots.txt'], - 'traversal' => ['../robots.txt'], - 'nested traversal' => ['foo/../../robots.txt'], - 'directory' => ['foo/'], - 'windows separator' => ['foo\\robots.txt'], - 'windows absolute' => ['C:\\robots.txt'], - 'dot' => ['.'], - 'leading dot segment' => ['./robots.txt'], - 'nested dot segment' => ['foo/./robots.txt'], - 'empty segment' => ['foo//robots.txt'], - ]; - } - - public function testGetRouteKeyForFile() - { - $this->assertSame('robots.txt', InMemoryPage::file('robots.txt')->getRouteKey()); - $this->assertSame('feed', InMemoryPage::file('feed')->getRouteKey()); - } - - public function testGetLinkForFile() - { - $this->assertSame('robots.txt', InMemoryPage::file('robots.txt')->getLink()); - } - - public function testGetLinkForFileIsNotAffectedByPrettyUrls() - { - config(['hyde.pretty_urls' => true]); - - $this->assertSame('robots.txt', InMemoryPage::file('robots.txt')->getLink()); - } - - public function testGetCanonicalUrlForFile() - { - config(['hyde.url' => 'https://example.com']); - - $this->assertSame('https://example.com/robots.txt', InMemoryPage::file('robots.txt')->getCanonicalUrl()); - } - - public function testCompiledContentsAreNotAffectedByExactOutputPath() - { - $this->assertSame('User-agent: *', InMemoryPage::file('robots.txt', contents: 'User-agent: *')->compile()); + foreach (['foo', 'robots.txt', 'docs/search.json'] as $identifier) { + $this->assertSame( + InMemoryPage::outputPath($identifier), + (new InMemoryPage($identifier))->getOutputPath() + ); + } } } diff --git a/packages/framework/tests/Unit/Pages/InMemoryPageUnitTest.php b/packages/framework/tests/Unit/Pages/InMemoryPageUnitTest.php index 3b770e28c64..505663f37cc 100644 --- a/packages/framework/tests/Unit/Pages/InMemoryPageUnitTest.php +++ b/packages/framework/tests/Unit/Pages/InMemoryPageUnitTest.php @@ -113,14 +113,6 @@ public function testMake() $this->assertEquals(InMemoryPage::make('foo'), new InMemoryPage('foo')); } - public function testFile() - { - $this->assertEquals( - InMemoryPage::file('robots.txt', contents: 'User-agent: *'), - new InMemoryPage('robots.txt', contents: 'User-agent: *', exactOutputPath: true) - ); - } - public function testMakeWithData() { $this->assertEquals( @@ -140,15 +132,6 @@ public function testShowInSitemap() $this->assertFalse((new InMemoryPage('foo', ['sitemap' => false]))->showInSitemap()); } - public function testShowInSitemapIsFalseForPagesWithNonHtmlOutputPaths() - { - $this->assertFalse(InMemoryPage::file('robots.txt')->showInSitemap()); - $this->assertFalse(InMemoryPage::file('data.json')->showInSitemap()); - $this->assertFalse(InMemoryPage::file('custom.xml')->showInSitemap()); - - $this->assertTrue(InMemoryPage::file('robots.txt', ['sitemap' => true])->showInSitemap()); - } - public function testNavigationMenuPriority() { $this->assertSame(999, (new InMemoryPage('foo'))->navigationMenuPriority()); diff --git a/packages/framework/tests/Unit/RouteKeyTest.php b/packages/framework/tests/Unit/RouteKeyTest.php index d5d7a2252ae..906224ea9c7 100644 --- a/packages/framework/tests/Unit/RouteKeyTest.php +++ b/packages/framework/tests/Unit/RouteKeyTest.php @@ -82,7 +82,7 @@ public function testFromPageWithInMemoryPage() $this->assertEquals(new RouteKey('foo/bar'), RouteKey::fromPage(InMemoryPage::class, 'foo/bar')); } - public function testFromPageWithInMemoryPageIdentifierDeclaringOutputExtension() + public function testFromPageWithDottedInMemoryPageIdentifier() { $this->assertEquals(new RouteKey('robots.txt'), RouteKey::fromPage(InMemoryPage::class, 'robots.txt')); $this->assertEquals(new RouteKey('docs/search.json'), RouteKey::fromPage(InMemoryPage::class, 'docs/search.json')); From 0d07fbf693f491e9f42da20fe35ec5c662bb0233 Mon Sep 17 00:00:00 2001 From: Emma De Silva Date: Tue, 14 Jul 2026 20:36:41 +0200 Subject: [PATCH 092/117] Document class-level non-HTML page output --- HYDEPHP_V3_PLANNING.md | 2 +- UPGRADE.md | 25 +++++++++-------- .../hyde-pages-api/in-memory-page-methods.md | 27 +------------------ docs/advanced-features/hyde-pages.md | 14 +++++----- docs/advanced-features/in-memory-pages.md | 26 +++++++++++++----- 5 files changed, 41 insertions(+), 53 deletions(-) diff --git a/HYDEPHP_V3_PLANNING.md b/HYDEPHP_V3_PLANNING.md index b45103f3e81..73e16ffc2fd 100644 --- a/HYDEPHP_V3_PLANNING.md +++ b/HYDEPHP_V3_PLANNING.md @@ -24,7 +24,7 @@ Having this document in code lets us know the devlopment state at any given poin - 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)) - Added built-in terminal code blocks using the `terminal` fence language. Command prompts are styled for selection-free copying, and `terminal xml` supports four Symfony-style Console formatter tags. ([#2188](https://github.com/hydephp/develop/issues/2188), [#2485](https://github.com/hydephp/develop/issues/2485)) -- Added `InMemoryPage::file()` for creating virtual pages whose identifier is used as the exact output path, allowing files such as `robots.txt`, `site.webmanifest`, nested JSON files, and extensionless outputs without extension inference. `InMemoryPage::make()` retains normal HTML page semantics. Custom page classes can compile to non-HTML files by setting the new static `$outputExtension` property (defaulting to `.html`). Only the HTML extension is implicit in route keys: pages compiled to non-HTML files keep their extension in the route key, formalizing the convention already used by the documentation search index. +- Custom page classes can compile to non-HTML files by setting the new static `$outputExtension` property (defaulting to `.html`). Only the HTML extension is implicit in route keys: pages compiled to non-HTML files keep their extension in the route key, formalizing the convention already used by the documentation search index. - Pages can now control their own sitemap inclusion. Set `sitemap: false` in a page's front matter to exclude it from the generated `sitemap.xml`, or override the new `HydePage::showInSitemap()` method in custom page classes. Pages compiled to non-HTML output files (like `robots.txt`) are excluded by default, and `sitemap: true` front matter opts such a page back in. - The sitemap and RSS feed are now first-class pages instead of post-build side effects: when the respective feature is enabled, `sitemap.xml` and the RSS feed (`feed.xml`, or the configured `hyde.rss.filename`) are registered as routes, so they are served by `hyde serve`, listed in `route:list`, included in the build manifest, and compiled through the standard site build. The output can be customized by rebinding the `SitemapGenerator` or `RssFeedGenerator` class in the service container, and registering a user-defined page with the same route key (from a service provider, booting callback, or extension) replaces the generated page entirely. - Hyde now generates a `robots.txt` file for the site out of the box. The default output allows all crawlers, and links to the sitemap when that feature is enabled. Rule values listed in the new `hyde.robots.disallow` configuration array are written verbatim as `Disallow` rules (so wildcard patterns are supported), and the file can be disabled entirely with `hyde.robots.enabled`. The page is wired like the sitemap and RSS feed: it is a real route (served by `hyde serve`, listed in `route:list`, included in the build manifest), the output can be customized by rebinding the `RobotsTxtGenerator` class in the service container, and a user-defined `robots.txt` page replaces the generated one entirely. diff --git a/UPGRADE.md b/UPGRADE.md index d8182edebde..aa1881d8fad 100644 --- a/UPGRADE.md +++ b/UPGRADE.md @@ -2,18 +2,16 @@ ## Overview -HydePHP v3 adds `InMemoryPage::file()` for creating virtual pages whose identifier is used as the exact output path, -allowing files such as `robots.txt`, `site.webmanifest`, nested JSON files, and extensionless outputs without extension -inference. Normal `InMemoryPage::make()` construction retains its historical HTML behavior: +HydePHP v3 lets custom page classes declare their output extension. `InMemoryPage` generates HTML by default; extend it +and set the static `$outputExtension` property when creating an in-memory page for another format: ```php -InMemoryPage::make('about', contents: $html); -// _site/about.html - -InMemoryPage::make('robots.txt', contents: $text); -// _site/robots.txt.html +class TextFilePage extends InMemoryPage +{ + public static string $outputExtension = '.txt'; +} -InMemoryPage::file('robots.txt', contents: $text); +TextFilePage::make('robots', contents: $text); // _site/robots.txt ``` @@ -343,8 +341,13 @@ The same works for `RssFeedGenerator`. use Hyde\Hyde; use Hyde\Pages\InMemoryPage; -Hyde::kernel()->booting(function ($kernel): void { - $kernel->pages()->addPage(InMemoryPage::file('sitemap.xml', contents: $myXml)); +class CustomSitemapPage extends InMemoryPage +{ + public static string $outputExtension = '.xml'; +} + +Hyde::kernel()->booting(function ($kernel) use ($myXml): void { + $kernel->pages()->addPage(new CustomSitemapPage('sitemap', contents: $myXml)); }); ``` diff --git a/docs/_data/partials/hyde-pages-api/in-memory-page-methods.md b/docs/_data/partials/hyde-pages-api/in-memory-page-methods.md index 141cb02548b..b4842f630d2 100644 --- a/docs/_data/partials/hyde-pages-api/in-memory-page-methods.md +++ b/docs/_data/partials/hyde-pages-api/in-memory-page-methods.md @@ -12,19 +12,6 @@ Static alias for the constructor. InMemoryPage::make(string|(Closure(): string)|(Closure(static):, Hyde\Markdown\Models\FrontMatter|array $matter, Closure|string|null $contents, string $view): static ``` -#### `file()` - -Create an in-memory page whose identifier is used as the exact output path. - -The output path must be a relative file path contained within the site output directory. - -The output path must be a relative file path contained within the site output directory. - -```php -/** @param string|(Closure(): string)|(Closure(static): string)|null $contents */ -InMemoryPage::file(string $outputPath, Hyde\Markdown\Models\FrontMatter|array $matter, Closure|string|null $contents, string $view): static -``` - #### `__construct()` Create a new in-memory (virtual) page instance. @@ -35,25 +22,13 @@ Contents and views cannot be used together. Omit both to create an empty page. A View values ending in `.blade.php` are treated as Blade file paths. Other values are treated as registered Laravel view keys. -Normal construction uses HTML page semantics; use the `file()` constructor to create an exact-path file page. - -- **Parameter $exactOutputPath:** Whether to validate and use the identifier as an exact output path. Prefer the `file()` constructor for this mode. - ```php /** @param string|(Closure(): string)|(Closure(static): string)|null $contents */ -$page = new InMemoryPage(string $identifier, FrontMatter|array $matter, string|(Closure(): string)|(Closure(static):, string|null $view, bool $exactOutputPath): void +$page = new InMemoryPage(string $identifier, FrontMatter|array $matter, string|(Closure(): string)|(Closure(static):, string|null $view): void ``` - **Throws:** InvalidArgumentException If both contents and a view are supplied. -#### `getOutputPath()` - -Get the path where the compiled page will be saved. - -```php -$page->getOutputPath(): string -``` - #### `getContents()` Get the literal contents or invoke the configured content closure. diff --git a/docs/advanced-features/hyde-pages.md b/docs/advanced-features/hyde-pages.md index 42a89c0744a..2abfaf5a759 100644 --- a/docs/advanced-features/hyde-pages.md +++ b/docs/advanced-features/hyde-pages.md @@ -161,16 +161,15 @@ autodiscovery, you may benefit from creating a custom page class instead, as tha You can learn more about the InMemoryPage class in the [InMemoryPage documentation](in-memory-pages). -Normal in-memory pages compile as HTML, regardless of dots in their identifiers. Use `file()` to opt into an exact output path: +In-memory pages generate HTML by default. Custom subclasses can generate another format by declaring their output extension: ```php -InMemoryPage::make('about', contents: $html); -// _site/about.html - -InMemoryPage::make('robots.txt', contents: $text); -// _site/robots.txt.html +class TextFilePage extends InMemoryPage +{ + public static string $outputExtension = '.txt'; +} -InMemoryPage::file('robots.txt', contents: $text); +TextFilePage::make('robots', contents: $text); // _site/robots.txt ``` @@ -199,7 +198,6 @@ class InMemoryPage extends HydePage protected string|\Closure $contents; protected string $view; - protected readonly bool $exactOutputPath; } ``` diff --git a/docs/advanced-features/in-memory-pages.md b/docs/advanced-features/in-memory-pages.md index 43aa68df1e6..81424237c60 100644 --- a/docs/advanced-features/in-memory-pages.md +++ b/docs/advanced-features/in-memory-pages.md @@ -34,22 +34,34 @@ To create an InMemoryPage, you need to instantiate it with the required paramete The constructor supports three content strategies: literal string contents, lazy closure contents, and Blade view rendering. Pass a string to the `$contents` parameter when the page contents are already available. Hyde saves the string literally. -Normal construction always uses HTML page semantics, even when the identifier contains a dot. Use `file()` when the -identifier should instead be used as the exact output path: ```php InMemoryPage::make('about', contents: $html); // _site/about.html +``` + +In-memory pages generate HTML by default. To generate another format, extend `InMemoryPage` and declare the output +extension on the page class: -InMemoryPage::make('robots.txt', contents: $text); -// _site/robots.txt.html +```php +class TextFilePage extends InMemoryPage +{ + public static string $outputExtension = '.txt'; +} -InMemoryPage::file('robots.txt', contents: $text); +TextFilePage::make('robots', contents: $text); // _site/robots.txt ``` -The route key and output path are exactly the relative file path passed to `file()`. This supports files such as -`site.webmanifest`, nested JSON files, and extensionless outputs without inferring behavior from the filename. +The output extension belongs to the page type, ensuring static and instance path resolution use the same rules: + +```php +TextFilePage::outputPath('robots'); +// robots.txt + +TextFilePage::make('robots')->getOutputPath(); +// robots.txt +``` Pass a closure when the contents should be generated lazily during compilation. The closure is invoked again for each compilation, which makes it useful for pages generated from the current application state. From 59c65c2daa9153238368a28d591b4242d3fc4ed2 Mon Sep 17 00:00:00 2001 From: Emma De Silva Date: Tue, 14 Jul 2026 20:37:57 +0200 Subject: [PATCH 093/117] Record class-level output path design decision --- EPIC_NON_HTML_PAGES.md | 116 ++++++++++++++++++----------------------- 1 file changed, 52 insertions(+), 64 deletions(-) diff --git a/EPIC_NON_HTML_PAGES.md b/EPIC_NON_HTML_PAGES.md index a2459a54d7e..e5878be2c1d 100644 --- a/EPIC_NON_HTML_PAGES.md +++ b/EPIC_NON_HTML_PAGES.md @@ -38,8 +38,8 @@ require every output format to have a matching filesystem-discovered page class. `docs/2.x/search.json`. There is no per-page sitemap exclusion mechanism at all. - **`hyde serve` does not serve `sitemap.xml` or the RSS feed**, because they only exist as post-build artifacts. -- **`InMemoryPage` has no unambiguous exact-file construction mode**, making the - natural "assemble a non-HTML file in code" escape hatch depend on inference. +- **Custom page classes cannot declare a non-HTML output format**, forcing them to + override path resolution and making route-key and output-path drift possible. - **The realtime compiler special-cases `search.json` by string suffix** instead of asking the route system. @@ -76,35 +76,36 @@ and `docs/search.json` (index) coexist as distinct routes, which they already do > D1-compliant out of the box and PR 2 can rely on "route key == request path with > only `.html` stripped" universally. -### D2: Exact output paths are explicit, not inferred +### D2: Output extensions are declared by the page class, not inferred Do **not** infer "this identifier has an extension" from a dot in the identifier — versioned docs route keys like `docs/1.x/index` would false-positive (`pathinfo('docs/1.x')['extension'] === 'x'`). Instead, output intent is explicit: -- File-discovered custom classes configure their output extension statically, - mirroring the existing `HtmlPage::$sourceExtension` pattern. -- `HydePage` gets `public static string $outputExtension = '.html'` (or an - instance-level hook), and `outputPath()` uses it instead of the hardcoded `'.html'`. -- `InMemoryPage` has two unambiguous construction modes: `make()` uses normal HTML - page semantics, while `file()` validates and uses its identifier as the exact - relative output path. - -> **Final decision (before PR 8): explicit page versus file construction.** The -> allowlist introduced in PR 1 was removed after review because it still inferred -> output behavior and merely relocated the original trap: `.txt` was treated as a -> file while `.webmanifest` was not. `InMemoryPage::make('docs/1.x')` now always -> produces `docs/1.x.html`, and `InMemoryPage::file('robots.txt')` produces exactly -> `robots.txt`. The file mode also handles `site.webmanifest`, `sitemap.xsl`, nested -> `downloads/data.csv`, and extensionless `feed` paths without special cases. +- `HydePage` gets `public static string $outputExtension = '.html'`, and both route-key + and output-path resolution use it. +- File-discovered and in-memory custom page classes declare another extension by + redeclaring that static property, mirroring the existing source path properties. +- A plain `InMemoryPage` always uses its class's HTML semantics. Dots in an identifier + carry no output-format meaning. + +> **Final design decision (before PR 8): output behavior remains class-level.** Two +> instance-level designs were implemented during development and rejected before +> release. The first inferred non-HTML behavior from an allowlist of identifier +> suffixes; it merely moved the trap because `.txt` worked while `.webmanifest` did +> not. The second added `InMemoryPage::file()` and an `$exactOutputPath` instance flag. +> It supported arbitrary paths, but broke the `HydePage` static/instance contract: +> `InMemoryPage::outputPath('robots.txt')` returned `robots.txt.html` while the file +> instance's `getOutputPath()` returned `robots.txt`. Compiler integrations are +> allowed to resolve paths statically, so the disagreement was unsafe. > -> The two output mechanisms are now both declarative: file-discovered custom page -> classes use the per-class static `$outputExtension`, while individual virtual -> files use `InMemoryPage::file()`. First-party generated files opt into exact-path -> mode directly. In particular, `RssFeedPage` no longer overrides an inference hook; -> its configurable filename is inherently an exact output path. Redirects retain -> normal HTML semantics even when their route identifiers contain dots, so a redirect -> identifier of `legacy.json` compiles to `legacy.json.html`. +> Both designs were removed. Non-HTML behavior is represented by a page subclass with +> a static `$outputExtension`, keeping `Page::outputPath($identifier)` equal to the +> corresponding instance's `getOutputPath()`. First-party generated pages follow the +> same rule. `RssFeedPage` is the narrow exception because its configured filename may +> use any extension or none; it overrides the static `outputPath()` method, so its +> static and instance resolution still agree. Redirects and plain `InMemoryPage` +> instances retain HTML semantics even when their identifiers contain dots. ### D3: Sitemap inclusion becomes a page-level concern @@ -117,17 +118,11 @@ a standalone feature in its own right. > **Implementation constraint (from PR 1) — read before writing `showInSitemap()`:** > the "output is not `.html`" default MUST be derived from the page's *resolved -> output path* (e.g. the extension of `getOutputPath()`), not from the static -> `outputExtension()` accessor. For file-discovered custom pages the two agree, but -> per D2 an `InMemoryPage::file()` instance uses an exact output path while its static -> `outputExtension()` stays `.html`. So a `robots.txt` / `sitemap.xml` / `llms.txt` -> file page reports `.html` statically despite compiling to a non-HTML file. -> Keying the non-HTML default off `getOutputPath()` makes all four generated pages -> self-exclude correctly; keying it off `outputExtension()` would silently -> re-introduce the exact `search.json` leak this epic exists to fix. This is the -> kind of "rule implemented only for the cases the current PR exercised" trap the -> agent workflow warns about — the discovered-page tests would pass while the -> InMemoryPage-backed generated pages regress. +> output path* (`getOutputPath()`), not merely from the declared extension. The +> resolved path is the canonical answer and also covers specialized classes such as +> `RssFeedPage`, whose configurable filename cannot be represented by one fixed +> extension. Keying the default off `getOutputPath()` makes all generated pages +> self-exclude correctly and prevents the `search.json` leak from returning. > **Implemented (PR 4):** `HydePage::showInSitemap()` reads the `sitemap` front > matter key, defaulting to whether the resolved output path (`getOutputPath()`) @@ -254,10 +249,9 @@ container → fully custom page in code. First-class non-HTML support is about a page's output path and participation in the route/build/serve lifecycle; it does not require a dedicated source-backed page class -for each file extension. `InMemoryPage::file('robots.txt', contents: ...)` already -provides the full lifecycle integration and is a better fit for the dynamic content -advanced users commonly need, while the planned generated robots and llms pages cover -the common cases without any source file at all. +for each file extension. A small `InMemoryPage` subclass declaring `$outputExtension` +provides the full lifecycle integration and is a better fit for dynamic content, while +the generated robots and llms pages cover the common cases without source files. A core `TextPage` would add only the convenience of autodiscovering `_pages/*.txt`, while creating pressure for parallel `XmlPage`, `JsonPage`, and similar classes. @@ -270,16 +264,15 @@ an extension point. ## Work breakdown (planned PR sequence, in dependency order) -### PR 1 — Foundation: explicit output paths and page-class extensions ✅ Implemented +### PR 1 — Foundation: page-class output extensions ✅ Implemented Goal: any page class can emit a non-`.html` file without overriding `getOutputPath()`. - Add `$outputExtension` (default `'.html'`) to `HydePage`; use it in `outputPath()` (`HydePage.php:211-214`). - Route keys follow D1; audit `RouteKey` and `Route` for assumptions. -- Add explicit exact-path construction for `InMemoryPage` per D2, so - `InMemoryPage::file('robots.txt', contents: ...)` outputs `robots.txt`. -- Refactor `DocumentationSearchIndex` to use the exact-path file-page mode. +- Keep `InMemoryPage` on the same class-level output path contract as other pages. +- Refactor `DocumentationSearchIndex` to declare its `.json` output extension. - Pure refactor for existing sites: no compiled-output changes. Implementation notes (branch `v3/non-html-pages-foundation`): @@ -299,15 +292,11 @@ Implementation notes (branch `v3/non-html-pages-foundation`): "Upgrade script rules" for the release-time Rector script. - Page-class output extension handling was placed in `RouteKey::fromPage()` (see D1 note) rather than only in `outputPath()`, so route keys and output paths cannot drift. -- **Revised before PR 8:** the original allowlist-based implementation was replaced - by `InMemoryPage::file()` (see the final D2 decision). `make()` and direct - construction consistently retain HTML output semantics, while exact-path file - instances work without an extension-specific subclass or inference. -- The two output-extension mechanisms coexist intentionally — per-class static for - discovered classes, per-instance exact-path construction for `InMemoryPage`. - The one constraint this places downstream is recorded in the D3 implementation - note: sitemap / non-HTML detection must read the resolved output path, not the - static `outputExtension()` accessor. +- **Revised before PR 8:** both identifier-suffix inference and the later + instance-level exact-path factory were removed (see D2). Output behavior is + class-level for discovered and in-memory pages alike. +- Sitemap / non-HTML detection reads the resolved output path, not just the static + extension declaration, so specialized static path overrides remain supported. ### PR 2 — Realtime compiler: route-first resolution for non-HTML paths ✅ Implemented @@ -373,10 +362,9 @@ Implementation notes (branch `v3/non-html-pages-sitemap-inclusion-policy`): - Implemented exactly per D3 (see the D3 "Implemented" note for the front matter semantics and the `Redirect` refinement). `showInSitemap()` joined the - `BaseHydePageUnitTest` contract; the InMemoryPage unit test covers the - exact-path non-HTML default that the static-extension tests cannot. -- The non-HTML self-exclusion is verified end-to-end: a registered `robots.txt` - `InMemoryPage` is built by the real `build` command and asserted absent from + `BaseHydePageUnitTest` contract. +- The non-HTML self-exclusion is verified end-to-end: a registered `.txt` + `InMemoryPage` subclass is built by the real `build` command and asserted absent from the built `sitemap.xml`, guarding the D3 resolved-output-path constraint against regression by construction rather than only at the unit level. - Two existing tests asserted the leak as expected behavior and were flipped: @@ -409,8 +397,8 @@ Goal: `sitemap.xml` and `feed.xml` are routes — served by `hyde serve`, listed bound). D4's whole swappability tier is a lie if `app(SitemapGenerator::class)` can't be rebound. Add a test that rebinds the generator and asserts the page's compiled output changes. -- Ensure all first-party generated files (`sitemap.xml`, `feed.xml`, `robots.txt`, - `llms.txt`) use the D2 exact-path mode, including the configurable RSS filename. +- Ensure first-party generated page classes declare their output format per D2; + preserve the configurable RSS filename through a class-level path override. - Register in `HydeCoreExtension::discoverPages()` behind `Features::hasSitemap()` / `Features::hasRss()`; remove `GenerateSitemap`/`GenerateRssFeed` from `BuildTaskService::registerFrameworkTasks()` (evaluate deprecation vs. removal — @@ -479,8 +467,8 @@ Implementation notes, part B (branch `v3/non-html-pages-convert-rss-feed`): from the sitemap, and both user override paths verified end-to-end. - One divergence: the route key comes from `RssFeedGenerator::getFilename()` (config `hyde.rss.filename`). Since the removed task wrote any configured filename - verbatim, `RssFeedPage` uses the exact-path construction mode — `feed.rss` and an - extensionless name therefore work without an inference override. + verbatim, `RssFeedPage` overrides static `outputPath()` — `feed.rss` and an + extensionless name therefore work while static and instance resolution still agree. - `build:rss` builds the registered route's page like `build:sitemap`, and fails with the generic "feature is not enabled" error when the route is not registered (see the revised-in-review notes in the part A section — the old task's no-guard @@ -536,7 +524,7 @@ Implementation notes (branch `v3/non-html-pages-robots`): index, instead of surfacing as a PHP-level type error at build time. Later generated text pages copying this pattern (llms.txt) should keep both halves — verbatim strings, explicit validation. -- `RobotsTxtPage` uses the D2 exact-path mode for `robots.txt`. +- `RobotsTxtPage` declares its `.txt` output extension per D2. - No `build:robots` command: the sitemap/RSS commands exist only as carry-overs of the removed post-build tasks; robots.txt never had one, and the standard build and realtime compiler (serve test asserts `text/plain`) cover the lifecycle. @@ -659,8 +647,8 @@ Implementation notes (branch `v3/non-html-pages-llms-txt`): generator-level curation concern rather than a page-level default (the sitemap precedent likewise keeps its 404 handling in the generator), and it is the reason the sitemap-derived inclusion rule is not a bare alias for `showInSitemap()`. -- Everything else the epic left implicit held: `LlmsTxtPage` uses the D2 exact-path - mode, and the generated page self-excludes from its own listing (and the sitemap) +- Everything else the epic left implicit held: `LlmsTxtPage` declares its `.txt` + output extension, and the generated page self-excludes from its own listing (and the sitemap) through the D3 resolved-output-path default. > **Scope correction (post-implementation review).** The first cut of this PR was From e01bee158bdf41da6b67d40f9f5d163db3b73224 Mon Sep 17 00:00:00 2001 From: Emma De Silva Date: Tue, 14 Jul 2026 20:40:52 +0200 Subject: [PATCH 094/117] Test static and instance output path symmetry --- .../framework/tests/Feature/DocumentationSearchIndexTest.php | 1 + packages/framework/tests/Feature/LlmsTxtPageTest.php | 1 + packages/framework/tests/Feature/RobotsTxtPageTest.php | 1 + packages/framework/tests/Feature/RssFeedPageTest.php | 2 ++ packages/framework/tests/Feature/SitemapPageTest.php | 1 + 5 files changed, 6 insertions(+) diff --git a/packages/framework/tests/Feature/DocumentationSearchIndexTest.php b/packages/framework/tests/Feature/DocumentationSearchIndexTest.php index 5c8a3d4b373..bc48fb20013 100644 --- a/packages/framework/tests/Feature/DocumentationSearchIndexTest.php +++ b/packages/framework/tests/Feature/DocumentationSearchIndexTest.php @@ -45,6 +45,7 @@ public function testRouteKeyIsSetToVersionedDocumentationOutputDirectory() $this->assertSame('docs/1.x/search.json', $page->routeKey); $this->assertSame('docs/1.x/search.json', $page->getOutputPath()); + $this->assertSame($page::outputPath($page->getIdentifier()), $page->getOutputPath()); $this->assertSame('1.x', $page->getDocumentationVersion()->name); } diff --git a/packages/framework/tests/Feature/LlmsTxtPageTest.php b/packages/framework/tests/Feature/LlmsTxtPageTest.php index 55f722ee813..370f35f1fdf 100644 --- a/packages/framework/tests/Feature/LlmsTxtPageTest.php +++ b/packages/framework/tests/Feature/LlmsTxtPageTest.php @@ -48,6 +48,7 @@ public function testLlmsTxtPageIsRegisteredAsRouteByDefault() $this->assertInstanceOf(LlmsTxtPage::class, $page); $this->assertSame('llms.txt', $page->getOutputPath()); + $this->assertSame($page::outputPath($page->getIdentifier()), $page->getOutputPath()); $this->assertSame('llms.txt', $page->getRouteKey()); } diff --git a/packages/framework/tests/Feature/RobotsTxtPageTest.php b/packages/framework/tests/Feature/RobotsTxtPageTest.php index f7b9435e05c..7198273048a 100644 --- a/packages/framework/tests/Feature/RobotsTxtPageTest.php +++ b/packages/framework/tests/Feature/RobotsTxtPageTest.php @@ -41,6 +41,7 @@ public function testRobotsTxtPageIsRegisteredAsRouteByDefault() $this->assertInstanceOf(RobotsTxtPage::class, $page); $this->assertSame('robots.txt', $page->getOutputPath()); + $this->assertSame($page::outputPath($page->getIdentifier()), $page->getOutputPath()); $this->assertSame('robots.txt', $page->getRouteKey()); } diff --git a/packages/framework/tests/Feature/RssFeedPageTest.php b/packages/framework/tests/Feature/RssFeedPageTest.php index a8efed9e178..817ee965fb4 100644 --- a/packages/framework/tests/Feature/RssFeedPageTest.php +++ b/packages/framework/tests/Feature/RssFeedPageTest.php @@ -51,6 +51,7 @@ public function testFeedPageIsRegisteredAsRouteWhenRssFeatureIsEnabled() $this->assertInstanceOf(RssFeedPage::class, $page); $this->assertSame('feed.xml', $page->getOutputPath()); + $this->assertSame($page::outputPath($page->getIdentifier()), $page->getOutputPath()); $this->assertSame('feed.xml', $page->getRouteKey()); } @@ -90,6 +91,7 @@ public function testFeedPageUsesConfiguredFilenameVerbatimForAnyExtension() $this->assertTrue(Routes::exists('feed.rss')); $this->assertSame('feed.rss', Routes::get('feed.rss')->getPage()->getOutputPath()); + $this->assertSame('feed.rss', RssFeedPage::outputPath('feed.rss')); } public function testFeedPageIsHiddenFromNavigationAndExcludesItselfFromTheSitemap() diff --git a/packages/framework/tests/Feature/SitemapPageTest.php b/packages/framework/tests/Feature/SitemapPageTest.php index 8180aef9173..24b5b2272d1 100644 --- a/packages/framework/tests/Feature/SitemapPageTest.php +++ b/packages/framework/tests/Feature/SitemapPageTest.php @@ -44,6 +44,7 @@ public function testSitemapPageIsRegisteredAsRouteWhenSitemapFeatureIsEnabled() $this->assertInstanceOf(SitemapPage::class, $page); $this->assertSame('sitemap.xml', $page->getOutputPath()); + $this->assertSame($page::outputPath($page->getIdentifier()), $page->getOutputPath()); $this->assertSame('sitemap.xml', $page->getRouteKey()); } From a91ed95367c33a82210e433012920fc006400d3a Mon Sep 17 00:00:00 2001 From: Emma De Silva Date: Tue, 14 Jul 2026 23:06:14 +0200 Subject: [PATCH 095/117] Infer in-memory output paths from identifiers --- .../DocumentationSearchIndex.php | 2 -- .../Features/TextGenerators/LlmsTxtPage.php | 2 -- .../Features/TextGenerators/RobotsTxtPage.php | 2 -- .../Features/XmlGenerators/RssFeedPage.php | 9 --------- .../Features/XmlGenerators/SitemapPage.php | 2 -- packages/framework/src/Pages/InMemoryPage.php | 16 ++++++++++++++++ .../Commands/BuildRssFeedCommandTest.php | 5 +---- .../Commands/BuildSitemapCommandTest.php | 10 ++-------- .../tests/Feature/LlmsTxtPageTest.php | 10 ++-------- .../tests/Feature/NonHtmlPageOutputTest.php | 19 +++++++------------ .../framework/tests/Feature/RedirectTest.php | 4 ++-- .../tests/Feature/RobotsTxtPageTest.php | 10 ++-------- .../tests/Feature/RssFeedPageTest.php | 10 ++-------- .../Feature/Services/SitemapServiceTest.php | 10 ++-------- .../tests/Feature/SitemapPageTest.php | 10 ++-------- .../tests/Unit/Pages/InMemoryPageTest.php | 19 ++++++++++--------- 16 files changed, 48 insertions(+), 92 deletions(-) diff --git a/packages/framework/src/Framework/Features/Documentation/DocumentationSearchIndex.php b/packages/framework/src/Framework/Features/Documentation/DocumentationSearchIndex.php index f55bbd6c5b2..bde991b7e74 100644 --- a/packages/framework/src/Framework/Features/Documentation/DocumentationSearchIndex.php +++ b/packages/framework/src/Framework/Features/Documentation/DocumentationSearchIndex.php @@ -18,8 +18,6 @@ */ class DocumentationSearchIndex extends InMemoryPage { - public static string $outputExtension = '.json'; - protected readonly ?DocumentationVersion $version; public function __construct(?DocumentationVersion $version = null) diff --git a/packages/framework/src/Framework/Features/TextGenerators/LlmsTxtPage.php b/packages/framework/src/Framework/Features/TextGenerators/LlmsTxtPage.php index 77949003d61..9d10ec260f9 100644 --- a/packages/framework/src/Framework/Features/TextGenerators/LlmsTxtPage.php +++ b/packages/framework/src/Framework/Features/TextGenerators/LlmsTxtPage.php @@ -17,8 +17,6 @@ */ class LlmsTxtPage extends InMemoryPage { - public static string $outputExtension = '.txt'; - public function __construct() { parent::__construct(static::routeKey(), [ diff --git a/packages/framework/src/Framework/Features/TextGenerators/RobotsTxtPage.php b/packages/framework/src/Framework/Features/TextGenerators/RobotsTxtPage.php index 5dbe8040b70..62653a35f5c 100644 --- a/packages/framework/src/Framework/Features/TextGenerators/RobotsTxtPage.php +++ b/packages/framework/src/Framework/Features/TextGenerators/RobotsTxtPage.php @@ -17,8 +17,6 @@ */ class RobotsTxtPage extends InMemoryPage { - public static string $outputExtension = '.txt'; - public function __construct() { parent::__construct(static::routeKey(), [ diff --git a/packages/framework/src/Framework/Features/XmlGenerators/RssFeedPage.php b/packages/framework/src/Framework/Features/XmlGenerators/RssFeedPage.php index a1488ad0f6d..32d2315d936 100644 --- a/packages/framework/src/Framework/Features/XmlGenerators/RssFeedPage.php +++ b/packages/framework/src/Framework/Features/XmlGenerators/RssFeedPage.php @@ -7,7 +7,6 @@ use Hyde\Pages\InMemoryPage; use function app; -use function Hyde\unslash; /** * @internal This page is used to render the RSS feed for the site. @@ -37,12 +36,4 @@ public static function routeKey(): string { return RssFeedGenerator::getFilename(); } - - /** - * Use the configured RSS filename as the output path. - */ - public static function outputPath(string $identifier): string - { - return unslash($identifier); - } } diff --git a/packages/framework/src/Framework/Features/XmlGenerators/SitemapPage.php b/packages/framework/src/Framework/Features/XmlGenerators/SitemapPage.php index ffc4a38ef77..075e79cac23 100644 --- a/packages/framework/src/Framework/Features/XmlGenerators/SitemapPage.php +++ b/packages/framework/src/Framework/Features/XmlGenerators/SitemapPage.php @@ -17,8 +17,6 @@ */ class SitemapPage extends InMemoryPage { - public static string $outputExtension = '.xml'; - public function __construct() { parent::__construct(static::routeKey(), [ diff --git a/packages/framework/src/Pages/InMemoryPage.php b/packages/framework/src/Pages/InMemoryPage.php index 458b9a6ed7d..5e5441b990c 100644 --- a/packages/framework/src/Pages/InMemoryPage.php +++ b/packages/framework/src/Pages/InMemoryPage.php @@ -11,6 +11,8 @@ use Illuminate\Support\Facades\View; use InvalidArgumentException; +use function Hyde\unslash; +use function pathinfo; use function str_ends_with; /** @@ -76,6 +78,7 @@ public static function make( * * View values ending in `.blade.php` are treated as Blade file paths. Other values are treated * as registered Laravel view keys. + * Identifiers that already have an extension use it as the output path unchanged. * * @param string $identifier * @param FrontMatter|array $matter @@ -104,6 +107,19 @@ public function __construct( $this->view = $view ?? ''; } + /** + * Qualify a page identifier into a target output file path. + * + * Identifiers with a file extension are used verbatim, while identifiers + * without an extension are compiled to HTML files. + */ + public static function outputPath(string $identifier): string + { + $identifier = unslash($identifier); + + return $identifier.(pathinfo($identifier, PATHINFO_EXTENSION) === '' ? '.html' : ''); + } + /** * Get the literal contents or invoke the configured content closure. */ diff --git a/packages/framework/tests/Feature/Commands/BuildRssFeedCommandTest.php b/packages/framework/tests/Feature/Commands/BuildRssFeedCommandTest.php index 0321cca5541..6c567804a7c 100644 --- a/packages/framework/tests/Feature/Commands/BuildRssFeedCommandTest.php +++ b/packages/framework/tests/Feature/Commands/BuildRssFeedCommandTest.php @@ -88,10 +88,7 @@ public function testCommandBuildsUserDefinedFeedPageEvenWhenRssFeatureConditions $this->cleanUpWhenDone('_site/feed.xml'); Hyde::kernel()->booting(function (HydeKernel $kernel): void { - $kernel->pages()->addPage(new class('feed.xml', contents: '') extends InMemoryPage - { - public static string $outputExtension = '.xml'; - }); + $kernel->pages()->addPage(InMemoryPage::make('feed.xml', contents: '')); }); $this->artisan('build:rss')->assertExitCode(0); diff --git a/packages/framework/tests/Feature/Commands/BuildSitemapCommandTest.php b/packages/framework/tests/Feature/Commands/BuildSitemapCommandTest.php index 6c10895029d..940f9342b04 100644 --- a/packages/framework/tests/Feature/Commands/BuildSitemapCommandTest.php +++ b/packages/framework/tests/Feature/Commands/BuildSitemapCommandTest.php @@ -60,10 +60,7 @@ public function testCommandBuildsUserDefinedSitemapPageWhenOneIsRegistered() $this->cleanUpWhenDone('_site/sitemap.xml'); Hyde::kernel()->booting(function (HydeKernel $kernel): void { - $kernel->pages()->addPage(new class('sitemap.xml', contents: '') extends InMemoryPage - { - public static string $outputExtension = '.xml'; - }); + $kernel->pages()->addPage(InMemoryPage::make('sitemap.xml', contents: '')); }); $this->artisan('build:sitemap')->assertExitCode(0); @@ -79,10 +76,7 @@ public function testCommandBuildsUserDefinedSitemapPageEvenWhenSitemapFeatureIsD $this->cleanUpWhenDone('_site/sitemap.xml'); Hyde::kernel()->booting(function (HydeKernel $kernel): void { - $kernel->pages()->addPage(new class('sitemap.xml', contents: '') extends InMemoryPage - { - public static string $outputExtension = '.xml'; - }); + $kernel->pages()->addPage(InMemoryPage::make('sitemap.xml', contents: '')); }); $this->artisan('build:sitemap')->assertExitCode(0); diff --git a/packages/framework/tests/Feature/LlmsTxtPageTest.php b/packages/framework/tests/Feature/LlmsTxtPageTest.php index 370f35f1fdf..faa3529dfe3 100644 --- a/packages/framework/tests/Feature/LlmsTxtPageTest.php +++ b/packages/framework/tests/Feature/LlmsTxtPageTest.php @@ -129,10 +129,7 @@ public function testLlmsTxtRouteIsIncludedInTheRouteList() public function testUserPageRegisteredInBootingCallbackSuppressesTheGeneratedLlmsTxtPage() { Hyde::kernel()->booting(function (HydeKernel $kernel): void { - $kernel->pages()->addPage(new class('llms.txt', contents: 'user defined llms') extends InMemoryPage - { - public static string $outputExtension = '.txt'; - }); + $kernel->pages()->addPage(InMemoryPage::make('llms.txt', contents: 'user defined llms')); }); $page = Routes::get('llms.txt')->getPage(); @@ -164,9 +161,6 @@ class LlmsTxtPageTestExtension extends HydeExtension { public function discoverPages(PageCollection $collection): void { - $collection->addPage(new class('llms.txt', contents: 'extension defined llms') extends InMemoryPage - { - public static string $outputExtension = '.txt'; - }); + $collection->addPage(InMemoryPage::make('llms.txt', contents: 'extension defined llms')); } } diff --git a/packages/framework/tests/Feature/NonHtmlPageOutputTest.php b/packages/framework/tests/Feature/NonHtmlPageOutputTest.php index 25e25c0b7d8..de720d66926 100644 --- a/packages/framework/tests/Feature/NonHtmlPageOutputTest.php +++ b/packages/framework/tests/Feature/NonHtmlPageOutputTest.php @@ -30,10 +30,10 @@ protected function tearDown(): void parent::tearDown(); } - public function testBuildCommandCompilesInMemoryPageSubclassWithTxtOutputExtension() + public function testBuildCommandCompilesInMemoryPageWithTxtIdentifier() { Hyde::kernel()->booting(function (HydeKernel $kernel): void { - $kernel->pages()->addPage(new InMemoryTxtTestPage('robots', contents: "User-agent: *\nAllow: /")); + $kernel->pages()->addPage(InMemoryPage::make('robots.txt', contents: "User-agent: *\nAllow: /")); }); $this->artisan('build')->assertExitCode(0); @@ -46,7 +46,7 @@ public function testBuildCommandCompilesInMemoryPageSubclassWithTxtOutputExtensi public function testBuildCommandCompilesNestedNonHtmlOutputPath() { Hyde::kernel()->booting(function (HydeKernel $kernel): void { - $kernel->pages()->addPage(new InMemoryTxtTestPage('foo/bar', contents: 'baz')); + $kernel->pages()->addPage(InMemoryPage::make('foo/bar.txt', contents: 'baz')); }); $this->artisan('build')->assertExitCode(0); @@ -58,7 +58,7 @@ public function testBuildCommandCompilesNestedNonHtmlOutputPath() public function testNonHtmlInMemoryPageIsRegisteredAsRoute() { Hyde::kernel()->booting(function (HydeKernel $kernel): void { - $kernel->pages()->addPage(new InMemoryTxtTestPage('robots', contents: 'User-agent: *')); + $kernel->pages()->addPage(InMemoryPage::make('robots.txt', contents: 'User-agent: *')); }); $this->assertTrue(Routes::exists('robots.txt')); @@ -67,7 +67,7 @@ public function testNonHtmlInMemoryPageIsRegisteredAsRoute() public function testStaticPageBuilderCompilesNonHtmlInMemoryPage() { - StaticPageBuilder::handle(new InMemoryTxtTestPage('llms', contents: '# Hello World')); + StaticPageBuilder::handle(InMemoryPage::make('llms.txt', contents: '# Hello World')); $this->assertFileExists(Hyde::path('_site/llms.txt')); $this->assertSame('# Hello World', file_get_contents(Hyde::path('_site/llms.txt'))); @@ -77,7 +77,7 @@ public function testNonHtmlInMemoryPageCanCompileUsingView() { $this->file('_pages/robots.blade.php', 'User-agent: {{ $agent }}'); - StaticPageBuilder::handle(new InMemoryTxtTestPage('robots', ['agent' => '*'], view: 'robots')); + StaticPageBuilder::handle(InMemoryPage::make('robots.txt', ['agent' => '*'], view: 'robots')); $this->assertFileExists(Hyde::path('_site/robots.txt')); $this->assertSame('User-agent: *', file_get_contents(Hyde::path('_site/robots.txt'))); @@ -88,7 +88,7 @@ public function testBuildCommandExcludesNonHtmlInMemoryPageFromSitemap() $this->withSiteUrl(); Hyde::kernel()->booting(function (HydeKernel $kernel): void { - $kernel->pages()->addPage(new InMemoryTxtTestPage('robots', contents: 'User-agent: *')); + $kernel->pages()->addPage(InMemoryPage::make('robots.txt', contents: 'User-agent: *')); }); $this->artisan('build')->assertExitCode(0); @@ -118,11 +118,6 @@ public function testBuildCommandCompilesDiscoverableCustomPageClassWithNonHtmlOu } } -class InMemoryTxtTestPage extends InMemoryPage -{ - public static string $outputExtension = '.txt'; -} - class DiscoverableNonHtmlTestPage extends HydePage { public static string $sourceDirectory = '_leaves'; diff --git a/packages/framework/tests/Feature/RedirectTest.php b/packages/framework/tests/Feature/RedirectTest.php index 46b8a67c7d7..ee051044bb8 100644 --- a/packages/framework/tests/Feature/RedirectTest.php +++ b/packages/framework/tests/Feature/RedirectTest.php @@ -64,12 +64,12 @@ public function testConfiguredRedirectsAreRegisteredWithTheKernelAndBuiltWithThe Filesystem::unlink('_site/foo.html'); } - public function testDottedRedirectPathUsesHtmlPageSemantics() + public function testDottedRedirectPathKeepsItsExtension() { $redirect = new Redirect('legacy.json', 'new-location'); $this->assertSame('legacy.json', $redirect->getRouteKey()); - $this->assertSame('legacy.json.html', $redirect->getOutputPath()); + $this->assertSame('legacy.json', $redirect->getOutputPath()); } public function testRedirectsCannotWriteOutsideTheBuildPipeline() diff --git a/packages/framework/tests/Feature/RobotsTxtPageTest.php b/packages/framework/tests/Feature/RobotsTxtPageTest.php index 7198273048a..3cde1aa6da8 100644 --- a/packages/framework/tests/Feature/RobotsTxtPageTest.php +++ b/packages/framework/tests/Feature/RobotsTxtPageTest.php @@ -128,10 +128,7 @@ public function testRobotsTxtRouteIsIncludedInTheRouteList() public function testUserPageRegisteredInBootingCallbackSuppressesTheGeneratedRobotsTxtPage() { Hyde::kernel()->booting(function (HydeKernel $kernel): void { - $kernel->pages()->addPage(new class('robots.txt', contents: 'user defined robots') extends InMemoryPage - { - public static string $outputExtension = '.txt'; - }); + $kernel->pages()->addPage(InMemoryPage::make('robots.txt', contents: 'user defined robots')); }); $page = Routes::get('robots.txt')->getPage(); @@ -163,9 +160,6 @@ class RobotsTxtPageTestExtension extends HydeExtension { public function discoverPages(PageCollection $collection): void { - $collection->addPage(new class('robots.txt', contents: 'extension defined robots') extends InMemoryPage - { - public static string $outputExtension = '.txt'; - }); + $collection->addPage(InMemoryPage::make('robots.txt', contents: 'extension defined robots')); } } diff --git a/packages/framework/tests/Feature/RssFeedPageTest.php b/packages/framework/tests/Feature/RssFeedPageTest.php index 817ee965fb4..f72764c2774 100644 --- a/packages/framework/tests/Feature/RssFeedPageTest.php +++ b/packages/framework/tests/Feature/RssFeedPageTest.php @@ -165,10 +165,7 @@ public function testFeedRouteIsIncludedInTheRouteList() public function testUserPageRegisteredInBootingCallbackSuppressesTheGeneratedFeedPage() { Hyde::kernel()->booting(function (HydeKernel $kernel): void { - $kernel->pages()->addPage(new class('feed.xml', contents: 'user defined feed') extends InMemoryPage - { - public static string $outputExtension = '.xml'; - }); + $kernel->pages()->addPage(InMemoryPage::make('feed.xml', contents: 'user defined feed')); }); $page = Routes::get('feed.xml')->getPage(); @@ -200,9 +197,6 @@ class RssFeedPageTestExtension extends HydeExtension { public function discoverPages(PageCollection $collection): void { - $collection->addPage(new class('feed.xml', contents: 'extension defined feed') extends InMemoryPage - { - public static string $outputExtension = '.xml'; - }); + $collection->addPage(InMemoryPage::make('feed.xml', contents: 'extension defined feed')); } } diff --git a/packages/framework/tests/Feature/Services/SitemapServiceTest.php b/packages/framework/tests/Feature/Services/SitemapServiceTest.php index b52951d4fa2..8c02c5afe1d 100644 --- a/packages/framework/tests/Feature/Services/SitemapServiceTest.php +++ b/packages/framework/tests/Feature/Services/SitemapServiceTest.php @@ -135,10 +135,7 @@ public function testGenerateDoesNotAddPagesWithSitemapFrontMatterSetToQuotedFals public function testGenerateDoesNotAddPagesWithNonHtmlOutputPaths() { - Routes::addRoute(new Route(new class('robots.txt') extends InMemoryPage - { - public static string $outputExtension = '.txt'; - })); + Routes::addRoute(new Route(InMemoryPage::make('robots.txt'))); $service = new SitemapGenerator(); $service->generate(); @@ -149,10 +146,7 @@ public function testGenerateDoesNotAddPagesWithNonHtmlOutputPaths() public function testGenerateAddsNonHtmlPagesWithSitemapFrontMatterSetToTrue() { - Routes::addRoute(new Route(new class('robots.txt', ['sitemap' => true]) extends InMemoryPage - { - public static string $outputExtension = '.txt'; - })); + Routes::addRoute(new Route(InMemoryPage::make('robots.txt', ['sitemap' => true]))); $service = new SitemapGenerator(); $service->generate(); diff --git a/packages/framework/tests/Feature/SitemapPageTest.php b/packages/framework/tests/Feature/SitemapPageTest.php index 24b5b2272d1..1e264f9585d 100644 --- a/packages/framework/tests/Feature/SitemapPageTest.php +++ b/packages/framework/tests/Feature/SitemapPageTest.php @@ -142,10 +142,7 @@ public function testUserPageRegisteredInBootingCallbackSuppressesTheGeneratedSit $this->withSiteUrl(); Hyde::kernel()->booting(function (HydeKernel $kernel): void { - $kernel->pages()->addPage(new class('sitemap.xml', contents: 'user defined sitemap') extends InMemoryPage - { - public static string $outputExtension = '.xml'; - }); + $kernel->pages()->addPage(InMemoryPage::make('sitemap.xml', contents: 'user defined sitemap')); }); $page = Routes::get('sitemap.xml')->getPage(); @@ -179,9 +176,6 @@ class SitemapPageTestExtension extends HydeExtension { public function discoverPages(PageCollection $collection): void { - $collection->addPage(new class('sitemap.xml', contents: 'extension defined sitemap') extends InMemoryPage - { - public static string $outputExtension = '.xml'; - }); + $collection->addPage(InMemoryPage::make('sitemap.xml', contents: 'extension defined sitemap')); } } diff --git a/packages/framework/tests/Unit/Pages/InMemoryPageTest.php b/packages/framework/tests/Unit/Pages/InMemoryPageTest.php index 7b5f41cf8f9..d27fd1f8578 100644 --- a/packages/framework/tests/Unit/Pages/InMemoryPageTest.php +++ b/packages/framework/tests/Unit/Pages/InMemoryPageTest.php @@ -398,21 +398,22 @@ public function getContents(): string $this->assertSame(0, $page->invocations); } - public function testOutputPathUsesNormalHtmlPageSemantics() + public function testOutputPathInfersFormatFromIdentifier() { $this->assertSame('foo.html', InMemoryPage::outputPath('foo')); - $this->assertSame('robots.txt.html', InMemoryPage::outputPath('robots.txt')); - $this->assertSame('data.json.html', InMemoryPage::outputPath('data.json')); - $this->assertSame('sitemap.xml.html', InMemoryPage::outputPath('sitemap.xml')); - $this->assertSame('docs/search.json.html', InMemoryPage::outputPath('docs/search.json')); - $this->assertSame('foo.md.html', InMemoryPage::outputPath('foo.md')); - $this->assertSame('foo.html.html', InMemoryPage::outputPath('foo.html')); - $this->assertSame('docs/1.x.html', InMemoryPage::outputPath('docs/1.x')); + $this->assertSame('robots.txt', InMemoryPage::outputPath('robots.txt')); + $this->assertSame('data.json', InMemoryPage::outputPath('data.json')); + $this->assertSame('sitemap.xml', InMemoryPage::outputPath('sitemap.xml')); + $this->assertSame('docs/search.json', InMemoryPage::outputPath('docs/search.json')); + $this->assertSame('foo.md', InMemoryPage::outputPath('foo.md')); + $this->assertSame('foo.html', InMemoryPage::outputPath('foo.html')); + $this->assertSame('docs/1.x', InMemoryPage::outputPath('docs/1.x')); + $this->assertSame('docs/1.x/index.html', InMemoryPage::outputPath('docs/1.x/index')); } public function testStaticAndInstanceOutputPathsUseTheSameSemantics() { - foreach (['foo', 'robots.txt', 'docs/search.json'] as $identifier) { + foreach (['foo', 'robots.txt', 'docs/search.json', 'docs/1.x/index'] as $identifier) { $this->assertSame( InMemoryPage::outputPath($identifier), (new InMemoryPage($identifier))->getOutputPath() From 4d56d2ea083147b9c5663265cf2abdb0100ea719 Mon Sep 17 00:00:00 2001 From: Emma De Silva Date: Tue, 14 Jul 2026 23:06:42 +0200 Subject: [PATCH 096/117] Document inferred in-memory output formats --- EPIC_NON_HTML_PAGES.md | 91 +++++++++++------------ HYDEPHP_V3_PLANNING.md | 4 +- UPGRADE.md | 24 +++--- docs/advanced-features/hyde-pages.md | 10 +-- docs/advanced-features/in-memory-pages.md | 17 ++--- 5 files changed, 65 insertions(+), 81 deletions(-) diff --git a/EPIC_NON_HTML_PAGES.md b/EPIC_NON_HTML_PAGES.md index e5878be2c1d..6097e3dca58 100644 --- a/EPIC_NON_HTML_PAGES.md +++ b/EPIC_NON_HTML_PAGES.md @@ -76,36 +76,34 @@ and `docs/search.json` (index) coexist as distinct routes, which they already do > D1-compliant out of the box and PR 2 can rely on "route key == request path with > only `.html` stripped" universally. -### D2: Output extensions are declared by the page class, not inferred - -Do **not** infer "this identifier has an extension" from a dot in the identifier — -versioned docs route keys like `docs/1.x/index` would false-positive -(`pathinfo('docs/1.x')['extension'] === 'x'`). Instead, output intent is explicit: - -- `HydePage` gets `public static string $outputExtension = '.html'`, and both route-key - and output-path resolution use it. -- File-discovered and in-memory custom page classes declare another extension by - redeclaring that static property, mirroring the existing source path properties. -- A plain `InMemoryPage` always uses its class's HTML semantics. Dots in an identifier - carry no output-format meaning. - -> **Final design decision (before PR 8): output behavior remains class-level.** Two -> instance-level designs were implemented during development and rejected before -> release. The first inferred non-HTML behavior from an allowlist of identifier -> suffixes; it merely moved the trap because `.txt` worked while `.webmanifest` did -> not. The second added `InMemoryPage::file()` and an `$exactOutputPath` instance flag. -> It supported arbitrary paths, but broke the `HydePage` static/instance contract: -> `InMemoryPage::outputPath('robots.txt')` returned `robots.txt.html` while the file -> instance's `getOutputPath()` returned `robots.txt`. Compiler integrations are -> allowed to resolve paths statically, so the disagreement was unsafe. -> -> Both designs were removed. Non-HTML behavior is represented by a page subclass with -> a static `$outputExtension`, keeping `Page::outputPath($identifier)` equal to the -> corresponding instance's `getOutputPath()`. First-party generated pages follow the -> same rule. `RssFeedPage` is the narrow exception because its configured filename may -> use any extension or none; it overrides the static `outputPath()` method, so its -> static and instance resolution still agree. Redirects and plain `InMemoryPage` -> instances retain HTML semantics even when their identifiers contain dots. +### D2: In-memory output formats are inferred from the identifier + +`InMemoryPage::outputPath()` uses `pathinfo($identifier, PATHINFO_EXTENSION)` directly. +When the result is empty it appends `.html`; otherwise it keeps the identifier unchanged. +The inherited instance `getOutputPath()` calls this same static method, so static and +instance path resolution cannot disagree. + +This final design followed three rejected approaches: + +1. **Extension allowlist inference.** Treating a fixed set such as `.txt`, `.xml`, and + `.json` as files made those formats convenient but merely moved the ambiguity. An + identifier ending in `.webmanifest` or any future format unexpectedly became HTML. +2. **`InMemoryPage::file()` plus `$exactOutputPath`.** The explicit instance flag + supported arbitrary filenames, but violated the static/instance path contract: + `InMemoryPage::outputPath('robots.txt')` produced `robots.txt.html` while the flagged + instance's `getOutputPath()` produced `robots.txt`. Compiler integrations may resolve + output paths statically, so that mismatch was unsafe. +3. **`$outputExtension` subclasses.** A class-level extension restored static/instance + symmetry, but the developer experience was poor: every one-off file required a + boilerplate subclass, callers had to omit the extension from the identifier, and a + configurable RSS filename still needed its own path override. + +Pure `pathinfo` inference keeps arbitrary extensions, needs no flag or subclass, and +preserves symmetry. The earlier concern that versioned documentation paths would be +false positives was based on a misunderstanding: `pathinfo` examines the basename, so +`docs/1.x/index` has no extension and correctly becomes `docs/1.x/index.html`. While +`docs/1.x` itself has extension `x`, that path is not a valid route key for the version's +root index; the valid identifier ends in `index`. ### D3: Sitemap inclusion becomes a page-level concern @@ -249,9 +247,9 @@ container → fully custom page in code. First-class non-HTML support is about a page's output path and participation in the route/build/serve lifecycle; it does not require a dedicated source-backed page class -for each file extension. A small `InMemoryPage` subclass declaring `$outputExtension` -provides the full lifecycle integration and is a better fit for dynamic content, while -the generated robots and llms pages cover the common cases without source files. +for each file extension. A plain `InMemoryPage` whose identifier includes the desired +extension provides the full lifecycle integration and is a better fit for dynamic +content, while the generated robots and llms pages cover the common cases without source files. A core `TextPage` would add only the convenience of autodiscovering `_pages/*.txt`, while creating pressure for parallel `XmlPage`, `JsonPage`, and similar classes. @@ -271,8 +269,8 @@ Goal: any page class can emit a non-`.html` file without overriding `getOutputPa - Add `$outputExtension` (default `'.html'`) to `HydePage`; use it in `outputPath()` (`HydePage.php:211-214`). - Route keys follow D1; audit `RouteKey` and `Route` for assumptions. -- Keep `InMemoryPage` on the same class-level output path contract as other pages. -- Refactor `DocumentationSearchIndex` to declare its `.json` output extension. +- Override `InMemoryPage::outputPath()` to infer the output suffix from the identifier. +- Keep `DocumentationSearchIndex`'s `.json` extension in its identifier. - Pure refactor for existing sites: no compiled-output changes. Implementation notes (branch `v3/non-html-pages-foundation`): @@ -292,9 +290,11 @@ Implementation notes (branch `v3/non-html-pages-foundation`): "Upgrade script rules" for the release-time Rector script. - Page-class output extension handling was placed in `RouteKey::fromPage()` (see D1 note) rather than only in `outputPath()`, so route keys and output paths cannot drift. -- **Revised before PR 8:** both identifier-suffix inference and the later - instance-level exact-path factory were removed (see D2). Output behavior is - class-level for discovered and in-memory pages alike. +- **Revised before PR 8, then finalized in the subsequent design pivot:** the + allowlist-based inference and later instance-level exact-path factory were removed. + A class-level extension approach briefly replaced them, but was also rejected for + in-memory pages due to its boilerplate and poor call-site ergonomics. The final + implementation uses unrestricted `pathinfo` inference (see D2). - Sitemap / non-HTML detection reads the resolved output path, not just the static extension declaration, so specialized static path overrides remain supported. @@ -363,9 +363,9 @@ Implementation notes (branch `v3/non-html-pages-sitemap-inclusion-policy`): - Implemented exactly per D3 (see the D3 "Implemented" note for the front matter semantics and the `Redirect` refinement). `showInSitemap()` joined the `BaseHydePageUnitTest` contract. -- The non-HTML self-exclusion is verified end-to-end: a registered `.txt` - `InMemoryPage` subclass is built by the real `build` command and asserted absent from - the built `sitemap.xml`, guarding the D3 resolved-output-path constraint +- The non-HTML self-exclusion is verified end-to-end: an `InMemoryPage` with a `.txt` + identifier is built by the real `build` command and asserted absent from the built + `sitemap.xml`, guarding the D3 resolved-output-path constraint against regression by construction rather than only at the unit level. - Two existing tests asserted the leak as expected behavior and were flipped: `SitemapServiceTest` now asserts the docs search *page* stays while the search @@ -397,8 +397,8 @@ Goal: `sitemap.xml` and `feed.xml` are routes — served by `hyde serve`, listed bound). D4's whole swappability tier is a lie if `app(SitemapGenerator::class)` can't be rebound. Add a test that rebinds the generator and asserts the page's compiled output changes. -- Ensure first-party generated page classes declare their output format per D2; - preserve the configurable RSS filename through a class-level path override. +- Ensure first-party generated page identifiers include their output extension per D2; + the configurable RSS filename then uses the shared inference without an override. - Register in `HydeCoreExtension::discoverPages()` behind `Features::hasSitemap()` / `Features::hasRss()`; remove `GenerateSitemap`/`GenerateRssFeed` from `BuildTaskService::registerFrameworkTasks()` (evaluate deprecation vs. removal — @@ -466,9 +466,8 @@ Implementation notes, part B (branch `v3/non-html-pages-convert-rss-feed`): `Features::hasRss()` with the D5 skip check, hidden from navigation, D3-excluded from the sitemap, and both user override paths verified end-to-end. - One divergence: the route key comes from `RssFeedGenerator::getFilename()` - (config `hyde.rss.filename`). Since the removed task wrote any configured filename - verbatim, `RssFeedPage` overrides static `outputPath()` — `feed.rss` and an - extensionless name therefore work while static and instance resolution still agree. + (config `hyde.rss.filename`). The shared `InMemoryPage` inference keeps configured + filenames with extensions verbatim and gives extensionless identifiers HTML output. - `build:rss` builds the registered route's page like `build:sitemap`, and fails with the generic "feature is not enabled" error when the route is not registered (see the revised-in-review notes in the part A section — the old task's no-guard diff --git a/HYDEPHP_V3_PLANNING.md b/HYDEPHP_V3_PLANNING.md index 73e16ffc2fd..89e8056679b 100644 --- a/HYDEPHP_V3_PLANNING.md +++ b/HYDEPHP_V3_PLANNING.md @@ -24,7 +24,7 @@ Having this document in code lets us know the devlopment state at any given poin - 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)) - Added built-in terminal code blocks using the `terminal` fence language. Command prompts are styled for selection-free copying, and `terminal xml` supports four Symfony-style Console formatter tags. ([#2188](https://github.com/hydephp/develop/issues/2188), [#2485](https://github.com/hydephp/develop/issues/2485)) -- Custom page classes can compile to non-HTML files by setting the new static `$outputExtension` property (defaulting to `.html`). Only the HTML extension is implicit in route keys: pages compiled to non-HTML files keep their extension in the route key, formalizing the convention already used by the documentation search index. +- `InMemoryPage` now infers its output format from the identifier: identifiers with an extension keep it, while identifiers without one compile to `.html`. Only the HTML extension is implicit in route keys, so non-HTML paths such as `robots.txt` and `docs/search.json` remain route keys as-is. - Pages can now control their own sitemap inclusion. Set `sitemap: false` in a page's front matter to exclude it from the generated `sitemap.xml`, or override the new `HydePage::showInSitemap()` method in custom page classes. Pages compiled to non-HTML output files (like `robots.txt`) are excluded by default, and `sitemap: true` front matter opts such a page back in. - The sitemap and RSS feed are now first-class pages instead of post-build side effects: when the respective feature is enabled, `sitemap.xml` and the RSS feed (`feed.xml`, or the configured `hyde.rss.filename`) are registered as routes, so they are served by `hyde serve`, listed in `route:list`, included in the build manifest, and compiled through the standard site build. The output can be customized by rebinding the `SitemapGenerator` or `RssFeedGenerator` class in the service container, and registering a user-defined page with the same route key (from a service provider, booting callback, or extension) replaces the generated page entirely. - Hyde now generates a `robots.txt` file for the site out of the box. The default output allows all crawlers, and links to the sitemap when that feature is enabled. Rule values listed in the new `hyde.robots.disallow` configuration array are written verbatim as `Disallow` rules (so wildcard patterns are supported), and the file can be disabled entirely with `hyde.robots.enabled`. The page is wired like the sitemap and RSS feed: it is a real route (served by `hyde serve`, listed in `route:list`, included in the build manifest), the output can be customized by rebinding the `RobotsTxtGenerator` class in the service container, and a user-defined `robots.txt` page replaces the generated one entirely. @@ -51,7 +51,7 @@ Having this document in code lets us know the devlopment state at any given poin ### Breaking Changes -- Renamed the static page class property `$fileExtension` to `$sourceExtension`, and the `fileExtension()` and `setFileExtension()` methods to `sourceExtension()` and `setSourceExtension()`. The old name was ambiguous now that page classes also declare an output extension through the new `$outputExtension` property, and the renamed pair makes the source/output distinction explicit. 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). Page discovery fails fast with an actionable exception for registered page classes that still use the old API, instead of silently skipping them during builds. +- 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). Page discovery fails fast with an actionable exception for registered page classes that still use the old API, instead of silently skipping them during builds. - 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 register a custom `sitemap.xml` page or rebind `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 register a custom page with the configured feed route key or rebind `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. A user-defined page registered under the feed route key is still built even when the feature conditions are not met. - Removed `Redirect::create()`, `Redirect::store()`, and the `Redirect` constructor's `showText` argument. Redirects must now be declared in `hyde.redirects`, keeping all generated output inside the kernel-owned build graph. Redirect routes are intrinsically excluded from navigation menus and sitemaps, and always include an accessible fallback link. diff --git a/UPGRADE.md b/UPGRADE.md index aa1881d8fad..35837e0f945 100644 --- a/UPGRADE.md +++ b/UPGRADE.md @@ -2,16 +2,16 @@ ## Overview -HydePHP v3 lets custom page classes declare their output extension. `InMemoryPage` generates HTML by default; extend it -and set the static `$outputExtension` property when creating an in-memory page for another format: +HydePHP v3 infers an `InMemoryPage` output format from its identifier. Identifiers that already have an extension keep +it, while identifiers without an extension compile to `.html`: ```php -class TextFilePage extends InMemoryPage -{ - public static string $outputExtension = '.txt'; -} +use Hyde\Pages\InMemoryPage; + +InMemoryPage::make('about', contents: $html); +// _site/about.html -TextFilePage::make('robots', contents: $text); +InMemoryPage::make('robots.txt', contents: $text); // _site/robots.txt ``` @@ -341,13 +341,8 @@ The same works for `RssFeedGenerator`. use Hyde\Hyde; use Hyde\Pages\InMemoryPage; -class CustomSitemapPage extends InMemoryPage -{ - public static string $outputExtension = '.xml'; -} - Hyde::kernel()->booting(function ($kernel) use ($myXml): void { - $kernel->pages()->addPage(new CustomSitemapPage('sitemap', contents: $myXml)); + $kernel->pages()->addPage(InMemoryPage::make('sitemap.xml', contents: $myXml)); }); ``` @@ -393,8 +388,7 @@ format by registering your own page. The static page class property `$fileExtension` has been renamed to `$sourceExtension`, along with the `fileExtension()` and `setFileExtension()` methods, which are now `sourceExtension()` and `setSourceExtension()`. -The rename pairs the source extension with the new `$outputExtension` property (defaulting to `.html`), which -page classes can override to compile to non-HTML output files. +The new name makes it explicit that these APIs describe the extension of source files. This only affects projects with custom page classes or code calling these APIs. Update property declarations, call sites, and any methods that override `fileExtension()` or `setFileExtension()` — the methods are public diff --git a/docs/advanced-features/hyde-pages.md b/docs/advanced-features/hyde-pages.md index 2abfaf5a759..e0ed038aaba 100644 --- a/docs/advanced-features/hyde-pages.md +++ b/docs/advanced-features/hyde-pages.md @@ -161,15 +161,11 @@ autodiscovery, you may benefit from creating a custom page class instead, as tha You can learn more about the InMemoryPage class in the [InMemoryPage documentation](in-memory-pages). -In-memory pages generate HTML by default. Custom subclasses can generate another format by declaring their output extension: +In-memory pages infer their output format from the identifier. Identifiers without an extension compile to `.html`, +while identifiers that already have an extension keep it: ```php -class TextFilePage extends InMemoryPage -{ - public static string $outputExtension = '.txt'; -} - -TextFilePage::make('robots', contents: $text); +InMemoryPage::make('robots.txt', contents: $text); // _site/robots.txt ``` diff --git a/docs/advanced-features/in-memory-pages.md b/docs/advanced-features/in-memory-pages.md index 81424237c60..7ec7ed617a5 100644 --- a/docs/advanced-features/in-memory-pages.md +++ b/docs/advanced-features/in-memory-pages.md @@ -40,26 +40,21 @@ InMemoryPage::make('about', contents: $html); // _site/about.html ``` -In-memory pages generate HTML by default. To generate another format, extend `InMemoryPage` and declare the output -extension on the page class: +The output format is inferred from the identifier. Identifiers without an extension compile to `.html`, while an +identifier that already has an extension keeps it: ```php -class TextFilePage extends InMemoryPage -{ - public static string $outputExtension = '.txt'; -} - -TextFilePage::make('robots', contents: $text); +InMemoryPage::make('robots.txt', contents: $text); // _site/robots.txt ``` -The output extension belongs to the page type, ensuring static and instance path resolution use the same rules: +Static and instance path resolution use the same inference: ```php -TextFilePage::outputPath('robots'); +InMemoryPage::outputPath('robots.txt'); // robots.txt -TextFilePage::make('robots')->getOutputPath(); +InMemoryPage::make('robots.txt')->getOutputPath(); // robots.txt ``` From b922e03276526c5112c5a47d2ea558c37e046b3a Mon Sep 17 00:00:00 2001 From: Emma De Silva Date: Tue, 14 Jul 2026 23:20:17 +0200 Subject: [PATCH 097/117] Build documentation --- .../hyde-pages-api/in-memory-page-methods.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/docs/_data/partials/hyde-pages-api/in-memory-page-methods.md b/docs/_data/partials/hyde-pages-api/in-memory-page-methods.md index b4842f630d2..8e67a276236 100644 --- a/docs/_data/partials/hyde-pages-api/in-memory-page-methods.md +++ b/docs/_data/partials/hyde-pages-api/in-memory-page-methods.md @@ -12,6 +12,16 @@ Static alias for the constructor. InMemoryPage::make(string|(Closure(): string)|(Closure(static):, Hyde\Markdown\Models\FrontMatter|array $matter, Closure|string|null $contents, string $view): static ``` +#### `outputPath()` + +Qualify a page identifier into a target output file path. + +Identifiers with a file extension are used verbatim, while identifiers without an extension are compiled to HTML files. + +```php +InMemoryPage::outputPath(string $identifier): string +``` + #### `__construct()` Create a new in-memory (virtual) page instance. @@ -22,6 +32,8 @@ Contents and views cannot be used together. Omit both to create an empty page. A View values ending in `.blade.php` are treated as Blade file paths. Other values are treated as registered Laravel view keys. +Identifiers that already have an extension use it as the output path unchanged. + ```php /** @param string|(Closure(): string)|(Closure(static): string)|null $contents */ $page = new InMemoryPage(string $identifier, FrontMatter|array $matter, string|(Closure(): string)|(Closure(static):, string|null $view): void From 1f1d2baf7a8fb2738b405fe8c148741dbc129988 Mon Sep 17 00:00:00 2001 From: Emma De Silva Date: Tue, 14 Jul 2026 23:40:58 +0200 Subject: [PATCH 098/117] Remove unnecessary code comments --- .../Features/TextGenerators/LlmsTxtGenerator.php | 16 ---------------- 1 file changed, 16 deletions(-) diff --git a/packages/framework/src/Framework/Features/TextGenerators/LlmsTxtGenerator.php b/packages/framework/src/Framework/Features/TextGenerators/LlmsTxtGenerator.php index 4a49ce4e9ca..96fb4fc3e8e 100644 --- a/packages/framework/src/Framework/Features/TextGenerators/LlmsTxtGenerator.php +++ b/packages/framework/src/Framework/Features/TextGenerators/LlmsTxtGenerator.php @@ -99,11 +99,6 @@ protected function getLines(): array return $lines; } - /** - * Group the site's listed routes into their sections, discarding the empty ones. - * - * @return array> - */ protected function getSections(): array { $sections = $this->sections(); @@ -129,10 +124,6 @@ protected function getSections(): array return array_filter($grouped); } - /** - * Pages are listed when they are included in the sitemap, apart from error pages, - * which are never listed as they are not content. - */ protected function shouldListPage(HydePage $page): bool { return $page->showInSitemap() && $page->getIdentifier() !== '404'; @@ -160,18 +151,11 @@ protected function getPageDescription(HydePage $page): ?string return $this->normalizeText($description); } - /** - * Escape the characters that would otherwise terminate the Markdown link label, - * so that a title like "Arrays [Advanced]" does not emit a malformed link. - */ protected function escapeLinkLabel(string $label): string { return addcslashes($this->normalizeText($label), '[]\\'); } - /** - * Collapse whitespace so that multi-line front matter cannot break the line-based file format. - */ protected function normalizeText(string $text): string { return preg_replace('/\s+/', ' ', trim($text)); From 847ecb782f0a93ca751ae8231697620f92a11db9 Mon Sep 17 00:00:00 2001 From: Emma De Silva Date: Wed, 15 Jul 2026 00:02:19 +0200 Subject: [PATCH 099/117] Remove overengineered subclasses --- EPIC_NON_HTML_PAGES.md | 82 ++++++++----------- .../Console/Commands/BuildRssFeedCommand.php | 4 +- .../Console/Commands/BuildSitemapCommand.php | 3 +- .../src/Foundation/HydeCoreExtension.php | 40 ++++++--- .../TextGenerators/LlmsTxtGenerator.php | 1 - .../Features/TextGenerators/LlmsTxtPage.php | 39 --------- .../TextGenerators/RobotsTxtGenerator.php | 5 +- .../Features/TextGenerators/RobotsTxtPage.php | 39 --------- .../Features/XmlGenerators/RssFeedPage.php | 39 --------- .../Features/XmlGenerators/SitemapPage.php | 39 --------- .../Commands/BuildRssFeedCommandTest.php | 1 - .../Commands/BuildSitemapCommandTest.php | 1 - .../tests/Feature/LlmsTxtPageTest.php | 12 ++- .../tests/Feature/RobotsTxtPageTest.php | 12 ++- .../tests/Feature/RssFeedPageTest.php | 14 ++-- .../tests/Feature/SitemapFeatureTest.php | 1 - .../tests/Feature/SitemapPageTest.php | 14 ++-- 17 files changed, 87 insertions(+), 259 deletions(-) delete mode 100644 packages/framework/src/Framework/Features/TextGenerators/LlmsTxtPage.php delete mode 100644 packages/framework/src/Framework/Features/TextGenerators/RobotsTxtPage.php delete mode 100644 packages/framework/src/Framework/Features/XmlGenerators/RssFeedPage.php delete mode 100644 packages/framework/src/Framework/Features/XmlGenerators/SitemapPage.php diff --git a/EPIC_NON_HTML_PAGES.md b/EPIC_NON_HTML_PAGES.md index 6097e3dca58..c4aa9afd80f 100644 --- a/EPIC_NON_HTML_PAGES.md +++ b/EPIC_NON_HTML_PAGES.md @@ -117,8 +117,8 @@ a standalone feature in its own right. > **Implementation constraint (from PR 1) — read before writing `showInSitemap()`:** > the "output is not `.html`" default MUST be derived from the page's *resolved > output path* (`getOutputPath()`), not merely from the declared extension. The -> resolved path is the canonical answer and also covers specialized classes such as -> `RssFeedPage`, whose configurable filename cannot be represented by one fixed +> resolved path is the canonical answer and also covers generated pages such as +> the RSS feed, whose configurable filename cannot be represented by one fixed > extension. Keying the default off `getOutputPath()` makes all generated pages > self-exclude correctly and prevents the `search.json` leak from returning. @@ -188,30 +188,17 @@ Registration happens in `HydeCoreExtension::discoverPages()` behind the existing `Features::hasSitemap()` / `Features::hasRss()` conditions, replacing the registrations in `BuildTaskService::registerFrameworkTasks()`. -**Plain page vs. thin subclass is deferred to PR 5.** D3 already defaults non-HTML -pages out of the sitemap, so a subclass is *not* needed merely to stop a generated -page self-listing. The only remaining reasons to introduce `SitemapPage extends -InMemoryPage` (mirroring `DocumentationSearchIndex`) are type identity (`instanceof`) -and giving the `build:sitemap` / `build:rss` commands a concrete class to -instantiate. Lean toward a plain `InMemoryPage` registered with a container-bound -`compile` closure unless PR 5 surfaces a concrete need for the subclass. - -> **Decided (PR 5 part A): thin subclass.** The command wiring is the concrete need -> the deferral anticipated: `build:sitemap` builds the registered route's page and -> must fall back to a fresh instance when the route is not registered (mirroring how -> `BuildSearchCommand` falls back to `new DocumentationSearchIndex()`), and a plain -> page would need that construction logic exported from a shared factory anyway. -> `SitemapPage extends InMemoryPage` lives next to its generator in -> `Hyde\Framework\Features\XmlGenerators`, keeps the construction in one place, and -> stays out of `Hyde\Pages` so it is exempt from the discovered-page unit test -> contract, exactly like `DocumentationSearchIndex`. The D4 swappability tier is -> unaffected: `compile()` resolves `SitemapGenerator` from the container, verified -> by a rebind test. Part B should mirror this with `RssFeedPage`. +**Decided: plain `InMemoryPage`.** D3 already defaults non-HTML pages out of the +sitemap, and the commands only build registered routes. The temporary thin subclasses +were justified by an early command fallback that instantiated fresh pages, but that +fallback was removed in review. With no type-identity consumer remaining, generated +pages are ordinary `InMemoryPage` instances registered with container-resolved +`compile` macros. The D4 swappability tier is preserved and verified by rebind tests. ### D5: User-defined pages beat generators If the page collection already contains a user-defined page with a route key such -as `robots.txt`, the framework does not register its generated `RobotsTxtPage`. +as `robots.txt`, the framework does not register its generated page. This follows the pattern of `discoverDocumentationRootRedirect()`, which skips when a user-defined route exists. Users can register an `InMemoryPage` from a service provider or provide a custom page @@ -389,9 +376,8 @@ Goal: `sitemap.xml` and `feed.xml` are routes — served by `hyde serve`, listed (`app(SitemapGenerator::class)->generate()` / `app(RssFeedGenerator::class)->generate()`), so the implementation is swappable via container rebind. RSS route key comes from `RssFeedGenerator::getFilename()` (config `hyde.rss.filename`). -- **Decide plain `InMemoryPage` (compile macro) vs. thin subclass here** (D4). Default - to plain + container binding unless type identity or command wiring forces a - subclass; note that D3 already handles sitemap self-exclusion either way. +- Use plain `InMemoryPage` instances with container-bound `compile` macros (D4); + D3 already handles sitemap self-exclusion. - **Verify the generators are actually container-resolvable** before advertising the rebind: no unresolvable constructor dependencies, not `final` (or the swap can't be bound). D4's whole swappability tier is a lie if `app(SitemapGenerator::class)` @@ -404,8 +390,7 @@ Goal: `sitemap.xml` and `feed.xml` are routes — served by `hyde serve`, listed `BuildTaskService::registerFrameworkTasks()` (evaluate deprecation vs. removal — v3 allows breaking changes, but third-party code may reference the task classes). - Rewire `build:sitemap` / `build:rss` commands to build the same registered page - via `StaticPageBuilder::handle(...)` (a shared factory if the pages are plain - `InMemoryPage`s, or `new …Page()` if subclassed). + via `StaticPageBuilder::handle(...)`. - Verify `GlobalMetadataBag` head links and the `hyde.url` requirements still hold (`Features::hasSitemap()` already requires a site URL). - Nice side effect: build output shows them under "Dynamic Pages" with the standard @@ -413,10 +398,9 @@ Goal: `sitemap.xml` and `feed.xml` are routes — served by `hyde serve`, listed Implementation notes, part A (branch `v3/non-html-pages-convert-sitemap`): -- `SitemapPage extends InMemoryPage` per the D4 "thin subclass" decision (see the D4 - note for the rationale), hiding itself from navigation like - `DocumentationSearchIndex` and self-excluding from the sitemap via the D3 non-HTML - default. Registered at the end of `HydeCoreExtension::discoverPages()` behind +- A plain `InMemoryPage` with a container-resolved `compile` macro is hidden from + navigation and self-excludes from the sitemap via the D3 non-HTML default. + Registered at the end of `HydeCoreExtension::discoverPages()` behind `Features::hasSitemap()` with the D5 skip check (see the D5 note for the verified override ordering semantics). - `SitemapGenerator` verified container-resolvable and rebindable: not `final`, no @@ -454,15 +438,15 @@ Implementation notes, part A (branch `v3/non-html-pages-convert-sitemap`): `Features::hasSitemap()` condition that registers the page — no drift possible. - Realtime compiler needed no changes (PR 2's route-first resolution); a serve test asserts `sitemap.xml` returns the generated XML with `application/xml`. -- Heads-up for part B: `BuildTaskServiceUnitTest`'s framework-task fixtures were +- For part B, `BuildTaskServiceUnitTest`'s framework-task fixtures were migrated from `GenerateSitemap` to `GenerateBuildManifest` (not `GenerateRssFeed`) - so removing the RSS task won't churn them again. Part B should mirror everything - here with `RssFeedPage`, taking its route key from `RssFeedGenerator::getFilename()`. + so removing the RSS task would not churn them again. The RSS route key comes from + `RssFeedGenerator::getFilename()`. Implementation notes, part B (branch `v3/non-html-pages-convert-rss-feed`): -- `RssFeedPage` mirrors `SitemapPage` throughout: thin subclass in `XmlGenerators`, - container-resolved `compile()` (rebind verified by test), registered behind +- The RSS feed mirrors the sitemap throughout: a plain `InMemoryPage` with a + container-resolved `compile` macro (rebind verified by test), registered behind `Features::hasRss()` with the D5 skip check, hidden from navigation, D3-excluded from the sitemap, and both user override paths verified end-to-end. - One divergence: the route key comes from `RssFeedGenerator::getFilename()` @@ -489,17 +473,15 @@ Goal: sensible robots.txt out of the box, zero config. Implementation notes (branch `v3/non-html-pages-robots`): -- `RobotsTxtPage extends InMemoryPage` mirrors `SitemapPage`/`RssFeedPage` throughout: - thin subclass, container-resolved generator in `compile()` (rebind verified by test), +- The generated robots.txt is a plain `InMemoryPage` with a container-resolved + `compile` macro (rebind verified by test), registered in `HydeCoreExtension::discoverPages()` with the D5 skip check, hidden from navigation, D3-excluded from the sitemap via the non-HTML default, and both user override paths (booting callback and extension) verified end-to-end through the real `build` command per the D5 mandate. -- The page and its `RobotsTxtGenerator` live in a new - `Hyde\Framework\Features\TextGenerators` namespace mirroring `XmlGenerators` - (and likewise outside `Hyde\Pages`, exempting the page from the discovered-page - unit test contract). PR 7's llms.txt generator and page should land there too — - as `LlmsTxtGenerator`/`LlmsTxtPage` for symmetry, superseding the epic's earlier +- `RobotsTxtGenerator` lives in the + `Hyde\Framework\Features\TextGenerators` namespace mirroring `XmlGenerators`. + PR 7's generator follows it as `LlmsTxtGenerator`, superseding the epic's earlier `GeneratesLlmsTxt` working name. - Feature gate: `Features::hasRobotsTxt()` reads only `hyde.robots.enabled` (default `true`). Unlike `hasSitemap()`/`hasRss()` there is no site URL @@ -523,7 +505,7 @@ Implementation notes (branch `v3/non-html-pages-robots`): index, instead of surfacing as a PHP-level type error at build time. Later generated text pages copying this pattern (llms.txt) should keep both halves — verbatim strings, explicit validation. -- `RobotsTxtPage` declares its `.txt` output extension per D2. +- Its `InMemoryPage` identifier includes the `.txt` output extension per D2. - No `build:robots` command: the sitemap/RSS commands exist only as carry-overs of the removed post-build tasks; robots.txt never had one, and the standard build and realtime compiler (serve test asserts `text/plain`) cover the lifecycle. @@ -547,10 +529,10 @@ Goal: best-in-class llms.txt support — no other SSG generates this well out of Implementation notes (branch `v3/non-html-pages-llms-txt`): -- `LlmsTxtPage` + `LlmsTxtGenerator` land in `Hyde\Framework\Features\TextGenerators` - next to the robots.txt pair (superseding the `GeneratesLlmsTxt` working name, as - PR 6 anticipated), and mirror `RobotsTxtPage` throughout: thin `InMemoryPage` - subclass, container-resolved generator in `compile()` (rebind verified by test), +- `LlmsTxtGenerator` lands in `Hyde\Framework\Features\TextGenerators` + next to the robots.txt generator (superseding the `GeneratesLlmsTxt` working name, + as PR 6 anticipated), and its plain `InMemoryPage` uses a container-resolved + `compile` macro (rebind verified by test), registered in `HydeCoreExtension::discoverPages()` with the D5 skip check, hidden from navigation, D3-excluded from the sitemap, and both user override paths verified end-to-end through the real `build` command. No `build:llms` command, for the same @@ -646,8 +628,8 @@ Implementation notes (branch `v3/non-html-pages-llms-txt`): generator-level curation concern rather than a page-level default (the sitemap precedent likewise keeps its 404 handling in the generator), and it is the reason the sitemap-derived inclusion rule is not a bare alias for `showInSitemap()`. -- Everything else the epic left implicit held: `LlmsTxtPage` declares its `.txt` - output extension, and the generated page self-excludes from its own listing (and the sitemap) +- Everything else the epic left implicit held: the `llms.txt` page identifier declares + its `.txt` output extension, and the generated page self-excludes from its own listing (and the sitemap) through the D3 resolved-output-path default. > **Scope correction (post-implementation review).** The first cut of this PR was diff --git a/packages/framework/src/Console/Commands/BuildRssFeedCommand.php b/packages/framework/src/Console/Commands/BuildRssFeedCommand.php index 85a9b820f3a..29cfda584f4 100644 --- a/packages/framework/src/Console/Commands/BuildRssFeedCommand.php +++ b/packages/framework/src/Console/Commands/BuildRssFeedCommand.php @@ -8,7 +8,7 @@ use Hyde\Console\Concerns\Command; use Hyde\Foundation\Facades\Routes; use Hyde\Framework\Actions\StaticPageBuilder; -use Hyde\Framework\Features\XmlGenerators\RssFeedPage; +use Hyde\Framework\Features\XmlGenerators\RssFeedGenerator; use function sprintf; @@ -25,7 +25,7 @@ class BuildRssFeedCommand extends Command public function handle(): int { - $page = Routes::find(RssFeedPage::routeKey())?->getPage(); + $page = Routes::find(RssFeedGenerator::getFilename())?->getPage(); if ($page === null) { $this->error('Cannot generate the RSS feed as the feature is not enabled'); diff --git a/packages/framework/src/Console/Commands/BuildSitemapCommand.php b/packages/framework/src/Console/Commands/BuildSitemapCommand.php index 6f503d210a9..b1c8abc6afc 100644 --- a/packages/framework/src/Console/Commands/BuildSitemapCommand.php +++ b/packages/framework/src/Console/Commands/BuildSitemapCommand.php @@ -8,7 +8,6 @@ use Hyde\Console\Concerns\Command; use Hyde\Foundation\Facades\Routes; use Hyde\Framework\Actions\StaticPageBuilder; -use Hyde\Framework\Features\XmlGenerators\SitemapPage; use function sprintf; @@ -25,7 +24,7 @@ class BuildSitemapCommand extends Command public function handle(): int { - $page = Routes::find(SitemapPage::routeKey())?->getPage(); + $page = Routes::find('sitemap.xml')?->getPage(); if ($page === null) { $this->error('Cannot generate the sitemap as the feature is not enabled'); diff --git a/packages/framework/src/Foundation/HydeCoreExtension.php b/packages/framework/src/Foundation/HydeCoreExtension.php index 85ab57ee089..d03aa4c8f55 100644 --- a/packages/framework/src/Foundation/HydeCoreExtension.php +++ b/packages/framework/src/Foundation/HydeCoreExtension.php @@ -10,6 +10,7 @@ use Hyde\Pages\MarkdownPage; use Hyde\Pages\MarkdownPost; use Hyde\Pages\DocumentationPage; +use Hyde\Pages\InMemoryPage; use Hyde\Pages\Concerns\HydePage; use Hyde\Support\BuildWarnings; use Hyde\Support\Models\Redirect; @@ -21,14 +22,15 @@ use Hyde\Facades\Config; use Hyde\Framework\Features\Documentation\DocumentationSearchPage; use Hyde\Framework\Features\Documentation\DocumentationSearchIndex; -use Hyde\Framework\Features\TextGenerators\LlmsTxtPage; -use Hyde\Framework\Features\TextGenerators\RobotsTxtPage; -use Hyde\Framework\Features\XmlGenerators\RssFeedPage; -use Hyde\Framework\Features\XmlGenerators\SitemapPage; +use Hyde\Framework\Features\TextGenerators\LlmsTxtGenerator; +use Hyde\Framework\Features\TextGenerators\RobotsTxtGenerator; +use Hyde\Framework\Features\XmlGenerators\RssFeedGenerator; +use Hyde\Framework\Features\XmlGenerators\SitemapGenerator; use Hyde\Framework\Features\Documentation\Versioning\DocumentationVersion; use Hyde\Framework\Features\Documentation\Versioning\DocumentationVersions; use function Hyde\unslash; +use function app; use function array_filter; use function array_keys; use function sprintf; @@ -105,32 +107,46 @@ public function discoverPages(PageCollection $collection): void /** Add the generated sitemap page unless the route is user-defined. */ protected function discoverSitemapPage(PageCollection $collection): void { - if (! $this->hasPageWithRouteKey($collection, SitemapPage::routeKey())) { - $collection->addPage(new SitemapPage()); + if (! $this->hasPageWithRouteKey($collection, 'sitemap.xml')) { + $page = new InMemoryPage('sitemap.xml', ['navigation' => ['hidden' => true]]); + $page->macro('compile', fn (): string => app(SitemapGenerator::class)->generate()->getXml()); + + $collection->addPage($page); } } /** Add the generated RSS feed page unless the route is user-defined. */ protected function discoverRssFeedPage(PageCollection $collection): void { - if (! $this->hasPageWithRouteKey($collection, RssFeedPage::routeKey())) { - $collection->addPage(new RssFeedPage()); + $routeKey = RssFeedGenerator::getFilename(); + + if (! $this->hasPageWithRouteKey($collection, $routeKey)) { + $page = new InMemoryPage($routeKey, ['navigation' => ['hidden' => true]]); + $page->macro('compile', fn (): string => app(RssFeedGenerator::class)->generate()->getXml()); + + $collection->addPage($page); } } /** Add the generated robots.txt page unless the route is user-defined. */ protected function discoverRobotsTxtPage(PageCollection $collection): void { - if (! $this->hasPageWithRouteKey($collection, RobotsTxtPage::routeKey())) { - $collection->addPage(new RobotsTxtPage()); + if (! $this->hasPageWithRouteKey($collection, 'robots.txt')) { + $page = new InMemoryPage('robots.txt', ['navigation' => ['hidden' => true]]); + $page->macro('compile', fn (): string => app(RobotsTxtGenerator::class)->generate()); + + $collection->addPage($page); } } /** Add the generated llms.txt page unless the route is user-defined. */ protected function discoverLlmsTxtPage(PageCollection $collection): void { - if (! $this->hasPageWithRouteKey($collection, LlmsTxtPage::routeKey())) { - $collection->addPage(new LlmsTxtPage()); + if (! $this->hasPageWithRouteKey($collection, 'llms.txt')) { + $page = new InMemoryPage('llms.txt', ['navigation' => ['hidden' => true]]); + $page->macro('compile', fn (): string => app(LlmsTxtGenerator::class)->generate()); + + $collection->addPage($page); } } diff --git a/packages/framework/src/Framework/Features/TextGenerators/LlmsTxtGenerator.php b/packages/framework/src/Framework/Features/TextGenerators/LlmsTxtGenerator.php index 96fb4fc3e8e..ffc70f80f16 100644 --- a/packages/framework/src/Framework/Features/TextGenerators/LlmsTxtGenerator.php +++ b/packages/framework/src/Framework/Features/TextGenerators/LlmsTxtGenerator.php @@ -46,7 +46,6 @@ * of the generated file may change in future minor and patch releases to follow the spec. * * @see https://llmstxt.org/ - * @see \Hyde\Framework\Features\TextGenerators\LlmsTxtPage */ class LlmsTxtGenerator { diff --git a/packages/framework/src/Framework/Features/TextGenerators/LlmsTxtPage.php b/packages/framework/src/Framework/Features/TextGenerators/LlmsTxtPage.php deleted file mode 100644 index 9d10ec260f9..00000000000 --- a/packages/framework/src/Framework/Features/TextGenerators/LlmsTxtPage.php +++ /dev/null @@ -1,39 +0,0 @@ - ['hidden' => true], - ]); - } - - public function compile(): string - { - return app(LlmsTxtGenerator::class)->generate(); - } - - /** - * Get the route key of the llms.txt file, which for this page is also its output path. - */ - public static function routeKey(): string - { - return 'llms.txt'; - } -} diff --git a/packages/framework/src/Framework/Features/TextGenerators/RobotsTxtGenerator.php b/packages/framework/src/Framework/Features/TextGenerators/RobotsTxtGenerator.php index fd73dcb512f..0cb769462ab 100644 --- a/packages/framework/src/Framework/Features/TextGenerators/RobotsTxtGenerator.php +++ b/packages/framework/src/Framework/Features/TextGenerators/RobotsTxtGenerator.php @@ -8,7 +8,6 @@ use Hyde\Facades\Config; use Hyde\Facades\Features; use Hyde\Framework\Exceptions\InvalidConfigurationException; -use Hyde\Framework\Features\XmlGenerators\SitemapPage; use function array_merge; use function get_debug_type; @@ -23,8 +22,6 @@ * is written verbatim as a Disallow rule, so wildcard patterns and the empty * rule are supported. A link to the sitemap is included when the sitemap * feature is enabled. - * - * @see \Hyde\Framework\Features\TextGenerators\RobotsTxtPage */ class RobotsTxtGenerator { @@ -39,7 +36,7 @@ protected function getLines(): array $lines = array_merge(['User-agent: *'], $this->getRuleLines()); if (Features::hasSitemap()) { - $lines = array_merge($lines, ['', 'Sitemap: '.Hyde::url(SitemapPage::routeKey())]); + $lines = array_merge($lines, ['', 'Sitemap: '.Hyde::url('sitemap.xml')]); } return $lines; diff --git a/packages/framework/src/Framework/Features/TextGenerators/RobotsTxtPage.php b/packages/framework/src/Framework/Features/TextGenerators/RobotsTxtPage.php deleted file mode 100644 index 62653a35f5c..00000000000 --- a/packages/framework/src/Framework/Features/TextGenerators/RobotsTxtPage.php +++ /dev/null @@ -1,39 +0,0 @@ - ['hidden' => true], - ]); - } - - public function compile(): string - { - return app(RobotsTxtGenerator::class)->generate(); - } - - /** - * Get the route key of the robots.txt file, which for this page is also its output path. - */ - public static function routeKey(): string - { - return 'robots.txt'; - } -} diff --git a/packages/framework/src/Framework/Features/XmlGenerators/RssFeedPage.php b/packages/framework/src/Framework/Features/XmlGenerators/RssFeedPage.php deleted file mode 100644 index 32d2315d936..00000000000 --- a/packages/framework/src/Framework/Features/XmlGenerators/RssFeedPage.php +++ /dev/null @@ -1,39 +0,0 @@ - ['hidden' => true], - ]); - } - - public function compile(): string - { - return app(RssFeedGenerator::class)->generate()->getXml(); - } - - /** - * Get the route key of the RSS feed, which for this page is also its output path. - */ - public static function routeKey(): string - { - return RssFeedGenerator::getFilename(); - } -} diff --git a/packages/framework/src/Framework/Features/XmlGenerators/SitemapPage.php b/packages/framework/src/Framework/Features/XmlGenerators/SitemapPage.php deleted file mode 100644 index 075e79cac23..00000000000 --- a/packages/framework/src/Framework/Features/XmlGenerators/SitemapPage.php +++ /dev/null @@ -1,39 +0,0 @@ - ['hidden' => true], - ]); - } - - public function compile(): string - { - return app(SitemapGenerator::class)->generate()->getXml(); - } - - /** - * Get the route key of the sitemap, which for this page is also its output path. - */ - public static function routeKey(): string - { - return 'sitemap.xml'; - } -} diff --git a/packages/framework/tests/Feature/Commands/BuildRssFeedCommandTest.php b/packages/framework/tests/Feature/Commands/BuildRssFeedCommandTest.php index 6c567804a7c..21547b40dd9 100644 --- a/packages/framework/tests/Feature/Commands/BuildRssFeedCommandTest.php +++ b/packages/framework/tests/Feature/Commands/BuildRssFeedCommandTest.php @@ -11,7 +11,6 @@ use Hyde\Testing\TestCase; #[\PHPUnit\Framework\Attributes\CoversClass(\Hyde\Console\Commands\BuildRssFeedCommand::class)] -#[\PHPUnit\Framework\Attributes\CoversClass(\Hyde\Framework\Features\XmlGenerators\RssFeedPage::class)] class BuildRssFeedCommandTest extends TestCase { public function testRssFeedIsGeneratedWhenConditionsAreMet() diff --git a/packages/framework/tests/Feature/Commands/BuildSitemapCommandTest.php b/packages/framework/tests/Feature/Commands/BuildSitemapCommandTest.php index 940f9342b04..02d8358654f 100644 --- a/packages/framework/tests/Feature/Commands/BuildSitemapCommandTest.php +++ b/packages/framework/tests/Feature/Commands/BuildSitemapCommandTest.php @@ -10,7 +10,6 @@ use Hyde\Testing\TestCase; #[\PHPUnit\Framework\Attributes\CoversClass(\Hyde\Console\Commands\BuildSitemapCommand::class)] -#[\PHPUnit\Framework\Attributes\CoversClass(\Hyde\Framework\Features\XmlGenerators\SitemapPage::class)] class BuildSitemapCommandTest extends TestCase { public function testSitemapIsGeneratedWhenConditionsAreMet() diff --git a/packages/framework/tests/Feature/LlmsTxtPageTest.php b/packages/framework/tests/Feature/LlmsTxtPageTest.php index faa3529dfe3..f1f58c32ec3 100644 --- a/packages/framework/tests/Feature/LlmsTxtPageTest.php +++ b/packages/framework/tests/Feature/LlmsTxtPageTest.php @@ -12,7 +12,6 @@ use Hyde\Foundation\Concerns\HydeExtension; use Hyde\Foundation\Kernel\PageCollection; use Hyde\Framework\Features\TextGenerators\LlmsTxtGenerator; -use Hyde\Framework\Features\TextGenerators\LlmsTxtPage; use Illuminate\Support\Facades\File; /** @@ -22,7 +21,6 @@ * * @see \Hyde\Framework\Testing\Feature\LlmsTxtGeneratorTest */ -#[\PHPUnit\Framework\Attributes\CoversClass(\Hyde\Framework\Features\TextGenerators\LlmsTxtPage::class)] #[\PHPUnit\Framework\Attributes\CoversClass(\Hyde\Foundation\HydeCoreExtension::class)] class LlmsTxtPageTest extends TestCase { @@ -46,7 +44,7 @@ public function testLlmsTxtPageIsRegisteredAsRouteByDefault() $page = Routes::get('llms.txt')->getPage(); - $this->assertInstanceOf(LlmsTxtPage::class, $page); + $this->assertSame(InMemoryPage::class, $page::class); $this->assertSame('llms.txt', $page->getOutputPath()); $this->assertSame($page::outputPath($page->getIdentifier()), $page->getOutputPath()); $this->assertSame('llms.txt', $page->getRouteKey()); @@ -68,7 +66,7 @@ public function testLlmsTxtPageIsNotRegisteredWhenDisabledInConfig() public function testLlmsTxtPageIsHiddenFromNavigationAndExcludedFromTheSitemap() { - $page = new LlmsTxtPage(); + $page = Routes::get('llms.txt')->getPage(); $this->assertFalse($page->showInNavigation()); $this->assertFalse($page->showInSitemap()); @@ -76,7 +74,7 @@ public function testLlmsTxtPageIsHiddenFromNavigationAndExcludedFromTheSitemap() public function testLlmsTxtPageCompilesUsingTheLlmsTxtGenerator() { - $this->assertSame((new LlmsTxtGenerator())->generate(), (new LlmsTxtPage())->compile()); + $this->assertSame((new LlmsTxtGenerator())->generate(), Routes::get('llms.txt')->getPage()->compile()); } public function testLlmsTxtGeneratorCanBeSwappedThroughTheServiceContainer() @@ -134,7 +132,7 @@ public function testUserPageRegisteredInBootingCallbackSuppressesTheGeneratedLlm $page = Routes::get('llms.txt')->getPage(); - $this->assertNotInstanceOf(LlmsTxtPage::class, $page); + $this->assertSame('user defined llms', $page->compile()); $this->assertSame(1, Hyde::pages()->filter(fn ($page) => $page->getRouteKey() === 'llms.txt')->count()); $this->artisan('build')->assertExitCode(0); @@ -148,7 +146,7 @@ public function testUserPageRegisteredThroughExtensionSuppressesTheGeneratedLlms $page = Routes::get('llms.txt')->getPage(); - $this->assertNotInstanceOf(LlmsTxtPage::class, $page); + $this->assertSame('extension defined llms', $page->compile()); $this->assertSame(1, Hyde::pages()->filter(fn ($page) => $page->getRouteKey() === 'llms.txt')->count()); $this->artisan('build')->assertExitCode(0); diff --git a/packages/framework/tests/Feature/RobotsTxtPageTest.php b/packages/framework/tests/Feature/RobotsTxtPageTest.php index 3cde1aa6da8..2e99f5ff310 100644 --- a/packages/framework/tests/Feature/RobotsTxtPageTest.php +++ b/packages/framework/tests/Feature/RobotsTxtPageTest.php @@ -12,7 +12,6 @@ use Hyde\Foundation\Concerns\HydeExtension; use Hyde\Foundation\Kernel\PageCollection; use Hyde\Framework\Features\TextGenerators\RobotsTxtGenerator; -use Hyde\Framework\Features\TextGenerators\RobotsTxtPage; use Illuminate\Support\Facades\File; /** @@ -22,7 +21,6 @@ * * @see \Hyde\Framework\Testing\Feature\RobotsTxtGeneratorTest */ -#[\PHPUnit\Framework\Attributes\CoversClass(\Hyde\Framework\Features\TextGenerators\RobotsTxtPage::class)] #[\PHPUnit\Framework\Attributes\CoversClass(\Hyde\Foundation\HydeCoreExtension::class)] class RobotsTxtPageTest extends TestCase { @@ -39,7 +37,7 @@ public function testRobotsTxtPageIsRegisteredAsRouteByDefault() $page = Routes::get('robots.txt')->getPage(); - $this->assertInstanceOf(RobotsTxtPage::class, $page); + $this->assertSame(InMemoryPage::class, $page::class); $this->assertSame('robots.txt', $page->getOutputPath()); $this->assertSame($page::outputPath($page->getIdentifier()), $page->getOutputPath()); $this->assertSame('robots.txt', $page->getRouteKey()); @@ -61,7 +59,7 @@ public function testRobotsTxtPageIsNotRegisteredWhenDisabledInConfig() public function testRobotsTxtPageIsHiddenFromNavigationAndExcludedFromTheSitemap() { - $page = new RobotsTxtPage(); + $page = Routes::get('robots.txt')->getPage(); $this->assertFalse($page->showInNavigation()); $this->assertFalse($page->showInSitemap()); @@ -71,7 +69,7 @@ public function testRobotsTxtPageCompilesUsingTheRobotsTxtGenerator() { $this->withoutSiteUrl(); - $this->assertSame("User-agent: *\nAllow: /\n", (new RobotsTxtPage())->compile()); + $this->assertSame("User-agent: *\nAllow: /\n", Routes::get('robots.txt')->getPage()->compile()); } public function testRobotsTxtGeneratorCanBeSwappedThroughTheServiceContainer() @@ -133,7 +131,7 @@ public function testUserPageRegisteredInBootingCallbackSuppressesTheGeneratedRob $page = Routes::get('robots.txt')->getPage(); - $this->assertNotInstanceOf(RobotsTxtPage::class, $page); + $this->assertSame('user defined robots', $page->compile()); $this->assertSame(1, Hyde::pages()->filter(fn ($page) => $page->getRouteKey() === 'robots.txt')->count()); $this->artisan('build')->assertExitCode(0); @@ -147,7 +145,7 @@ public function testUserPageRegisteredThroughExtensionSuppressesTheGeneratedRobo $page = Routes::get('robots.txt')->getPage(); - $this->assertNotInstanceOf(RobotsTxtPage::class, $page); + $this->assertSame('extension defined robots', $page->compile()); $this->assertSame(1, Hyde::pages()->filter(fn ($page) => $page->getRouteKey() === 'robots.txt')->count()); $this->artisan('build')->assertExitCode(0); diff --git a/packages/framework/tests/Feature/RssFeedPageTest.php b/packages/framework/tests/Feature/RssFeedPageTest.php index f72764c2774..9931e624509 100644 --- a/packages/framework/tests/Feature/RssFeedPageTest.php +++ b/packages/framework/tests/Feature/RssFeedPageTest.php @@ -13,7 +13,6 @@ use Hyde\Foundation\Concerns\HydeExtension; use Hyde\Foundation\Kernel\PageCollection; use Hyde\Framework\Features\XmlGenerators\RssFeedGenerator; -use Hyde\Framework\Features\XmlGenerators\RssFeedPage; use Illuminate\Support\Facades\File; /** @@ -24,7 +23,6 @@ * @see \Hyde\Framework\Testing\Feature\Services\RssFeedServiceTest * @see \Hyde\Framework\Testing\Feature\Commands\BuildRssFeedCommandTest */ -#[\PHPUnit\Framework\Attributes\CoversClass(\Hyde\Framework\Features\XmlGenerators\RssFeedPage::class)] #[\PHPUnit\Framework\Attributes\CoversClass(\Hyde\Foundation\HydeCoreExtension::class)] class RssFeedPageTest extends TestCase { @@ -49,7 +47,7 @@ public function testFeedPageIsRegisteredAsRouteWhenRssFeatureIsEnabled() $page = Routes::get('feed.xml')->getPage(); - $this->assertInstanceOf(RssFeedPage::class, $page); + $this->assertSame(InMemoryPage::class, $page::class); $this->assertSame('feed.xml', $page->getOutputPath()); $this->assertSame($page::outputPath($page->getIdentifier()), $page->getOutputPath()); $this->assertSame('feed.xml', $page->getRouteKey()); @@ -91,12 +89,12 @@ public function testFeedPageUsesConfiguredFilenameVerbatimForAnyExtension() $this->assertTrue(Routes::exists('feed.rss')); $this->assertSame('feed.rss', Routes::get('feed.rss')->getPage()->getOutputPath()); - $this->assertSame('feed.rss', RssFeedPage::outputPath('feed.rss')); + $this->assertSame('feed.rss', InMemoryPage::outputPath('feed.rss')); } public function testFeedPageIsHiddenFromNavigationAndExcludesItselfFromTheSitemap() { - $page = new RssFeedPage(); + $page = Routes::get('feed.xml')->getPage(); $this->assertFalse($page->showInNavigation()); $this->assertFalse($page->showInSitemap()); @@ -104,7 +102,7 @@ public function testFeedPageIsHiddenFromNavigationAndExcludesItselfFromTheSitema public function testFeedPageCompilesUsingTheRssFeedGenerator() { - $contents = (new RssFeedPage())->compile(); + $contents = Routes::get('feed.xml')->getPage()->compile(); $this->assertStringStartsWith('', $contents); $this->assertStringContainsString('getPage(); - $this->assertNotInstanceOf(RssFeedPage::class, $page); + $this->assertSame('user defined feed', $page->compile()); $this->assertSame(1, Hyde::pages()->filter(fn ($page) => $page->getRouteKey() === 'feed.xml')->count()); $this->artisan('build')->assertExitCode(0); @@ -184,7 +182,7 @@ public function testUserPageRegisteredThroughExtensionSuppressesTheGeneratedFeed $page = Routes::get('feed.xml')->getPage(); - $this->assertNotInstanceOf(RssFeedPage::class, $page); + $this->assertSame('extension defined feed', $page->compile()); $this->assertSame(1, Hyde::pages()->filter(fn ($page) => $page->getRouteKey() === 'feed.xml')->count()); $this->artisan('build')->assertExitCode(0); diff --git a/packages/framework/tests/Feature/SitemapFeatureTest.php b/packages/framework/tests/Feature/SitemapFeatureTest.php index cf7a493bab9..2792a979f58 100644 --- a/packages/framework/tests/Feature/SitemapFeatureTest.php +++ b/packages/framework/tests/Feature/SitemapFeatureTest.php @@ -20,7 +20,6 @@ * @see \Hyde\Framework\Testing\Feature\Commands\BuildSitemapCommandTest */ #[\PHPUnit\Framework\Attributes\CoversClass(\Hyde\Framework\Features\XmlGenerators\SitemapGenerator::class)] -#[\PHPUnit\Framework\Attributes\CoversClass(\Hyde\Framework\Features\XmlGenerators\SitemapPage::class)] #[\PHPUnit\Framework\Attributes\CoversClass(\Hyde\Console\Commands\BuildSitemapCommand::class)] class SitemapFeatureTest extends TestCase { diff --git a/packages/framework/tests/Feature/SitemapPageTest.php b/packages/framework/tests/Feature/SitemapPageTest.php index 1e264f9585d..29cfe3ee1ad 100644 --- a/packages/framework/tests/Feature/SitemapPageTest.php +++ b/packages/framework/tests/Feature/SitemapPageTest.php @@ -12,7 +12,6 @@ use Hyde\Foundation\Concerns\HydeExtension; use Hyde\Foundation\Kernel\PageCollection; use Hyde\Framework\Features\XmlGenerators\SitemapGenerator; -use Hyde\Framework\Features\XmlGenerators\SitemapPage; use Illuminate\Support\Facades\File; /** @@ -23,7 +22,6 @@ * @see \Hyde\Framework\Testing\Feature\SitemapFeatureTest * @see \Hyde\Framework\Testing\Feature\Services\SitemapServiceTest */ -#[\PHPUnit\Framework\Attributes\CoversClass(\Hyde\Framework\Features\XmlGenerators\SitemapPage::class)] #[\PHPUnit\Framework\Attributes\CoversClass(\Hyde\Foundation\HydeCoreExtension::class)] class SitemapPageTest extends TestCase { @@ -42,7 +40,7 @@ public function testSitemapPageIsRegisteredAsRouteWhenSitemapFeatureIsEnabled() $page = Routes::get('sitemap.xml')->getPage(); - $this->assertInstanceOf(SitemapPage::class, $page); + $this->assertSame(InMemoryPage::class, $page::class); $this->assertSame('sitemap.xml', $page->getOutputPath()); $this->assertSame($page::outputPath($page->getIdentifier()), $page->getOutputPath()); $this->assertSame('sitemap.xml', $page->getRouteKey()); @@ -65,7 +63,9 @@ public function testSitemapPageIsNotRegisteredWhenSitemapIsDisabledInConfig() public function testSitemapPageIsHiddenFromNavigationAndExcludesItselfFromTheSitemap() { - $page = new SitemapPage(); + $this->withSiteUrl(); + + $page = Routes::get('sitemap.xml')->getPage(); $this->assertFalse($page->showInNavigation()); $this->assertFalse($page->showInSitemap()); @@ -75,7 +75,7 @@ public function testSitemapPageCompilesUsingTheSitemapGenerator() { $this->withSiteUrl(); - $contents = (new SitemapPage())->compile(); + $contents = Routes::get('sitemap.xml')->getPage()->compile(); $this->assertStringStartsWith('', $contents); $this->assertStringContainsString('getPage(); - $this->assertNotInstanceOf(SitemapPage::class, $page); + $this->assertSame('user defined sitemap', $page->compile()); $this->assertSame(1, Hyde::pages()->filter(fn ($page) => $page->getRouteKey() === 'sitemap.xml')->count()); $this->artisan('build')->assertExitCode(0); @@ -163,7 +163,7 @@ public function testUserPageRegisteredThroughExtensionSuppressesTheGeneratedSite $page = Routes::get('sitemap.xml')->getPage(); - $this->assertNotInstanceOf(SitemapPage::class, $page); + $this->assertSame('extension defined sitemap', $page->compile()); $this->assertSame(1, Hyde::pages()->filter(fn ($page) => $page->getRouteKey() === 'sitemap.xml')->count()); $this->artisan('build')->assertExitCode(0); From abd9d3ed629e8eb05e7c4523953802193289c75f Mon Sep 17 00:00:00 2001 From: Emma De Silva Date: Wed, 15 Jul 2026 00:11:19 +0200 Subject: [PATCH 100/117] Remove overzealous config validation --- .../TextGenerators/RobotsTxtGenerator.php | 15 ++----------- .../tests/Feature/RobotsTxtGeneratorTest.php | 21 ++++--------------- 2 files changed, 6 insertions(+), 30 deletions(-) diff --git a/packages/framework/src/Framework/Features/TextGenerators/RobotsTxtGenerator.php b/packages/framework/src/Framework/Features/TextGenerators/RobotsTxtGenerator.php index 0cb769462ab..e9fd579182a 100644 --- a/packages/framework/src/Framework/Features/TextGenerators/RobotsTxtGenerator.php +++ b/packages/framework/src/Framework/Features/TextGenerators/RobotsTxtGenerator.php @@ -7,13 +7,9 @@ use Hyde\Hyde; use Hyde\Facades\Config; use Hyde\Facades\Features; -use Hyde\Framework\Exceptions\InvalidConfigurationException; use function array_merge; -use function get_debug_type; use function implode; -use function is_string; -use function sprintf; /** * Generates the contents for the robots.txt file. @@ -53,15 +49,8 @@ protected function getRuleLines(): array $lines = []; - foreach ($rules as $index => $rule) { - if (! is_string($rule)) { - throw new InvalidConfigurationException(sprintf( - 'Invalid `hyde.robots.disallow` entry at index [%s]: each Disallow rule must be a string, %s given.', - $index, get_debug_type($rule) - ), 'hyde', 'disallow'); - } - - $lines[] = "Disallow: $rule"; + foreach ($rules as $rule) { + $lines[] = 'Disallow: '.(string) $rule; } return $lines; diff --git a/packages/framework/tests/Feature/RobotsTxtGeneratorTest.php b/packages/framework/tests/Feature/RobotsTxtGeneratorTest.php index 2fb62ab68b9..5f73afb21c1 100644 --- a/packages/framework/tests/Feature/RobotsTxtGeneratorTest.php +++ b/packages/framework/tests/Feature/RobotsTxtGeneratorTest.php @@ -5,7 +5,6 @@ namespace Hyde\Framework\Testing\Feature; use Hyde\Testing\TestCase; -use Hyde\Framework\Exceptions\InvalidConfigurationException; use Hyde\Framework\Features\TextGenerators\RobotsTxtGenerator; #[\PHPUnit\Framework\Attributes\CoversClass(\Hyde\Framework\Features\TextGenerators\RobotsTxtGenerator::class)] @@ -49,24 +48,12 @@ public function testDisallowRulesAreWrittenVerbatim() $this->assertSame("User-agent: *\nDisallow: /*.pdf$\nDisallow: \n", $this->generate()); } - public function testNonStringDisallowRuleFailsWithConfigurationException() + public function testNumericDisallowRulesAreCastToStrings() { - config(['hyde.robots.disallow' => ['/private', 123]]); - - $this->expectException(InvalidConfigurationException::class); - $this->expectExceptionMessage('Invalid `hyde.robots.disallow` entry at index [1]: each Disallow rule must be a string, int given.'); - - $this->generate(); - } - - public function testNonStringDisallowRuleExceptionIdentifiesStringKeys() - { - config(['hyde.robots.disallow' => ['foo' => null]]); - - $this->expectException(InvalidConfigurationException::class); - $this->expectExceptionMessage('Invalid `hyde.robots.disallow` entry at index [foo]: each Disallow rule must be a string, null given.'); + $this->withoutSiteUrl(); + config(['hyde.robots.disallow' => [123, 3.14]]); - $this->generate(); + $this->assertSame("User-agent: *\nDisallow: 123\nDisallow: 3.14\n", $this->generate()); } public function testGeneratesDisallowRulesAndSitemapLineTogether() From 15d1e0afc623468669e944c79f1f9a7ad03b2d5f Mon Sep 17 00:00:00 2001 From: Emma De Silva Date: Wed, 15 Jul 2026 00:13:50 +0200 Subject: [PATCH 101/117] Remove repeated runtime validation on static properties --- .../hyde-pages-api/hyde-page-methods.md | 4 +--- .../framework/src/Pages/Concerns/HydePage.php | 17 +---------------- .../framework/tests/Feature/HydePageTest.php | 14 ++++---------- 3 files changed, 6 insertions(+), 29 deletions(-) diff --git a/docs/_data/partials/hyde-pages-api/hyde-page-methods.md b/docs/_data/partials/hyde-pages-api/hyde-page-methods.md index 2b1b770e02a..4307951e7c1 100644 --- a/docs/_data/partials/hyde-pages-api/hyde-page-methods.md +++ b/docs/_data/partials/hyde-pages-api/hyde-page-methods.md @@ -1,7 +1,7 @@
- + #### `make()` @@ -92,8 +92,6 @@ The value includes the leading dot, so it can be used directly as a file name su HydePage::outputExtension(): string ``` -- **Throws:** \InvalidArgumentException If the output extension does not start with a dot or contains a path separator. - #### `setSourceDirectory()` Set the source directory for the page type. diff --git a/packages/framework/src/Pages/Concerns/HydePage.php b/packages/framework/src/Pages/Concerns/HydePage.php index dbf08226941..f78ea430194 100644 --- a/packages/framework/src/Pages/Concerns/HydePage.php +++ b/packages/framework/src/Pages/Concerns/HydePage.php @@ -24,16 +24,12 @@ use Hyde\Support\Models\Route; use Hyde\Support\Models\RouteKey; use Illuminate\Support\Str; -use InvalidArgumentException; use function Hyde\unslash; use function filled; use function ltrim; use function rtrim; -use function sprintf; -use function str_contains; use function str_ends_with; -use function str_starts_with; /** * The base class for all Hyde pages. @@ -183,21 +179,10 @@ public static function sourceExtension(): string * Get the output file extension for the page type, such as `.html` or `.txt`. * * The value includes the leading dot, so it can be used directly as a file name suffix. - * - * @throws \InvalidArgumentException If the output extension does not start with a dot or contains a path separator. */ public static function outputExtension(): string { - $extension = static::$outputExtension; - - if (! str_starts_with($extension, '.') || str_contains($extension, '/') || str_contains($extension, '\\')) { - throw new InvalidArgumentException(sprintf( - "Invalid output extension '%s' declared by %s: extensions must start with a dot and cannot contain path separators.", - $extension, static::class - )); - } - - return $extension; + return static::$outputExtension; } /** diff --git a/packages/framework/tests/Feature/HydePageTest.php b/packages/framework/tests/Feature/HydePageTest.php index a752e0e7417..36fb949272e 100644 --- a/packages/framework/tests/Feature/HydePageTest.php +++ b/packages/framework/tests/Feature/HydePageTest.php @@ -20,7 +20,6 @@ use Hyde\Pages\MarkdownPost; use Hyde\Support\Models\Route; use Hyde\Testing\TestCase; -use InvalidArgumentException; /** * Test the base HydePage class. @@ -129,19 +128,14 @@ public function testOutputPathUsesTheOutputExtensionOfThePageClass() $this->assertSame('output/hello-world.txt', NonHtmlOutputTestPage::outputPath('hello-world')); } - public function testOutputExtensionWithoutLeadingDotThrows() + public function testOutputExtensionWithoutLeadingDotIsReturnedUnchanged() { - $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessage("Invalid output extension 'txt' declared by"); - - MissingDotOutputExtensionTestPage::outputExtension(); + $this->assertSame('txt', MissingDotOutputExtensionTestPage::outputExtension()); } - public function testOutputExtensionWithPathSeparatorThrows() + public function testOutputExtensionWithPathSeparatorIsReturnedUnchanged() { - $this->expectException(InvalidArgumentException::class); - - PathSeparatorOutputExtensionTestPage::outputExtension(); + $this->assertSame('.txt/../evil', PathSeparatorOutputExtensionTestPage::outputExtension()); } public function testGetRouteKeyForPageWithNonHtmlOutputExtensionIncludesExtension() From 8dfd6f73926d4c143858fde1d7cff66a69dfa854 Mon Sep 17 00:00:00 2001 From: Emma De Silva Date: Wed, 15 Jul 2026 00:21:49 +0200 Subject: [PATCH 102/117] Remove the boot-time legacy API guard --- HYDEPHP_V3_PLANNING.md | 2 +- UPGRADE.md | 3 -- .../src/Foundation/Kernel/FileCollection.php | 20 ----------- .../Feature/HydeExtensionFeatureTest.php | 33 ------------------- 4 files changed, 1 insertion(+), 57 deletions(-) diff --git a/HYDEPHP_V3_PLANNING.md b/HYDEPHP_V3_PLANNING.md index 89e8056679b..de1e520e0b6 100644 --- a/HYDEPHP_V3_PLANNING.md +++ b/HYDEPHP_V3_PLANNING.md @@ -51,7 +51,7 @@ Having this document in code lets us know the devlopment state at any given poin ### Breaking Changes -- 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). Page discovery fails fast with an actionable exception for registered page classes that still use the old API, instead of silently skipping them during builds. +- 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 register a custom `sitemap.xml` page or rebind `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 register a custom page with the configured feed route key or rebind `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. A user-defined page registered under the feed route key is still built even when the feature conditions are not met. - Removed `Redirect::create()`, `Redirect::store()`, and the `Redirect` constructor's `showText` argument. Redirects must now be declared in `hyde.redirects`, keeping all generated output inside the kernel-owned build graph. Redirect routes are intrinsically excluded from navigation menus and sitemaps, and always include an accessible fallback link. diff --git a/UPGRADE.md b/UPGRADE.md index 35837e0f945..2eed7b16fc3 100644 --- a/UPGRADE.md +++ b/UPGRADE.md @@ -421,9 +421,6 @@ 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. -You do not need to hunt for affected classes: page discovery fails fast with an exception naming any -registered page class that still uses the old API, instead of silently skipping the class during builds. - ## Migration Checklist Use this checklist to track your upgrade progress: diff --git a/packages/framework/src/Foundation/Kernel/FileCollection.php b/packages/framework/src/Foundation/Kernel/FileCollection.php index 75a4cd4fab3..7659cbbd9df 100644 --- a/packages/framework/src/Foundation/Kernel/FileCollection.php +++ b/packages/framework/src/Foundation/Kernel/FileCollection.php @@ -9,11 +9,8 @@ use Hyde\Framework\Exceptions\FileNotFoundException; use Hyde\Pages\Concerns\HydePage; use Hyde\Support\Filesystem\SourceFile; -use RuntimeException; use function basename; -use function method_exists; -use function property_exists; use function str_starts_with; /** @@ -44,29 +41,12 @@ protected function runDiscovery(): void { /** @var class-string<\Hyde\Pages\Concerns\HydePage> $pageClass */ foreach ($this->kernel->getRegisteredPageClasses() as $pageClass) { - self::guardAgainstLegacyFileExtensionApi($pageClass); - if ($pageClass::isDiscoverable()) { $this->discoverFilesFor($pageClass); } } } - /** - * Fail fast for page classes still using the file extension API renamed in HydePHP v3. - * Without this guard such classes are simply not discoverable, so a build would - * succeed while silently omitting the entire page type. Temporary upgrade - * aid that can be removed in a future release. - * - * @param class-string $pageClass - */ - protected static function guardAgainstLegacyFileExtensionApi(string $pageClass): void - { - if (property_exists($pageClass, 'fileExtension') || method_exists($pageClass, 'fileExtension') || method_exists($pageClass, 'setFileExtension')) { - throw new RuntimeException("The page class [$pageClass] uses the \$fileExtension API which was renamed in HydePHP v3. Rename \$fileExtension, fileExtension(), and setFileExtension() to \$sourceExtension, sourceExtension(), and setSourceExtension()."); - } - } - protected function runExtensionHandlers(): void { /** @var class-string<\Hyde\Foundation\Concerns\HydeExtension> $extension */ diff --git a/packages/framework/tests/Feature/HydeExtensionFeatureTest.php b/packages/framework/tests/Feature/HydeExtensionFeatureTest.php index f074acc165e..4fe121218cf 100644 --- a/packages/framework/tests/Feature/HydeExtensionFeatureTest.php +++ b/packages/framework/tests/Feature/HydeExtensionFeatureTest.php @@ -18,7 +18,6 @@ use Hyde\Support\Models\Route; use Hyde\Testing\TestCase; use InvalidArgumentException; -use RuntimeException; use stdClass; /** @@ -44,16 +43,6 @@ protected function setUp(): void $this->kernel = HydeKernel::getInstance(); } - public function testDiscoveryFailsFastForPageClassUsingLegacyFileExtensionApi() - { - $this->kernel->registerExtension(LegacyPageExtension::class); - - $this->expectException(RuntimeException::class); - $this->expectExceptionMessage('uses the $fileExtension API which was renamed in HydePHP v3'); - - $this->kernel->boot(); - } - public function testHandlerMethodsAreCalledByDiscovery() { $this->kernel->registerExtension(HydeTestExtension::class); @@ -230,28 +219,6 @@ public function compile(): string } } -class LegacyFileExtensionPageClass extends HydePage -{ - public static string $sourceDirectory = 'foo'; - public static string $outputDirectory = 'foo'; - public static string $fileExtension = '.txt'; - - public function compile(): string - { - return ''; - } -} - -class LegacyPageExtension extends HydeExtension -{ - public static function getPageClasses(): array - { - return [ - LegacyFileExtensionPageClass::class, - ]; - } -} - class TestPageExtension extends HydeExtension { public static function getPageClasses(): array From d7910677ffecad2fae2c102b92b4f02ed3f89a64 Mon Sep 17 00:00:00 2001 From: Emma De Silva Date: Wed, 15 Jul 2026 00:34:41 +0200 Subject: [PATCH 103/117] Ensure spec is up to date --- EPIC_NON_HTML_PAGES.md | 16 ++++------------ 1 file changed, 4 insertions(+), 12 deletions(-) diff --git a/EPIC_NON_HTML_PAGES.md b/EPIC_NON_HTML_PAGES.md index c4aa9afd80f..4ab5bf22a65 100644 --- a/EPIC_NON_HTML_PAGES.md +++ b/EPIC_NON_HTML_PAGES.md @@ -493,18 +493,10 @@ Implementation notes (branch `v3/non-html-pages-robots`): existing tests asserting exact collections gained a `hyde.robots.enabled => false` in their setup, alongside their existing sitemap/RSS switches. - Generator output: `User-agent: *`, then verbatim `Disallow:` lines from the - `hyde.robots.disallow` config array, or `Allow: /` when there are none (a group - needs at least one rule; an unconditional `Allow: /` next to disallow rules would - be noise). The config entries are *rule values*, not filesystem paths — named and - documented as such in the config stubs and generator — and are deliberately not - normalized (no leading-slash fixup, trimming, or empty-string removal): - normalization would guess intent and break valid values like wildcard patterns or - the empty string (a valid "allow everything" rule). The verbatim contract is - string-only, validated per entry: a non-string value throws an - `InvalidConfigurationException` naming `hyde.robots.disallow` and the offending - index, instead of surfacing as a PHP-level type error at build time. Later - generated text pages copying this pattern (llms.txt) should keep both halves — - verbatim strings, explicit validation. + `hyde.robots.disallow` config array, or `Allow: /` when there are none. The config entries are + *rule values*, not filesystem paths, and are deliberately not normalized + (no leading-slash fixup or empty-string removal) so valid values like wildcard patterns are supported. + Non-string values (like integers or floats) are safely cast to strings during generation. - Its `InMemoryPage` identifier includes the `.txt` output extension per D2. - No `build:robots` command: the sitemap/RSS commands exist only as carry-overs of the removed post-build tasks; robots.txt never had one, and the standard build From 3e2a623c22114633656dbef152467f06f774b8f3 Mon Sep 17 00:00:00 2001 From: Emma De Silva Date: Fri, 17 Jul 2026 02:03:32 +0200 Subject: [PATCH 104/117] Use lazy contents for generated pages --- .../src/Foundation/HydeCoreExtension.php | 36 ++++++++++--------- 1 file changed, 20 insertions(+), 16 deletions(-) diff --git a/packages/framework/src/Foundation/HydeCoreExtension.php b/packages/framework/src/Foundation/HydeCoreExtension.php index d03aa4c8f55..2665ebe85b7 100644 --- a/packages/framework/src/Foundation/HydeCoreExtension.php +++ b/packages/framework/src/Foundation/HydeCoreExtension.php @@ -108,10 +108,11 @@ public function discoverPages(PageCollection $collection): void protected function discoverSitemapPage(PageCollection $collection): void { if (! $this->hasPageWithRouteKey($collection, 'sitemap.xml')) { - $page = new InMemoryPage('sitemap.xml', ['navigation' => ['hidden' => true]]); - $page->macro('compile', fn (): string => app(SitemapGenerator::class)->generate()->getXml()); - - $collection->addPage($page); + $collection->addPage(new InMemoryPage( + 'sitemap.xml', + ['navigation' => ['hidden' => true]], + fn (): string => app(SitemapGenerator::class)->generate()->getXml(), + )); } } @@ -121,10 +122,11 @@ protected function discoverRssFeedPage(PageCollection $collection): void $routeKey = RssFeedGenerator::getFilename(); if (! $this->hasPageWithRouteKey($collection, $routeKey)) { - $page = new InMemoryPage($routeKey, ['navigation' => ['hidden' => true]]); - $page->macro('compile', fn (): string => app(RssFeedGenerator::class)->generate()->getXml()); - - $collection->addPage($page); + $collection->addPage(new InMemoryPage( + $routeKey, + ['navigation' => ['hidden' => true]], + fn (): string => app(RssFeedGenerator::class)->generate()->getXml(), + )); } } @@ -132,10 +134,11 @@ protected function discoverRssFeedPage(PageCollection $collection): void protected function discoverRobotsTxtPage(PageCollection $collection): void { if (! $this->hasPageWithRouteKey($collection, 'robots.txt')) { - $page = new InMemoryPage('robots.txt', ['navigation' => ['hidden' => true]]); - $page->macro('compile', fn (): string => app(RobotsTxtGenerator::class)->generate()); - - $collection->addPage($page); + $collection->addPage(new InMemoryPage( + 'robots.txt', + ['navigation' => ['hidden' => true]], + fn (): string => app(RobotsTxtGenerator::class)->generate(), + )); } } @@ -143,10 +146,11 @@ protected function discoverRobotsTxtPage(PageCollection $collection): void protected function discoverLlmsTxtPage(PageCollection $collection): void { if (! $this->hasPageWithRouteKey($collection, 'llms.txt')) { - $page = new InMemoryPage('llms.txt', ['navigation' => ['hidden' => true]]); - $page->macro('compile', fn (): string => app(LlmsTxtGenerator::class)->generate()); - - $collection->addPage($page); + $collection->addPage(new InMemoryPage( + 'llms.txt', + ['navigation' => ['hidden' => true]], + fn (): string => app(LlmsTxtGenerator::class)->generate(), + )); } } From 71ffcd18e88929d3f1f51c3e986a2138957bdbaa Mon Sep 17 00:00:00 2001 From: Emma De Silva Date: Fri, 17 Jul 2026 23:40:26 +0200 Subject: [PATCH 105/117] Build documentation --- .../_data/partials/hyde-pages-api/in-memory-page-methods.md | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/docs/_data/partials/hyde-pages-api/in-memory-page-methods.md b/docs/_data/partials/hyde-pages-api/in-memory-page-methods.md index 8e67a276236..aeab59cc739 100644 --- a/docs/_data/partials/hyde-pages-api/in-memory-page-methods.md +++ b/docs/_data/partials/hyde-pages-api/in-memory-page-methods.md @@ -1,7 +1,7 @@
- + #### `make()` @@ -30,9 +30,7 @@ Pass literal contents or a closure to `$contents`, or pass a registered Laravel Contents and views cannot be used together. Omit both to create an empty page. An empty view value is treated as no view. -View values ending in `.blade.php` are treated as Blade file paths. Other values are treated as registered Laravel view keys. - -Identifiers that already have an extension use it as the output path unchanged. +View values ending in `.blade.php` are treated as Blade file paths. Other values are treated as registered Laravel view keys. Identifiers that already have an extension use it as the output path unchanged. ```php /** @param string|(Closure(): string)|(Closure(static): string)|null $contents */ From bd2cb963abffe6e8ab1c8996422228913a4be2ea Mon Sep 17 00:00:00 2001 From: Emma De Silva Date: Fri, 17 Jul 2026 11:37:51 +0200 Subject: [PATCH 106/117] Hide non-HTML pages from navigation by default --- CHANGELOG.md | 1 + HYDEPHP_V3_PLANNING.md | 2 + UPGRADE.md | 24 +++++++++++- docs/advanced-features/in-memory-pages.md | 14 ++++++- .../src/Foundation/HydeCoreExtension.php | 12 ++---- .../Factories/NavigationDataFactory.php | 15 +++++++- .../AutomaticNavigationConfigurationsTest.php | 22 +++++++++++ .../Unit/NavigationDataFactoryUnitTest.php | 37 +++++++++++++++++-- 8 files changed, 110 insertions(+), 17 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0d311a44260..029fa287d01 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,6 +27,7 @@ This serves two purposes: - Raw HTML in Markdown is now enabled by default. Set `markdown.allow_html` to `false` when compiling untrusted or unreviewed Markdown to strip potentially unsafe HTML tags. - `InMemoryPage` now requires callers to select either `contents` or `view`; configuring both throws an `InvalidArgumentException` instead of silently giving contents precedence. - `InMemoryPage` now treats an empty string as an omitted `view`, matching the existing compile-time behavior and allowing literal contents to be used with an empty view value. +- Pages with non-HTML output paths are now excluded from automatic navigation by default. Set `navigation.visible: true` or `navigation.hidden: false` to include one explicitly. ### Deprecated - for changes that will be removed in upcoming releases. diff --git a/HYDEPHP_V3_PLANNING.md b/HYDEPHP_V3_PLANNING.md index de1e520e0b6..b36de9c298e 100644 --- a/HYDEPHP_V3_PLANNING.md +++ b/HYDEPHP_V3_PLANNING.md @@ -25,6 +25,7 @@ Having this document in code lets us know the devlopment state at any given poin - 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)) - Added built-in terminal code blocks using the `terminal` fence language. Command prompts are styled for selection-free copying, and `terminal xml` supports four Symfony-style Console formatter tags. ([#2188](https://github.com/hydephp/develop/issues/2188), [#2485](https://github.com/hydephp/develop/issues/2485)) - `InMemoryPage` now infers its output format from the identifier: identifiers with an extension keep it, while identifiers without one compile to `.html`. Only the HTML extension is implicit in route keys, so non-HTML paths such as `robots.txt` and `docs/search.json` remain route keys as-is. +- Pages with non-HTML output paths are excluded from automatic navigation by default. Set `navigation.visible: true` or `navigation.hidden: false` to opt a non-HTML page into the generated navigation. - Pages can now control their own sitemap inclusion. Set `sitemap: false` in a page's front matter to exclude it from the generated `sitemap.xml`, or override the new `HydePage::showInSitemap()` method in custom page classes. Pages compiled to non-HTML output files (like `robots.txt`) are excluded by default, and `sitemap: true` front matter opts such a page back in. - The sitemap and RSS feed are now first-class pages instead of post-build side effects: when the respective feature is enabled, `sitemap.xml` and the RSS feed (`feed.xml`, or the configured `hyde.rss.filename`) are registered as routes, so they are served by `hyde serve`, listed in `route:list`, included in the build manifest, and compiled through the standard site build. The output can be customized by rebinding the `SitemapGenerator` or `RssFeedGenerator` class in the service container, and registering a user-defined page with the same route key (from a service provider, booting callback, or extension) replaces the generated page entirely. - Hyde now generates a `robots.txt` file for the site out of the box. The default output allows all crawlers, and links to the sitemap when that feature is enabled. Rule values listed in the new `hyde.robots.disallow` configuration array are written verbatim as `Disallow` rules (so wildcard patterns are supported), and the file can be disabled entirely with `hyde.robots.enabled`. The page is wired like the sitemap and RSS feed: it is a real route (served by `hyde serve`, listed in `route:list`, included in the build manifest), the output can be customized by rebinding the `RobotsTxtGenerator` class in the service container, and a user-defined `robots.txt` page replaces the generated one entirely. @@ -70,6 +71,7 @@ Please fill in UPGRADE.md as you make changes. - Move any calls to `Redirect::create()` or `Redirect::store()` into the `redirects` array in `config/hyde.php`, using the old path as the key and the destination as the value. - Move `InMemoryPage` `compile` macro callbacks into the contents argument, and replace other instance macros with methods on an `InMemoryPage` subclass. - 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. - Rename `$fileExtension` to `$sourceExtension` in custom page classes, and update any calls to `fileExtension()` or `setFileExtension()` to `sourceExtension()` and `setSourceExtension()`. - 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 rebinding `SitemapGenerator` or `RssFeedGenerator` in the service container or by registering your own page with the same route key instead. diff --git a/UPGRADE.md b/UPGRADE.md index 2eed7b16fc3..5a4343f9cb4 100644 --- a/UPGRADE.md +++ b/UPGRADE.md @@ -203,8 +203,7 @@ $page->macro( ```php $page = new InMemoryPage( 'sitemap.xml', - ['navigation' => ['hidden' => true]], - fn (): string => app(SitemapGenerator::class)->generate()->getXml(), + contents: fn (): string => app(SitemapGenerator::class)->generate()->getXml(), ); ``` @@ -313,6 +312,26 @@ new InMemoryPage('example', view: ''); new InMemoryPage('example', view: null); ``` +### Review Non-HTML Navigation Visibility + +Pages whose resolved output path does not end in `.html` are no longer included in automatic navigation by default. +This applies both to identifier-based `InMemoryPage` instances such as `feed.xml` and to custom page classes with a +non-HTML `$outputExtension`. Machine-readable resources generally need no migration, and any +`navigation.hidden: true` matter used only for this purpose can be removed. + +If a non-HTML page is intentionally linked from the generated navigation, opt it in explicitly: + +```php +new InMemoryPage( + 'downloads/catalog.pdf', + matter: ['navigation' => ['visible' => true]], + contents: $catalog, +); +``` + +The equivalent `navigation.hidden: false` setting is also supported. Explicit visibility overrides only the new +non-HTML default; existing exclusions for posts, configured route keys, and hidden subdirectories still apply. + ## Step 6: Review Sitemap and RSS Feed Customizations The sitemap and RSS feed are now generated as regular pages instead of by post-build tasks, so `sitemap.xml` and @@ -430,6 +449,7 @@ Use this checklist to track your upgrade progress: - [ ] Moved calls to `Redirect::create()` or `Redirect::store()` into the `hyde.redirects` configuration array - [ ] Moved `InMemoryPage` `compile` macro callbacks into the contents argument and replaced other macros with subclass methods - [ ] Updated `InMemoryPage` calls to supply only one of `contents` and `view` +- [ ] 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 a generator container rebind or a user-defined page - [ ] Confirmed the new generated `robots.txt` does not conflict with an existing one, or disabled it with `hyde.robots.enabled` - [ ] Decided whether to publish the new generated `llms.txt` for AI services, or disabled it with `hyde.llms.enabled` diff --git a/docs/advanced-features/in-memory-pages.md b/docs/advanced-features/in-memory-pages.md index 7ec7ed617a5..f6431891913 100644 --- a/docs/advanced-features/in-memory-pages.md +++ b/docs/advanced-features/in-memory-pages.md @@ -58,6 +58,17 @@ InMemoryPage::make('robots.txt')->getOutputPath(); // robots.txt ``` +Non-HTML output is excluded from automatic navigation by default. To link one of these files from the generated +navigation, opt it in explicitly with `navigation.visible` (or set `navigation.hidden` to `false`): + +```php +InMemoryPage::make( + 'downloads/catalog.pdf', + matter: ['navigation' => ['visible' => true]], + contents: $catalog, +); +``` + Pass a closure when the contents should be generated lazily during compilation. The closure is invoked again for each compilation, which makes it useful for pages generated from the current application state. @@ -67,8 +78,7 @@ use Hyde\Pages\InMemoryPage; $page = new InMemoryPage( 'sitemap.xml', - ['navigation' => ['hidden' => true]], - fn (): string => app(SitemapGenerator::class)->generate()->getXml(), + contents: fn (): string => app(SitemapGenerator::class)->generate()->getXml(), ); ``` diff --git a/packages/framework/src/Foundation/HydeCoreExtension.php b/packages/framework/src/Foundation/HydeCoreExtension.php index 2665ebe85b7..00ee9712844 100644 --- a/packages/framework/src/Foundation/HydeCoreExtension.php +++ b/packages/framework/src/Foundation/HydeCoreExtension.php @@ -110,8 +110,7 @@ protected function discoverSitemapPage(PageCollection $collection): void if (! $this->hasPageWithRouteKey($collection, 'sitemap.xml')) { $collection->addPage(new InMemoryPage( 'sitemap.xml', - ['navigation' => ['hidden' => true]], - fn (): string => app(SitemapGenerator::class)->generate()->getXml(), + contents: fn (): string => app(SitemapGenerator::class)->generate()->getXml(), )); } } @@ -124,8 +123,7 @@ protected function discoverRssFeedPage(PageCollection $collection): void if (! $this->hasPageWithRouteKey($collection, $routeKey)) { $collection->addPage(new InMemoryPage( $routeKey, - ['navigation' => ['hidden' => true]], - fn (): string => app(RssFeedGenerator::class)->generate()->getXml(), + contents: fn (): string => app(RssFeedGenerator::class)->generate()->getXml(), )); } } @@ -136,8 +134,7 @@ protected function discoverRobotsTxtPage(PageCollection $collection): void if (! $this->hasPageWithRouteKey($collection, 'robots.txt')) { $collection->addPage(new InMemoryPage( 'robots.txt', - ['navigation' => ['hidden' => true]], - fn (): string => app(RobotsTxtGenerator::class)->generate(), + contents: fn (): string => app(RobotsTxtGenerator::class)->generate(), )); } } @@ -148,8 +145,7 @@ protected function discoverLlmsTxtPage(PageCollection $collection): void if (! $this->hasPageWithRouteKey($collection, 'llms.txt')) { $collection->addPage(new InMemoryPage( 'llms.txt', - ['navigation' => ['hidden' => true]], - fn (): string => app(LlmsTxtGenerator::class)->generate(), + contents: fn (): string => app(LlmsTxtGenerator::class)->generate(), )); } } diff --git a/packages/framework/src/Framework/Factories/NavigationDataFactory.php b/packages/framework/src/Framework/Factories/NavigationDataFactory.php index 8ba85db9461..3cecc8ec52b 100644 --- a/packages/framework/src/Framework/Factories/NavigationDataFactory.php +++ b/packages/framework/src/Framework/Factories/NavigationDataFactory.php @@ -21,6 +21,7 @@ use function array_flip; use function array_intersect; use function array_key_exists; +use function str_ends_with; /** * Discover data used for navigation menus and the documentation sidebar. @@ -40,6 +41,7 @@ class NavigationDataFactory extends Concerns\PageDataFactory implements Navigati protected readonly ?int $priority; private readonly string $title; private readonly string $routeKey; + private readonly string $outputPath; private readonly string $pageClass; private readonly string $identifier; private readonly FrontMatter $matter; @@ -57,6 +59,7 @@ public function __construct(CoreDataObject $pageData, string $title) $this->identifier = $pageData->identifier; $this->pageClass = $pageData->pageClass; $this->routeKey = $pageData->routeKey; + $this->outputPath = $pageData->outputPath; $this->title = $title; $this->configurationKeys = $this->isInstanceOf(DocumentationPage::class) @@ -103,10 +106,18 @@ protected function makeGroup(): ?string protected function makeHidden(): bool { + $frontMatterHidden = $this->searchForHiddenInFrontMatter(); + return $this->isInstanceOf(MarkdownPost::class) - || $this->searchForHiddenInFrontMatter() + || $frontMatterHidden === true || $this->searchForHiddenInConfigs() - || $this->isNonDocumentationPageInHiddenSubdirectory(); + || $this->isNonDocumentationPageInHiddenSubdirectory() + || ($frontMatterHidden === null && $this->hasNonHtmlOutput()); + } + + private function hasNonHtmlOutput(): bool + { + return ! str_ends_with($this->outputPath, '.html'); } protected function makePriority(): int diff --git a/packages/framework/tests/Feature/AutomaticNavigationConfigurationsTest.php b/packages/framework/tests/Feature/AutomaticNavigationConfigurationsTest.php index 9caffbcf199..33d8fa43991 100644 --- a/packages/framework/tests/Feature/AutomaticNavigationConfigurationsTest.php +++ b/packages/framework/tests/Feature/AutomaticNavigationConfigurationsTest.php @@ -97,6 +97,28 @@ public function testInMemoryPagesAreAddedToNavigationMenu() ]); } + public function testNonHtmlInMemoryPagesAreNotAddedToNavigationMenuByDefault() + { + $this->assertMenuEquals([], [ + new InMemoryPage('feed.xml'), + new InMemoryPage('robots.txt'), + ]); + } + + public function testNonHtmlInMemoryPagesCanBeExplicitlyAddedToNavigationMenu() + { + $this->assertMenuEquals(['Feed', 'Humans'], [ + new InMemoryPage('feed.xml', [ + 'navigation.visible' => true, + 'navigation.label' => 'Feed', + ]), + new InMemoryPage('humans.txt', [ + 'navigation.hidden' => false, + 'navigation.label' => 'Humans', + ]), + ]); + } + public function testMainNavigationDoesNotInclude404Page() { $this->assertMenuEquals([], [new MarkdownPage('404')]); diff --git a/packages/framework/tests/Unit/NavigationDataFactoryUnitTest.php b/packages/framework/tests/Unit/NavigationDataFactoryUnitTest.php index 5c6e8a5da94..d04d78c170d 100644 --- a/packages/framework/tests/Unit/NavigationDataFactoryUnitTest.php +++ b/packages/framework/tests/Unit/NavigationDataFactoryUnitTest.php @@ -247,6 +247,32 @@ public function testPageIsHiddenBasedOnNavigationConfiguration() $this->assertFalse($factory->makeHidden()); } + public function testNonHtmlPagesAreHiddenFromNavigationByDefault() + { + $factory = new NavigationConfigTestClass($this->makeCoreDataObject( + routeKey: 'feed.xml', + outputPath: 'feed.xml', + )); + + $this->assertTrue($factory->makeHidden()); + } + + public function testNonHtmlPagesCanBeExplicitlyShownInNavigation() + { + foreach ([ + ['navigation.visible' => true], + ['navigation.hidden' => false], + ] as $matter) { + $factory = new NavigationConfigTestClass($this->makeCoreDataObject( + routeKey: 'feed.xml', + outputPath: 'feed.xml', + matter: $matter, + )); + + $this->assertFalse($factory->makeHidden()); + } + } + public function testPageIsHiddenBasedOnSidebarConfigurationForDocumentationPage() { self::mockConfig(['docs.sidebar.exclude' => ['hiddenDocPage']]); @@ -374,9 +400,14 @@ public function testFrontMatterValueOverridesFilenamePrefixPriority() $this->assertSame(10, $factory->makePriority()); } - protected function makeCoreDataObject(string $identifier = '', string $routeKey = '', string $pageClass = MarkdownPage::class): CoreDataObject - { - return new CoreDataObject(new FrontMatter(), new Markdown(), $pageClass, $identifier, '', '', $routeKey); + protected function makeCoreDataObject( + string $identifier = '', + string $routeKey = '', + string $pageClass = MarkdownPage::class, + string $outputPath = 'page.html', + array $matter = [], + ): CoreDataObject { + return new CoreDataObject(new FrontMatter($matter), new Markdown(), $pageClass, $identifier, '', $outputPath, $routeKey); } } From fb8667981bf02915e465e6102ff6ccd19d5978fc Mon Sep 17 00:00:00 2001 From: Emma De Silva Date: Sat, 18 Jul 2026 17:47:39 +0200 Subject: [PATCH 107/117] Remove narrating generated page comments --- packages/framework/src/Foundation/HydeCoreExtension.php | 4 ---- 1 file changed, 4 deletions(-) diff --git a/packages/framework/src/Foundation/HydeCoreExtension.php b/packages/framework/src/Foundation/HydeCoreExtension.php index 00ee9712844..00e7ce6180e 100644 --- a/packages/framework/src/Foundation/HydeCoreExtension.php +++ b/packages/framework/src/Foundation/HydeCoreExtension.php @@ -104,7 +104,6 @@ public function discoverPages(PageCollection $collection): void } } - /** Add the generated sitemap page unless the route is user-defined. */ protected function discoverSitemapPage(PageCollection $collection): void { if (! $this->hasPageWithRouteKey($collection, 'sitemap.xml')) { @@ -115,7 +114,6 @@ protected function discoverSitemapPage(PageCollection $collection): void } } - /** Add the generated RSS feed page unless the route is user-defined. */ protected function discoverRssFeedPage(PageCollection $collection): void { $routeKey = RssFeedGenerator::getFilename(); @@ -128,7 +126,6 @@ protected function discoverRssFeedPage(PageCollection $collection): void } } - /** Add the generated robots.txt page unless the route is user-defined. */ protected function discoverRobotsTxtPage(PageCollection $collection): void { if (! $this->hasPageWithRouteKey($collection, 'robots.txt')) { @@ -139,7 +136,6 @@ protected function discoverRobotsTxtPage(PageCollection $collection): void } } - /** Add the generated llms.txt page unless the route is user-defined. */ protected function discoverLlmsTxtPage(PageCollection $collection): void { if (! $this->hasPageWithRouteKey($collection, 'llms.txt')) { From 5a8608ac2e25c8ead9f7eb92599a48ef17dba05a Mon Sep 17 00:00:00 2001 From: Emma De Silva Date: Sat, 18 Jul 2026 17:48:16 +0200 Subject: [PATCH 108/117] Remove empty test class comment --- packages/framework/tests/Feature/LlmsTxtGeneratorTest.php | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/framework/tests/Feature/LlmsTxtGeneratorTest.php b/packages/framework/tests/Feature/LlmsTxtGeneratorTest.php index 1b698627878..364dc059eb5 100644 --- a/packages/framework/tests/Feature/LlmsTxtGeneratorTest.php +++ b/packages/framework/tests/Feature/LlmsTxtGeneratorTest.php @@ -213,5 +213,4 @@ protected function generate(): string class LlmsTxtGeneratorTestPage extends MarkdownPage { - // } From b597d9008a244bee2a116d376b1d82f8d85f341a Mon Sep 17 00:00:00 2001 From: Emma De Silva Date: Sat, 18 Jul 2026 17:49:40 +0200 Subject: [PATCH 109/117] Centralize generated page registration --- .../src/Foundation/HydeCoreExtension.php | 51 ++++++++++--------- 1 file changed, 28 insertions(+), 23 deletions(-) diff --git a/packages/framework/src/Foundation/HydeCoreExtension.php b/packages/framework/src/Foundation/HydeCoreExtension.php index 00e7ce6180e..a8d6716567a 100644 --- a/packages/framework/src/Foundation/HydeCoreExtension.php +++ b/packages/framework/src/Foundation/HydeCoreExtension.php @@ -4,6 +4,7 @@ namespace Hyde\Foundation; +use Closure; use Hyde\Hyde; use Hyde\Pages\HtmlPage; use Hyde\Pages\BladePage; @@ -106,42 +107,46 @@ public function discoverPages(PageCollection $collection): void protected function discoverSitemapPage(PageCollection $collection): void { - if (! $this->hasPageWithRouteKey($collection, 'sitemap.xml')) { - $collection->addPage(new InMemoryPage( - 'sitemap.xml', - contents: fn (): string => app(SitemapGenerator::class)->generate()->getXml(), - )); - } + $this->addGeneratedPage( + $collection, + 'sitemap.xml', + fn (): string => app(SitemapGenerator::class)->generate()->getXml(), + ); } protected function discoverRssFeedPage(PageCollection $collection): void { - $routeKey = RssFeedGenerator::getFilename(); - - if (! $this->hasPageWithRouteKey($collection, $routeKey)) { - $collection->addPage(new InMemoryPage( - $routeKey, - contents: fn (): string => app(RssFeedGenerator::class)->generate()->getXml(), - )); - } + $this->addGeneratedPage( + $collection, + RssFeedGenerator::getFilename(), + fn (): string => app(RssFeedGenerator::class)->generate()->getXml(), + ); } protected function discoverRobotsTxtPage(PageCollection $collection): void { - if (! $this->hasPageWithRouteKey($collection, 'robots.txt')) { - $collection->addPage(new InMemoryPage( - 'robots.txt', - contents: fn (): string => app(RobotsTxtGenerator::class)->generate(), - )); - } + $this->addGeneratedPage( + $collection, + 'robots.txt', + fn (): string => app(RobotsTxtGenerator::class)->generate(), + ); } protected function discoverLlmsTxtPage(PageCollection $collection): void { - if (! $this->hasPageWithRouteKey($collection, 'llms.txt')) { + $this->addGeneratedPage( + $collection, + 'llms.txt', + fn (): string => app(LlmsTxtGenerator::class)->generate(), + ); + } + + protected function addGeneratedPage(PageCollection $collection, string $routeKey, Closure $contents): void + { + if (! $this->hasPageWithRouteKey($collection, $routeKey)) { $collection->addPage(new InMemoryPage( - 'llms.txt', - contents: fn (): string => app(LlmsTxtGenerator::class)->generate(), + $routeKey, + contents: $contents, )); } } From 458a8ceb5539057cee7cba614ed3fbbe80235951 Mon Sep 17 00:00:00 2001 From: Emma De Silva Date: Sat, 18 Jul 2026 23:20:55 +0200 Subject: [PATCH 110/117] Add planned test --- .../tests/RealtimeCompilerTest.php | 37 +++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/packages/realtime-compiler/tests/RealtimeCompilerTest.php b/packages/realtime-compiler/tests/RealtimeCompilerTest.php index 603f6c2a0f2..af1f320f7e9 100644 --- a/packages/realtime-compiler/tests/RealtimeCompilerTest.php +++ b/packages/realtime-compiler/tests/RealtimeCompilerTest.php @@ -347,6 +347,36 @@ public function testDocsSearchJsonRendersSearchIndexWithJsonContentType() Filesystem::unlink('_docs/index.md'); } + public function testVersionedDocsSearchJsonRendersSearchIndexWithJsonContentType() + { + $this->mockCompilerRoute('docs/1.x/search.json'); + Filesystem::ensureDirectoryExists('_docs/1.x'); + Filesystem::put('_docs/1.x/index.md', '# Hello World!'); + + try { + $router = new Router(new Request()); + + $this->bootRouterApplication($router); + config(['docs.versions' => ['1.x']]); + Hyde::boot(); + + $response = $router->handle(); + + $this->assertInstanceOf(Response::class, $response); + $this->assertNotInstanceOf(HtmlResponse::class, $response); + $this->assertSame(200, $response->statusCode); + $this->assertSame('OK', $response->statusMessage); + + $headers = $this->getResponseHeaders($response); + $this->assertSame('application/json', $headers['Content-Type']); + $this->assertSame((string) strlen($response->body), $headers['Content-Length']); + + $this->assertIsArray(json_decode($response->body, true)); + } finally { + Filesystem::deleteDirectory('_docs/1.x'); + } + } + public function testSitemapXmlRouteIsServedWithXmlContentType() { config(['hyde.url' => 'https://example.com']); @@ -734,6 +764,13 @@ protected function invokeOverrideSiteUrl(): void $method->invoke(new Router(new Request())); } + protected function bootRouterApplication(Router $router): void + { + $method = new ReflectionMethod(Router::class, 'bootApplication'); + $method->setAccessible(true); + $method->invoke($router); + } + protected function invokeGetContentType(InMemoryPage $page): string { $this->mockCompilerRoute('foo'); From 052a688d7951eec1854836c1cb3a53ee38111e90 Mon Sep 17 00:00:00 2001 From: Emma De Silva Date: Mon, 20 Jul 2026 20:44:44 +0200 Subject: [PATCH 111/117] Document generated non-HTML pages --- docs/advanced-features/in-memory-pages.md | 17 +++++ docs/digging-deeper/customization.md | 91 ++++++++++++++++++++--- 2 files changed, 99 insertions(+), 9 deletions(-) diff --git a/docs/advanced-features/in-memory-pages.md b/docs/advanced-features/in-memory-pages.md index f6431891913..eb20b7e149a 100644 --- a/docs/advanced-features/in-memory-pages.md +++ b/docs/advanced-features/in-memory-pages.md @@ -69,6 +69,19 @@ InMemoryPage::make( ); ``` +Non-HTML pages are also excluded from the sitemap by default. You can control sitemap inclusion for any page with +the `sitemap` front matter key. This same setting controls whether a page is listed in Hyde's generated `llms.txt`: + +```php +InMemoryPage::make( + 'api/schema.json', + matter: ['sitemap' => true], + contents: $schema, +); +``` + +For custom page classes, override `showInSitemap()` when the decision cannot be expressed as front matter. + Pass a closure when the contents should be generated lazily during compilation. The closure is invoked again for each compilation, which makes it useful for pages generated from the current application state. @@ -173,6 +186,10 @@ class AppServiceProvider extends ServiceProvider The page will be written to `_site/hello.html` and can be referenced using the `hello` route key. +Hyde's generated `sitemap.xml`, RSS feed, `robots.txt`, and `llms.txt` are also in-memory pages. Registering your own +page with one of those route keys during booting replaces Hyde's generated page, allowing complete control over the +file. For the RSS feed, use the filename configured in `hyde.rss.filename`. + ### In a package extension Package extensions can register the page directly in the page discovery callback. Pages added at this stage are diff --git a/docs/digging-deeper/customization.md b/docs/digging-deeper/customization.md index ab067fb53f4..acb8f6d3c89 100644 --- a/docs/digging-deeper/customization.md +++ b/docs/digging-deeper/customization.md @@ -127,26 +127,99 @@ redirects: docs/old-guide: docs/new-guide ``` -### RSS feed generation +### Generated discovery files -When enabled, an RSS feed containing all your Markdown blog posts will be generated when you compile your static site. -Here are the default settings: +Hyde generates a sitemap, RSS feed, `robots.txt`, and `llms.txt` as regular in-memory pages. They are registered routes, +so they are included in a normal site build, shown by `php hyde route:list`, recorded in the build manifest, and served +by `php hyde serve`. Generated non-HTML pages are excluded from navigation and from the sitemap by default. + +The sitemap and llms.txt require a site URL so they can contain absolute links. RSS additionally requires Markdown blog +posts and the SimpleXML extension; sitemap generation also requires SimpleXML. Robots.txt has no site URL requirement, +and includes a `Sitemap:` line only when the sitemap is available. + +Here are the related default settings: ```php // filepath config/hyde.php +'generate_sitemap' => true, + 'rss' => [ - // Should the RSS feed be generated? 'enabled' => true, - - // What filename should the RSS file use? 'filename' => 'feed.xml', - - // The channel description. 'description' => env('SITE_NAME', 'HydePHP').' RSS Feed', ], + +'robots' => [ + 'enabled' => true, + 'disallow' => [ + // '/private', + // '/*.pdf$', + ], +], + +'llms' => [ + 'enabled' => true, + 'description' => null, +], ``` ->warning Note that this feature requires that a `site_url` is set! +Each robots.txt `disallow` value is written verbatim as a `Disallow:` rule, allowing patterns such as `/*.pdf$`. +The llms.txt description becomes its introductory blockquote. The file groups published pages by type and uses each +page's `abstract` front matter, falling back to `description`, as the link description. A page is included in llms.txt +when it is included in the sitemap, so `sitemap: false` excludes it from both indexes. This does not prevent an AI +crawler from accessing the page; use robots.txt rules for crawler access control. + +>warning Llms.txt is an emerging standard. Hyde may change the generated format in minor or patch releases as the +> specification evolves. Disable it with `hyde.llms.enabled` or replace the page if you need a fixed format. + +#### Customizing generated output + +For small output changes, extend the corresponding generator and bind your implementation in a service provider. Hyde +resolves generators from the service container when the page is compiled, after route discovery is complete: + +```php +// filepath app/Providers/AppServiceProvider.php + +use Hyde\Framework\Features\TextGenerators\RobotsTxtGenerator; +use Illuminate\Support\ServiceProvider; + +class CustomRobotsTxtGenerator extends RobotsTxtGenerator +{ + public function generate(): string + { + return parent::generate()."\nHost: example.com\n"; + } +} + +class AppServiceProvider extends ServiceProvider +{ + public function register(): void + { + $this->app->bind(RobotsTxtGenerator::class, CustomRobotsTxtGenerator::class); + } +} +``` + +The available generators are `SitemapGenerator`, `RssFeedGenerator`, `RobotsTxtGenerator`, and `LlmsTxtGenerator` in +their respective `Hyde\Framework\Features\XmlGenerators` and `Hyde\Framework\Features\TextGenerators` namespaces. +The protected `LlmsTxtGenerator::sections()` method can be overridden to change its page groups. + +To replace a generated file completely, register your own [`InMemoryPage`](in-memory-pages) with +the same route key during a kernel booting callback or extension discovery. User-defined pages take precedence over +Hyde's generators, even when the corresponding feature is disabled. For example: + +```php +use Hyde\Hyde; +use Hyde\Pages\InMemoryPage; +use Hyde\Foundation\HydeKernel; + +Hyde::booting(function (HydeKernel $kernel): void { + $kernel->pages()->addPage(InMemoryPage::make( + 'robots.txt', + contents: "User-agent: *\nDisallow: /private\n", + )); +}); +``` ### Authors From 9e26a6acb053f888cc23ef70d6ba9143934118a1 Mon Sep 17 00:00:00 2001 From: Emma De Silva Date: Mon, 20 Jul 2026 20:44:51 +0200 Subject: [PATCH 112/117] Update generated page command documentation --- docs/advanced-features/build-tasks.md | 7 ++++--- docs/getting-started/console-commands.md | 10 ++++++++++ 2 files changed, 14 insertions(+), 3 deletions(-) diff --git a/docs/advanced-features/build-tasks.md b/docs/advanced-features/build-tasks.md index b85e9c0c4e7..abb9b8b196c 100644 --- a/docs/advanced-features/build-tasks.md +++ b/docs/advanced-features/build-tasks.md @@ -1,5 +1,5 @@ --- -abstract: "The Build Task API lets you hook into HydePHP's build process to run custom logic, or override built-in tasks like sitemap and RSS generation, whenever the site compiles." +abstract: "The Build Task API lets you hook custom logic into HydePHP's build process before or after the site's pages are compiled." --- # Custom Build Tasks @@ -9,9 +9,10 @@ abstract: "The Build Task API lets you hook into HydePHP's build process to run The Build Task API offers a simple way to hook into the build process. The build tasks are very powerful and allow for limitless customizability. -The built-in Hyde features like sitemap generation and RSS feeds are created using tasks like these. Maybe you want to create your own, to for example upload the site to FTP or copy the files to a public directory? -You can also overload the built-in tasks to customize them to your needs. + +Sitemaps, RSS feeds, robots.txt, and llms.txt are generated as regular pages rather than build tasks. To customize +those files, see [Generated discovery files](customization#generated-discovery-files). ## Good to know before you start diff --git a/docs/getting-started/console-commands.md b/docs/getting-started/console-commands.md index 2f4c8f55be7..32194760945 100644 --- a/docs/getting-started/console-commands.md +++ b/docs/getting-started/console-commands.md @@ -116,6 +116,11 @@ php hyde build:rss Generate the RSS feed +This command compiles the registered RSS page. The page is normally registered when RSS generation is enabled, a site +URL is configured, at least one Markdown post exists, and the SimpleXML extension is available. The command exits with +an error if no RSS page is registered. A custom page registered at the configured `hyde.rss.filename` route is compiled +instead of Hyde's generated page. + ## Generate the `docs/search.json` file @@ -136,6 +141,11 @@ php hyde build:sitemap Generate the `sitemap.xml` file +This command compiles the registered `sitemap.xml` page. The page is normally registered when sitemap generation is +enabled, a site URL is configured, and the SimpleXML extension is available. The command exits with an error if no +sitemap page is registered. A custom page registered at the `sitemap.xml` route is compiled instead of Hyde's generated +page. + ## Scaffold a new Markdown, Blade, or documentation page file From f653146e2436ae62ff93743eb3c67b3d543bf28f Mon Sep 17 00:00:00 2001 From: Emma De Silva Date: Mon, 20 Jul 2026 20:47:21 +0200 Subject: [PATCH 113/117] Complete non-HTML pages epic --- EPIC_NON_HTML_PAGES.md | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/EPIC_NON_HTML_PAGES.md b/EPIC_NON_HTML_PAGES.md index 4ab5bf22a65..346c6474ed9 100644 --- a/EPIC_NON_HTML_PAGES.md +++ b/EPIC_NON_HTML_PAGES.md @@ -648,7 +648,7 @@ Implementation notes (branch `v3/non-html-pages-llms-txt`): > 3. **A long explanatory comment in a config stub is a design smell,** not diligence. If > an option needs paragraphs to explain, the option is usually the problem. -### PR 8 — Documentation & release notes +### PR 8 — Documentation & release notes ✅ Implemented - Document in-code virtual pages, `sitemap: false` front matter, robots/llms config, the container-rebind customization tier for generated pages, and the "user-defined @@ -657,6 +657,23 @@ Implementation notes (branch `v3/non-html-pages-llms-txt`): serve support for sitemap/RSS), breaking changes (build task classes removed/relocated, search.json removed from sitemaps). +Implementation notes (branch `v3/non-html-pages-documentation`): + +- The public InMemoryPage and customization guides document exact non-HTML paths, + navigation and sitemap defaults, all four generated pages, their feature conditions, + robots/llms configuration, generator rebinding, and user-defined route precedence. +- The Build Tasks guide no longer describes sitemap/RSS generation as post-build tasks. + The console command guide now records that `build:sitemap` and `build:rss` compile + registered pages and fail when no matching page is registered. +- The audit found behavior worth documenting beyond the original checklist: llms.txt + reuses sitemap inclusion, its format has no minor/patch compatibility promise while + the proposal evolves, robots.txt controls crawler access while llms.txt does not, + generated pages are hidden from automatic navigation, and sitemap/RSS registration + depends on SimpleXML in addition to their documented content prerequisites. +- `HYDEPHP_V3_PLANNING.md` and `UPGRADE.md` already contain the feature, breaking-change, + and migration entries added with PRs 1–7; this PR verified them rather than duplicating + those notes. + ## Out of scope (noted for later) - Filesystem autodiscovery for verbatim or Blade-processed text files From 0f5059d754b905e8d493de98a7466619ac3ff84f Mon Sep 17 00:00:00 2001 From: Emma De Silva Date: Sun, 26 Jul 2026 19:00:09 +0200 Subject: [PATCH 114/117] Default llms.txt to disabled MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Unlike the sitemap, RSS feed, and robots.txt, llms.txt is not an established web convention that fixes a compatibility or crawler-control problem — it is an emerging, format-unstable proposal, and publishing it is a deliberate invitation for AI services to read the site rather than a neutral discovery mechanism. That argues for an opt-in default rather than the opt-out the feature originally shipped with. Co-Authored-By: Claude Sonnet 5 --- config/hyde.php | 11 ++++++----- packages/framework/config/hyde.php | 11 ++++++----- packages/framework/src/Facades/Features.php | 2 +- 3 files changed, 13 insertions(+), 11 deletions(-) diff --git a/config/hyde.php b/config/hyde.php index 67510e555f3..2c0f6380926 100644 --- a/config/hyde.php +++ b/config/hyde.php @@ -166,19 +166,20 @@ | | When enabled, an llms.txt file listing your pages will be generated when | you compile your static site, helping AI services and agents find your - | content. Set this to false if you would rather they did not. + | content. Set this to true if you would like to publish one. | | This feature requires that a site base URL has been set. | - | Note that llms.txt is an emerging standard which is still subject to - | change, so we may need to change the format of the generated file - | in future minor and patch releases in order to follow the spec. + | Unlike robots.txt, llms.txt is not an established web convention: it's + | an emerging proposal that may still change in minor and patch releases, + | and publishing it is a deliberate invitation for AI services to read + | your site, so it defaults to off until you decide you want that. | */ 'llms' => [ // Should the llms.txt file be generated? - 'enabled' => true, + 'enabled' => false, // An optional summary of your site, added as the introductory blockquote. 'description' => null, diff --git a/packages/framework/config/hyde.php b/packages/framework/config/hyde.php index 67510e555f3..2c0f6380926 100644 --- a/packages/framework/config/hyde.php +++ b/packages/framework/config/hyde.php @@ -166,19 +166,20 @@ | | When enabled, an llms.txt file listing your pages will be generated when | you compile your static site, helping AI services and agents find your - | content. Set this to false if you would rather they did not. + | content. Set this to true if you would like to publish one. | | This feature requires that a site base URL has been set. | - | Note that llms.txt is an emerging standard which is still subject to - | change, so we may need to change the format of the generated file - | in future minor and patch releases in order to follow the spec. + | Unlike robots.txt, llms.txt is not an established web convention: it's + | an emerging proposal that may still change in minor and patch releases, + | and publishing it is a deliberate invitation for AI services to read + | your site, so it defaults to off until you decide you want that. | */ 'llms' => [ // Should the llms.txt file be generated? - 'enabled' => true, + 'enabled' => false, // An optional summary of your site, added as the introductory blockquote. 'description' => null, diff --git a/packages/framework/src/Facades/Features.php b/packages/framework/src/Facades/Features.php index 8235fbd3c07..b46519c5ccc 100644 --- a/packages/framework/src/Facades/Features.php +++ b/packages/framework/src/Facades/Features.php @@ -128,7 +128,7 @@ public static function hasRobotsTxt(): bool public static function hasLlmsTxt(): bool { return Hyde::hasSiteUrl() - && Config::getBool('hyde.llms.enabled', true); + && Config::getBool('hyde.llms.enabled', false); } /** From 214f0ea524967c187c07cbeb9358baa60982adf0 Mon Sep 17 00:00:00 2001 From: Emma De Silva Date: Sun, 26 Jul 2026 19:00:14 +0200 Subject: [PATCH 115/117] Update llms.txt tests for the opt-in default Cover the new default-off behavior and require explicit opt-in for the existing registration/build tests that previously relied on the feature being on by default. Co-Authored-By: Claude Sonnet 5 --- .../tests/Feature/ConfigurableFeaturesTest.php | 13 +++++++------ .../framework/tests/Feature/LlmsTxtPageTest.php | 4 +++- 2 files changed, 10 insertions(+), 7 deletions(-) diff --git a/packages/framework/tests/Feature/ConfigurableFeaturesTest.php b/packages/framework/tests/Feature/ConfigurableFeaturesTest.php index 51dc4f489bc..9c2f98386d5 100644 --- a/packages/framework/tests/Feature/ConfigurableFeaturesTest.php +++ b/packages/framework/tests/Feature/ConfigurableFeaturesTest.php @@ -75,23 +75,24 @@ public function testHasRobotsTxtReturnsFalseWhenDisabledInConfig() $this->assertFalse(Features::hasRobotsTxt()); } - public function testHasLlmsTxtReturnsTrueByDefaultWhenHydeHasBaseUrl() + public function testHasLlmsTxtReturnsFalseByDefaultEvenWhenHydeHasBaseUrl() { $this->withSiteUrl(); - $this->assertTrue(Features::hasLlmsTxt()); + $this->assertFalse(Features::hasLlmsTxt()); } public function testHasLlmsTxtReturnsFalseIfHydeDoesNotHaveBaseUrl() { - config(['hyde.url' => '']); + $this->withSiteUrl(); + config(['hyde.llms.enabled' => true, 'hyde.url' => '']); $this->assertFalse(Features::hasLlmsTxt()); } - public function testHasLlmsTxtReturnsFalseWhenDisabledInConfig() + public function testHasLlmsTxtReturnsTrueWhenEnabledInConfigAndHydeHasBaseUrl() { $this->withSiteUrl(); - config(['hyde.llms.enabled' => false]); - $this->assertFalse(Features::hasLlmsTxt()); + config(['hyde.llms.enabled' => true]); + $this->assertTrue(Features::hasLlmsTxt()); } public function testHasThemeToggleButtonsReturnsTrueWhenDarkmodeEnabledAndConfigTrue() diff --git a/packages/framework/tests/Feature/LlmsTxtPageTest.php b/packages/framework/tests/Feature/LlmsTxtPageTest.php index f1f58c32ec3..eb489535d5b 100644 --- a/packages/framework/tests/Feature/LlmsTxtPageTest.php +++ b/packages/framework/tests/Feature/LlmsTxtPageTest.php @@ -29,6 +29,8 @@ protected function setUp(): void parent::setUp(); $this->withSiteUrl(); + + config(['hyde.llms.enabled' => true]); } protected function tearDown(): void @@ -38,7 +40,7 @@ protected function tearDown(): void parent::tearDown(); } - public function testLlmsTxtPageIsRegisteredAsRouteByDefault() + public function testLlmsTxtPageIsRegisteredAsRouteWhenEnabled() { $this->assertTrue(Routes::exists('llms.txt')); From 31037eca7dc4816147c7e3c86eba4bd16baa3443 Mon Sep 17 00:00:00 2001 From: Emma De Silva Date: Sun, 26 Jul 2026 19:00:19 +0200 Subject: [PATCH 116/117] Document the llms.txt default-off reversal Record the reasoning in the epic (PR 7 deviation), the v3 release notes, the upgrade guide, and the customization docs: llms.txt now needs an explicit hyde.llms.enabled opt-in, unlike the sitemap, RSS feed, and robots.txt. Co-Authored-By: Claude Sonnet 5 --- EPIC_NON_HTML_PAGES.md | 25 ++++++++++++------- HYDEPHP_V3_PLANNING.md | 4 ++-- UPGRADE.md | 36 ++++++++++++++-------------- docs/digging-deeper/customization.md | 7 ++++-- 4 files changed, 42 insertions(+), 30 deletions(-) diff --git a/EPIC_NON_HTML_PAGES.md b/EPIC_NON_HTML_PAGES.md index 346c6474ed9..bdf6779aa25 100644 --- a/EPIC_NON_HTML_PAGES.md +++ b/EPIC_NON_HTML_PAGES.md @@ -529,14 +529,23 @@ Implementation notes (branch `v3/non-html-pages-llms-txt`): from navigation, D3-excluded from the sitemap, and both user override paths verified end-to-end through the real `build` command. No `build:llms` command, for the same reason PR 6 added no `build:robots`. -- **Default on, with the opt-out as the documented choice.** `Features::hasLlmsTxt()` - reads `hyde.llms.enabled` (default `true`), so the file ships by default. The - decision the epic demanded: llms.txt lists only already-published pages and surfaces - nothing the sitemap does not, sitemap/RSS/robots are all on by default, and the - actual crawler control plane is robots.txt, not llms.txt — so an opt-*in* would - bury the feature for the majority to protect a minority that a `false` in the config - serves just as well. The opt-out is called out in the config stub, the release notes, - and its own UPGRADE.md step rather than being left for users to discover. +- **Reversed to default off (post-implementation review).** `Features::hasLlmsTxt()` + reads `hyde.llms.enabled` (default `false`). The PR originally shipped default `true`, + reasoning that llms.txt lists only already-published pages and surfaces nothing the + sitemap does not, and that sitemap/RSS/robots are all on by default — so an opt-*in* + would bury the feature for the majority to protect a minority that a `false` in the + config serves just as well. That argument proves too little: sitemap.xml, RSS, and + robots.txt are each an established web convention that fixes a real compatibility or + crawler-control problem, so their on-by-default posture costs a site nothing it wasn't + already exposing through routing and search-engine norms. llms.txt has no such + precedent — it is, by the epic's and the config stub's own description, "an emerging + proposal" whose format may still change in a minor or patch release, and publishing it + is explicitly "a deliberate invitation" for AI services to read the site, not a + neutral discovery mechanism like a sitemap. "Bury the feature" describes a marketing + cost, not a reason to default an unstable, AI-specific invitation to on. The opt-in is + called out in the config stub, the release notes, and its own UPGRADE.md step, mirroring + how the (default-on) `hyde.robots.enabled` opt-out is documented, so the choice is a + first-class one either way rather than something a user has to discover. - **Emerging-standard caveat, recorded deliberately.** llms.txt is a proposal, not a ratified standard, so the generated *format* carries no backwards-compatibility promise: we expect to change it in minor and patch releases as the spec moves. This diff --git a/HYDEPHP_V3_PLANNING.md b/HYDEPHP_V3_PLANNING.md index b36de9c298e..627131b9d85 100644 --- a/HYDEPHP_V3_PLANNING.md +++ b/HYDEPHP_V3_PLANNING.md @@ -29,9 +29,9 @@ Having this document in code lets us know the devlopment state at any given poin - Pages can now control their own sitemap inclusion. Set `sitemap: false` in a page's front matter to exclude it from the generated `sitemap.xml`, or override the new `HydePage::showInSitemap()` method in custom page classes. Pages compiled to non-HTML output files (like `robots.txt`) are excluded by default, and `sitemap: true` front matter opts such a page back in. - The sitemap and RSS feed are now first-class pages instead of post-build side effects: when the respective feature is enabled, `sitemap.xml` and the RSS feed (`feed.xml`, or the configured `hyde.rss.filename`) are registered as routes, so they are served by `hyde serve`, listed in `route:list`, included in the build manifest, and compiled through the standard site build. The output can be customized by rebinding the `SitemapGenerator` or `RssFeedGenerator` class in the service container, and registering a user-defined page with the same route key (from a service provider, booting callback, or extension) replaces the generated page entirely. - Hyde now generates a `robots.txt` file for the site out of the box. The default output allows all crawlers, and links to the sitemap when that feature is enabled. Rule values listed in the new `hyde.robots.disallow` configuration array are written verbatim as `Disallow` rules (so wildcard patterns are supported), and the file can be disabled entirely with `hyde.robots.enabled`. The page is wired like the sitemap and RSS feed: it is a real route (served by `hyde serve`, listed in `route:list`, included in the build manifest), the output can be customized by rebinding the `RobotsTxtGenerator` class in the service container, and a user-defined `robots.txt` page replaces the generated one entirely. -- Hyde now generates an [`llms.txt`](https://llmstxt.org/) file for the site out of the box, so that AI services and agents can discover your content without crawling your rendered HTML. The file uses the site name as its heading, the optional `hyde.llms.description` as its summary blockquote, and lists your pages as Markdown links, grouped into a section for each page type (Pages, Documentation, and Blog Posts). Each link is described by the page's `abstract` front matter, falling back to its `description`, and pages are listed in the same order the sitemap lists them in, so numerically prefixed source files keep their intended reading order. A page is listed when it is included in the sitemap, as both files are machine-readable indexes of your published pages, so `sitemap: false` front matter leaves a page out of both and no new front matter key is introduced. The file indexes only material you already publish and grants no access to anything private. It requires a site base URL since it needs absolute links, and can be disabled with `hyde.llms.enabled`. The page is wired like the sitemap, RSS feed, and robots.txt: it is a real route (served by `hyde serve`, listed in `route:list`, included in the build manifest), the output can be customized by rebinding the `LlmsTxtGenerator` class in the service container, and a user-defined `llms.txt` page replaces the generated one entirely. +- Hyde can now generate an [`llms.txt`](https://llmstxt.org/) file for the site, opt-in, so that AI services and agents can discover your content without crawling your rendered HTML. Set `hyde.llms.enabled` to `true` to publish it. The file uses the site name as its heading, the optional `hyde.llms.description` as its summary blockquote, and lists your pages as Markdown links, grouped into a section for each page type (Pages, Documentation, and Blog Posts). Each link is described by the page's `abstract` front matter, falling back to its `description`, and pages are listed in the same order the sitemap lists them in, so numerically prefixed source files keep their intended reading order. A page is listed when it is included in the sitemap, as both files are machine-readable indexes of your published pages, so `sitemap: false` front matter leaves a page out of both and no new front matter key is introduced. The file indexes only material you already publish and grants no access to anything private, but publishing it is still a deliberate invitation for AI services to read your site, so unlike the sitemap, RSS feed, and robots.txt, it defaults to off. It requires a site base URL since it needs absolute links. The page is wired like the sitemap, RSS feed, and robots.txt: once enabled, it is a real route (served by `hyde serve`, listed in `route:list`, included in the build manifest), the output can be customized by rebinding the `LlmsTxtGenerator` class in the service container, and a user-defined `llms.txt` page replaces the generated one entirely. - Please note that llms.txt is an emerging standard which is still subject to change, and we are unable to make a backwards compatibility promise while implementing against a moving specification. We expect to change the format of the generated file in minor and patch releases as the standard evolves. We still think that shipping this is better than nothing, assuming you want AI services to read your site — and if you would rather they did not, set `hyde.llms.enabled` to `false` to skip the file. + Please note that llms.txt is an emerging standard which is still subject to change, and we are unable to make a backwards compatibility promise while implementing against a moving specification. We expect to change the format of the generated file in minor and patch releases as the standard evolves. ### Feature Changes diff --git a/UPGRADE.md b/UPGRADE.md index 5a4343f9cb4..906210e8975 100644 --- a/UPGRADE.md +++ b/UPGRADE.md @@ -380,23 +380,23 @@ disable the feature with `hyde.robots.enabled => false`, or move the contents in `robots.txt` page (which replaces the generated one, using the same registration pattern as the sitemap example above). Crawl rules can be added to the `hyde.robots.disallow` configuration array without any custom code. -## Step 8: Decide Whether to Publish the New llms.txt - -Hyde now generates an [`llms.txt`](https://llmstxt.org/) file by default, indexing your site's content for AI -services and agents. It requires a site base URL, since the file links to your pages with absolute URLs, so -sites without one are unaffected. The file indexes only material you already publish — it lists nothing your -sitemap does not, and grants no access to anything private — but it is a deliberate invitation for AI services -to read your site. That is a choice worth making consciously: if you would rather not extend that invitation, -set `hyde.llms.enabled` to `false`. Note that leaving pages out of this file does not stop AI crawlers from -reading them; crawler access is governed by your `robots.txt`, not by llms.txt. - -If you do keep it, it needs no configuration. Pages are grouped into a section per page type and listed in the -same order as your sitemap, and each link is described by the page's `abstract` front matter, falling back to -its `description`, so filling those in improves the file. A page is listed when it is included in the sitemap, -so anything already carrying `sitemap: false` stays out of this file too — there is no separate front matter key -to learn. As with the sitemap and robots.txt, you can replace the file wholesale by registering your own -`llms.txt` page, or adjust the sections and output by extending the `LlmsTxtGenerator` class and rebinding it in -the service container. +## Step 8: Optionally Publish the New llms.txt + +Hyde can now generate an [`llms.txt`](https://llmstxt.org/) file, indexing your site's content for AI services +and agents. Unlike the sitemap, RSS feed, and robots.txt, this is opt-in — set `hyde.llms.enabled` to `true` to +publish it. Publishing it is a deliberate invitation for AI services to read your site, so no action is needed +if you'd rather not extend that. Note that leaving pages out of this file does not stop AI crawlers from reading +them either way; crawler access is governed by your `robots.txt`, not by llms.txt. + +If you enable it, it needs no further configuration. It requires a site base URL, since the file links to your +pages with absolute URLs. Pages are grouped into a section per page type and listed in the same order as your +sitemap, and each link is described by the page's `abstract` front matter, falling back to its `description`, +so filling those in improves the file. A page is listed when it is included in the sitemap — the file indexes +only material you already publish, listing nothing your sitemap does not and granting no access to anything +private — so anything already carrying `sitemap: false` stays out of this file too, and there is no separate +front matter key to learn. As with the sitemap and robots.txt, you can replace the file wholesale by registering +your own `llms.txt` page, or adjust the sections and output by extending the `LlmsTxtGenerator` class and +rebinding it in the service container. Be aware that llms.txt is an emerging standard which is still subject to change. We cannot make a backwards compatibility promise for the generated output while the specification is still moving, and we expect to change @@ -452,7 +452,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 a generator container rebind or a user-defined page - [ ] Confirmed the new generated `robots.txt` does not conflict with an existing one, or disabled it with `hyde.robots.enabled` -- [ ] Decided whether to publish the new generated `llms.txt` for AI services, or disabled it with `hyde.llms.enabled` +- [ ] Decided whether to opt in to the new generated `llms.txt` for AI services with `hyde.llms.enabled` - [ ] Renamed `$fileExtension`, `fileExtension()`, and `setFileExtension()` to `$sourceExtension`, `sourceExtension()`, and `setSourceExtension()` in custom page classes and call sites ## Troubleshooting diff --git a/docs/digging-deeper/customization.md b/docs/digging-deeper/customization.md index acb8f6d3c89..a90fc214fee 100644 --- a/docs/digging-deeper/customization.md +++ b/docs/digging-deeper/customization.md @@ -158,7 +158,7 @@ Here are the related default settings: ], 'llms' => [ - 'enabled' => true, + 'enabled' => false, 'description' => null, ], ``` @@ -169,8 +169,11 @@ page's `abstract` front matter, falling back to `description`, as the link descr when it is included in the sitemap, so `sitemap: false` excludes it from both indexes. This does not prevent an AI crawler from accessing the page; use robots.txt rules for crawler access control. +Unlike the sitemap, RSS feed, and robots.txt, llms.txt is disabled by default: publishing it is a deliberate +invitation for AI services to read your site, so set `hyde.llms.enabled` to `true` to opt in. + >warning Llms.txt is an emerging standard. Hyde may change the generated format in minor or patch releases as the -> specification evolves. Disable it with `hyde.llms.enabled` or replace the page if you need a fixed format. +> specification evolves. Replace the page if you need a fixed format. #### Customizing generated output From 792f7130741ef141a1f8f2767e84ac32b56cb6cd Mon Sep 17 00:00:00 2001 From: Emma De Silva Date: Thu, 30 Jul 2026 14:12:22 +0200 Subject: [PATCH 117/117] Update tests --- packages/realtime-compiler/tests/RealtimeCompilerTest.php | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/packages/realtime-compiler/tests/RealtimeCompilerTest.php b/packages/realtime-compiler/tests/RealtimeCompilerTest.php index af1f320f7e9..fee3eacb25a 100644 --- a/packages/realtime-compiler/tests/RealtimeCompilerTest.php +++ b/packages/realtime-compiler/tests/RealtimeCompilerTest.php @@ -447,8 +447,12 @@ public function testLlmsTxtRouteIsServedWithPlainTextContentType() { $this->mockCompilerRoute('llms.txt'); - $kernel = new HttpKernel(); - $response = $kernel->handle(new Request()); + $router = new Router(new Request()); + $this->bootRouterApplication($router); + config(['hyde.llms.enabled' => true, 'hyde.url' => 'https://example.com']); + Hyde::boot(); + + $response = $router->handle(); $this->assertInstanceOf(Response::class, $response); $this->assertNotInstanceOf(HtmlResponse::class, $response);