Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
131 changes: 59 additions & 72 deletions EPIC_NON_HTML_PAGES.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -76,35 +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: 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, 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.
>
> 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`.
### 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

Expand All @@ -117,17 +116,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()`)
Expand Down Expand Up @@ -254,10 +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. `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 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.
Expand All @@ -270,16 +262,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.
- 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`):
Expand All @@ -299,15 +290,13 @@ 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, 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.

### PR 2 — Realtime compiler: route-first resolution for non-HTML paths ✅ Implemented

Expand Down Expand Up @@ -373,11 +362,10 @@ 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
the built `sitemap.xml`, guarding the D3 resolved-output-path constraint
`BaseHydePageUnitTest` contract.
- 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
Expand Down Expand Up @@ -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 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 —
Expand Down Expand Up @@ -478,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` uses the exact-path construction mode — `feed.rss` and an
extensionless name therefore work without an inference override.
(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
Expand Down Expand Up @@ -536,7 +523,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.
Expand Down Expand Up @@ -659,8 +646,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
Expand Down
Loading