diff --git a/EPIC_NON_HTML_PAGES.md b/EPIC_NON_HTML_PAGES.md
index a2459a54d7e..6097e3dca58 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,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
@@ -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()`)
@@ -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.
@@ -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`):
@@ -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
@@ -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
@@ -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 —
@@ -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
@@ -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.
@@ -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
diff --git a/HYDEPHP_V3_PLANNING.md b/HYDEPHP_V3_PLANNING.md
index 651a4bc77c2..80df700d147 100644
--- a/HYDEPHP_V3_PLANNING.md
+++ b/HYDEPHP_V3_PLANNING.md
@@ -23,7 +23,7 @@ Having this document in code lets us know the devlopment state at any given poin
- Added native support for versioned documentation pages. Register versions in the new `docs.versions` configuration option, and store the pages for each version in a matching subdirectory of the documentation source directory (like `_docs/1.x` and `_docs/2.x`). Each version is compiled to a matching subdirectory of the documentation output directory, and gets its own sidebar, search index, and search page. A version switcher dropdown is shown in the documentation sidebar, the main navigation links to the default version's index page, and a redirect page is generated at the documentation root pointing to the default version. Sidebar and search configuration entries (`docs.sidebar.order`, `docs.sidebar.labels`, `docs.sidebar.exclude`, and `docs.exclude_from_search`) match version-agnostic identifiers and route keys, so a single entry applies to the page in every version, while full versioned keys allow version-specific overrides. Enabling the feature is all or nothing: documentation source files stored outside the version directories are ignored, so pages that should live at the documentation root belong in the normal page source directory (like `_pages/docs/index.md`). Versioning is disabled by default, and single-version sites are unaffected. ([#2516](https://github.com/hydephp/develop/pull/2516))
- Redirects can now be declared as source and destination path pairs in the `hyde.redirects` configuration array. Hyde registers them with the kernel, includes them in `route:list`, and generates them through the normal site build.
- Added Blade Blocks for rendering Blade and Blade components from fenced code blocks in Markdown pages. The supported directives are `blade render` and `blade component(name)`, and the feature is controlled by `markdown.enable_blade`. ([#2504](https://github.com/hydephp/develop/pull/2504))
-- 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.
+- `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.
@@ -48,7 +48,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 011a83e1c51..1576c678d44 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 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
+use Hyde\Pages\InMemoryPage;
+
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
```
@@ -207,8 +205,8 @@ 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));
+Hyde::kernel()->booting(function ($kernel) use ($myXml): void {
+ $kernel->pages()->addPage(InMemoryPage::make('sitemap.xml', contents: $myXml));
});
```
@@ -254,8 +252,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/_data/partials/hyde-pages-api/in-memory-page-methods.md b/docs/_data/partials/hyde-pages-api/in-memory-page-methods.md
index 5f94bbf096d..249c6a9cf32 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()`
@@ -11,14 +11,14 @@ Static alias for the constructor.
InMemoryPage::make(string $identifier, Hyde\Markdown\Models\FrontMatter|array $matter, string $contents, string $view): static
```
-#### `file()`
+#### `outputPath()`
-Create an in-memory page whose identifier is used as the exact output path.
+Qualify a page identifier into a target output file path.
-The output path must be a relative file path contained within the site output directory.
+Identifiers with a file extension are used verbatim, while identifiers without an extension are compiled to HTML files.
```php
-InMemoryPage::file(string $outputPath, Hyde\Markdown\Models\FrontMatter|array $matter, string $contents, string $view): static
+InMemoryPage::outputPath(string $identifier): string
```
#### `__construct()`
@@ -27,24 +27,15 @@ Create a new in-memory/virtual page instance.
The in-memory page class offers two content options. You can either pass a string to the $contents parameter, Hyde will then save that literally as the page's contents. Alternatively, you can pass a view name to the $view parameter, and Hyde will use that view to render the page contents with the supplied front matter during the static site build process.
-Note that $contents take precedence over $view, so if you pass both, only $contents will be used. You can also register a macro with the name 'compile' to overload the default compile method. Normal construction uses HTML page semantics; use the `file()` constructor to create an exact-path file page.
+Note that $contents take precedence over $view, so if you pass both, only $contents will be used. You can also register a macro with the name 'compile' to overload the default compile method.
-If the identifier for an in-memory page is "foo/bar" the page will be saved to "_site/foo/bar.html". You can then also use the route helper to get a link to it by using the route key "foo/bar". Take note that the identifier must be unique to prevent overwriting other pages. all this data will be passed to the view rendering engine.
+If the identifier for an in-memory page is "foo/bar" the page will be saved to "_site/foo/bar.html". If the identifier already has an extension, such as "robots.txt", it is used as the output path unchanged. You can then also use the route helper to get a link to it by using the route key "foo/bar". Take note that the identifier must be unique to prevent overwriting other pages. all this data will be passed to the view rendering engine.
- **Parameter $view:** The view key or Blade file for the view to use to render the page contents.
- **Parameter $matter:** The front matter of the page. When using the Blade view rendering option,
-- **Parameter $exactOutputPath:** Whether to validate and use the identifier as an exact output path. Prefer the `file()` constructor for this mode.
```php
-$page = new InMemoryPage(string $identifier, \Hyde\Markdown\Models\FrontMatter|array $matter, string $contents, string $view, bool $exactOutputPath): void
-```
-
-#### `getOutputPath()`
-
-Get the path where the compiled page will be saved.
-
-```php
-$page->getOutputPath(): string
+$page = new InMemoryPage(string $identifier, \Hyde\Markdown\Models\FrontMatter|array $matter, string $contents, string $view): void
```
#### `getContents()`
diff --git a/docs/advanced-features/hyde-pages.md b/docs/advanced-features/hyde-pages.md
index fa5f9f5f6a7..19c459dfd50 100644
--- a/docs/advanced-features/hyde-pages.md
+++ b/docs/advanced-features/hyde-pages.md
@@ -161,16 +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).
-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 infer their output format from the identifier. Identifiers without an extension compile to `.html`,
+while identifiers that already have an extension keep it:
```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
```
@@ -198,7 +193,6 @@ class InMemoryPage extends HydePage
protected string $contents;
protected string $view;
- protected readonly bool $exactOutputPath;
/** @var array */
protected array $macros = [];
diff --git a/docs/advanced-features/in-memory-pages.md b/docs/advanced-features/in-memory-pages.md
index e6bd25e0de9..44597e6dfe1 100644
--- a/docs/advanced-features/in-memory-pages.md
+++ b/docs/advanced-features/in-memory-pages.md
@@ -34,22 +34,29 @@ To create an InMemoryPage, you need to instantiate it with the required paramete
Since a page would not be useful without any content to render, the class offers two content options through the constructor.
You can either pass a string to the `$contents` parameter, Hyde will then save that literally as the page's contents.
-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
+```
-InMemoryPage::make('robots.txt', contents: $text);
-// _site/robots.txt.html
+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:
-InMemoryPage::file('robots.txt', contents: $text);
+```php
+InMemoryPage::make('robots.txt', 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.
+Static and instance path resolution use the same inference:
+
+```php
+InMemoryPage::outputPath('robots.txt');
+// robots.txt
+
+InMemoryPage::make('robots.txt')->getOutputPath();
+// robots.txt
+```
Alternatively, you can pass a Blade view name to the `$view` parameter, and Hyde will use that view to render the page
contents with the supplied front matter during the static site build process.
diff --git a/packages/framework/src/Framework/Features/Documentation/DocumentationSearchIndex.php b/packages/framework/src/Framework/Features/Documentation/DocumentationSearchIndex.php
index fc595b61fc9..bde991b7e74 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 782d0946bb9..9d10ec260f9 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 0e68f158378..62653a35f5c 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 658565a73a4..32d2315d936 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
diff --git a/packages/framework/src/Framework/Features/XmlGenerators/SitemapPage.php b/packages/framework/src/Framework/Features/XmlGenerators/SitemapPage.php
index 87673073977..075e79cac23 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 b8ab81def3c..434a31bf63f 100644
--- a/packages/framework/src/Pages/InMemoryPage.php
+++ b/packages/framework/src/Pages/InMemoryPage.php
@@ -10,13 +10,11 @@
use Hyde\Markdown\Models\FrontMatter;
use Hyde\Pages\Concerns\HydePage;
use Illuminate\Support\Facades\View;
-use InvalidArgumentException;
use function Hyde\unslash;
+use function pathinfo;
use function sprintf;
-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 any source files.
@@ -36,7 +34,6 @@ class InMemoryPage extends HydePage
protected string $contents;
protected string $view;
- protected readonly bool $exactOutputPath;
/** @var array */
protected array $macros = [];
@@ -49,16 +46,6 @@ public static function make(string $identifier = '', FrontMatter|array $matter =
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.
- */
- public static function file(string $outputPath, FrontMatter|array $matter = [], string $contents = '', string $view = ''): static
- {
- return new static($outputPath, $matter, $contents, $view, exactOutputPath: true);
- }
-
/**
* Create a new in-memory/virtual page instance.
*
@@ -68,69 +55,36 @@ public static function file(string $outputPath, FrontMatter|array $matter = [],
*
* Note that $contents take precedence over $view, so if you pass both, only $contents will be used.
* You can also register a macro with the name 'compile' to overload the default compile method.
- * Normal construction uses HTML page semantics; use the `file()` constructor to create an exact-path file page.
*
* @param string $identifier The identifier of the page. This is used to generate the route key which is used to create the output filename.
* If the identifier for an in-memory page is "foo/bar" the page will be saved to "_site/foo/bar.html".
+ * If the identifier already has an extension, such as "robots.txt", it is used as the output path unchanged.
* You can then also use the route helper to get a link to it by using the route key "foo/bar".
* Take note that the identifier must be unique to prevent overwriting other pages.
* @param \Hyde\Markdown\Models\FrontMatter|array $matter The front matter of the page. When using the Blade view rendering option,
* all this data will be passed to the view rendering engine.
* @param string $contents The contents of the page. This will be saved as-is to the output file.
* @param string $view The view key or Blade file for the view to use to render the page contents.
- * @param bool $exactOutputPath Whether to validate and use the identifier as an exact output path. Prefer the `file()` constructor for this mode.
*/
- public function __construct(
- string $identifier = '',
- FrontMatter|array $matter = [],
- string $contents = '',
- string $view = '',
- bool $exactOutputPath = false
- ) {
- if ($exactOutputPath) {
- $identifier = static::normalizeExactOutputPath($identifier);
- }
-
- $this->exactOutputPath = $exactOutputPath;
-
+ public function __construct(string $identifier = '', FrontMatter|array $matter = [], string $contents = '', string $view = '')
+ {
parent::__construct($identifier, $matter);
$this->contents = $contents;
$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.
+ * 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 function getOutputPath(): string
+ public static function outputPath(string $identifier): string
{
- if ($this->exactOutputPath) {
- return unslash($this->identifier);
- }
+ $identifier = unslash($identifier);
- return parent::getOutputPath();
+ return $identifier.(pathinfo($identifier, PATHINFO_EXTENSION) === '' ? '.html' : '');
}
/** Get the contents of the page. This will be saved as-is to the output file when this strategy is used. */
diff --git a/packages/framework/tests/Feature/Commands/BuildRssFeedCommandTest.php b/packages/framework/tests/Feature/Commands/BuildRssFeedCommandTest.php
index 5d44aa1884e..6c567804a7c 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(InMemoryPage::file('feed.xml', contents: ''));
+ $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 3822aa4e38e..940f9342b04 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(InMemoryPage::file('sitemap.xml', contents: ''));
+ $kernel->pages()->addPage(InMemoryPage::make('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(InMemoryPage::file('sitemap.xml', contents: ''));
+ $kernel->pages()->addPage(InMemoryPage::make('sitemap.xml', contents: ''));
});
$this->artisan('build:sitemap')->assertExitCode(0);
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 1b3b65ebc80..faa3529dfe3 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());
}
@@ -128,7 +129,7 @@ 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(InMemoryPage::make('llms.txt', contents: 'user defined llms'));
});
$page = Routes::get('llms.txt')->getPage();
@@ -160,6 +161,6 @@ class LlmsTxtPageTestExtension extends HydeExtension
{
public function discoverPages(PageCollection $collection): void
{
- $collection->addPage(InMemoryPage::file('llms.txt', contents: 'extension defined llms'));
+ $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 28e4dc372fd..de720d66926 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 testBuildCommandCompilesInMemoryPageWithTxtIdentifier()
{
Hyde::kernel()->booting(function (HydeKernel $kernel): void {
- $kernel->pages()->addPage(InMemoryPage::file('robots.txt', contents: "User-agent: *\nAllow: /"));
+ $kernel->pages()->addPage(InMemoryPage::make('robots.txt', 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(InMemoryPage::make('foo/bar.txt', 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(InMemoryPage::make('robots.txt', 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(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')));
}
- 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(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')));
}
- 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(InMemoryPage::make('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 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 00a66ce3f6b..3cde1aa6da8 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());
}
@@ -127,7 +128,7 @@ 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(InMemoryPage::make('robots.txt', contents: 'user defined robots'));
});
$page = Routes::get('robots.txt')->getPage();
@@ -159,6 +160,6 @@ class RobotsTxtPageTestExtension extends HydeExtension
{
public function discoverPages(PageCollection $collection): void
{
- $collection->addPage(InMemoryPage::file('robots.txt', contents: 'extension defined robots'));
+ $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 a7d7853896f..f72764c2774 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()
@@ -163,7 +165,7 @@ 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(InMemoryPage::make('feed.xml', contents: 'user defined feed'));
});
$page = Routes::get('feed.xml')->getPage();
@@ -195,6 +197,6 @@ class RssFeedPageTestExtension extends HydeExtension
{
public function discoverPages(PageCollection $collection): void
{
- $collection->addPage(InMemoryPage::file('feed.xml', contents: 'extension defined feed'));
+ $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 35358e1c73b..8c02c5afe1d 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(InMemoryPage::file('robots.txt')));
+ Routes::addRoute(new Route(InMemoryPage::make('robots.txt')));
$service = new SitemapGenerator();
$service->generate();
@@ -146,7 +146,7 @@ public function testGenerateDoesNotAddPagesWithNonHtmlOutputPaths()
public function testGenerateAddsNonHtmlPagesWithSitemapFrontMatterSetToTrue()
{
- Routes::addRoute(new Route(InMemoryPage::file('robots.txt', ['sitemap' => true])));
+ 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 873146887b1..1e264f9585d 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());
}
@@ -141,7 +142,7 @@ 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(InMemoryPage::make('sitemap.xml', contents: 'user defined sitemap'));
});
$page = Routes::get('sitemap.xml')->getPage();
@@ -175,6 +176,6 @@ class SitemapPageTestExtension extends HydeExtension
{
public function discoverPages(PageCollection $collection): void
{
- $collection->addPage(InMemoryPage::file('sitemap.xml', contents: 'extension defined sitemap'));
+ $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 f20206ab5b9..d1c5eb30cae 100644
--- a/packages/framework/tests/Unit/Pages/InMemoryPageTest.php
+++ b/packages/framework/tests/Unit/Pages/InMemoryPageTest.php
@@ -7,8 +7,6 @@
use BadMethodCallException;
use Hyde\Pages\InMemoryPage;
use Hyde\Testing\TestCase;
-use InvalidArgumentException;
-use PHPUnit\Framework\Attributes\DataProvider;
/**
* @see \Hyde\Framework\Testing\Unit\Pages\InMemoryPageUnitTest
@@ -27,12 +25,6 @@ public function testMakeWithContentsString()
$this->assertEquals(InMemoryPage::make('foo', contents: 'bar'), new InMemoryPage('foo', contents: 'bar'));
}
- 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 testContentsMethod()
{
$this->assertSame('bar', (new InMemoryPage('foo', contents: 'bar'))->getContents());
@@ -152,80 +144,26 @@ public function testHasMacro()
$this->assertFalse($page->hasMacro('bar'));
}
- 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'));
- }
-
- 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
- {
- $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());
+ $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', 'docs/1.x/index'] 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'));