Skip to content
Open
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
30 changes: 30 additions & 0 deletions docs/generation/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ Both can subscribe to shared values prepared by the [data pipeline](../data/).
| Archives, tag indexes, or HTML redirects with page variables and layouts | `*.pages.ts` | One or more DOMStack pages |
| Feeds, sitemaps, JSON, text, or fully controlled output | `*.template.ts` | One or more files, without layout wrapping |
| An ordinary page with its own source directory and browser assets | [Page files](../pages/#page-files) | A source-backed page |
| Markdown downloads, JSON metadata, or other extra files owned by a source-backed page | [`pageOutputs`](../pages/#page-outputs) | Extra files alongside the page's HTML |

## Table of Contents

Expand Down Expand Up @@ -48,6 +49,8 @@ A generated-pages module can default-export:
| An async iterable, usually returned by `async function*` | Pages are discovered incrementally or the total is not known in advance |

Static objects and arrays do not receive factory parameters.
A `null` or `undefined` default export or factory result produces no pages, as does an empty array or async iterable.
Each array entry or yielded value must still be a page-definition object; `null` entries are not skipped.

#### One page definition

Expand Down Expand Up @@ -138,6 +141,29 @@ export default async function * archivePages ({ data }) {
}
```

#### Streaming and failures

DOMStack consumes generated pages one at a time rather than collecting all definitions before building them.
For each definition, it validates the definition and output path, initializes the page's variables, layouts, and declared data, then renders and writes the HTML before requesting the next definition.
An async generator resumes after `yield` only once that page has been written, so it can release resources associated with the completed page before preparing the next one.
Arrays and single-object results use the same per-page pipeline, although an array-producing factory necessarily creates its array before returning it.

Definitions marked `draft: true` are skipped unless drafts are enabled.
Skipped drafts retain their position in source identifiers: after a skipped first definition, the next page is identified as `archive.pages.ts#1`, not `archive.pages.ts#0`.
Output paths must be unique across generated pages and must not collide with source-backed pages.
Do not rely on the processing order of sibling factory modules.

A factory, validation, initialization, or render failure stops the active iterator without requesting later definitions.
Async generators can use `try`/`finally` to release resources when iteration stops.
Earlier completed pages remain written; generated-page builds are not transactional, and a late failure does not roll back earlier HTML.
Conflict errors still identify both sources even when one page has already been written.
Build results retain output metadata and the owning pages-file path for completed pages, including when the build fails.

In watch mode, failed builds retain both previously owned outputs and any newly written partial outputs without stale-output cleanup.
A later successful rebuild removes obsolete outputs, including partial pages from repeated failures or an initially failed watch build.
Removing the factory or changing it to return no pages also cleans up its tracked outputs after a successful rebuild.
This ownership tracking does not require a public DOMStack manifest and does not remove outputs still owned by sibling factories.

### Generated-pages factory parameters

Functions receive one object with:
Expand All @@ -150,6 +176,8 @@ Functions receive one object with:

Factories do not receive raw source or generated `PageData` collections.
Put page-collection logic in [`global.data.ts`](../data/#global-data), return a focused serializable value, and subscribe to its key from the factory.
Source-backed pages remain fully initialized before `global.data.ts` runs, so it can inspect their resolved variables and use their rendering methods subject to the normal data-dependency rules.
Generated pages are not included in that collection, even after earlier yielded pages have been written.
This keeps factories downstream of source discovery without exposing generation order or creating page-generation cycles.

### Generated page definitions
Expand All @@ -163,6 +191,8 @@ This keeps factories downstream of source discovery without exposing generation

Generated pages use [global bundles](../global-bundles/) and [layout assets](../layouts/#layout-styles).
They do not have page-local `style.css`, `client.js`, or worker entries because they do not have their own source-page directory.
Support for [page outputs](../pages/#page-outputs) on generated pages is deferred.
Generated pages skip all `pageOutputs` hooks, including hooks inherited from layouts.

### Generated-pages types

Expand Down
40 changes: 40 additions & 0 deletions docs/layouts/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ DOMStack recognizes these exports from a layout module:
| `default` | Yes | A synchronous or asynchronous [layout render function](#layout-render-function). |
| `vars` | No | An object, or a sync/async function returning an object, providing [layout defaults](#layout-variables). |
| `parentLayout` | No | A non-empty string naming the immediate outer layout; see [Declaring nested layouts](#declaring-nested-layouts). |
| `pageOutputs` | No | A build-only function returning [extra files for each source page](../pages/#page-outputs), such as Markdown downloads or JSON metadata. |

## Declaring nested layouts

Expand Down Expand Up @@ -104,6 +105,45 @@ See [Data subscriptions in nested layouts](../cookbook/nested-layouts/#data-subs

See [Compose nested layouts](../cookbook/nested-layouts/) for a complete example and asset guidance.

## Page outputs

Export `pageOutputs` from a layout to produce extra files for each source-backed page that uses it.
This is the same export used by page modules and vars companions, and it receives `{ page, vars, data }`.
The function runs for each page, and that page owns the returned files.
For example, a documentation layout can publish a Markdown download alongside each rendered page:

```js
// src/docs.layout.js
export default ({ children }) => children

export async function pageOutputs ({ page, vars }) {
if (page.type !== 'md' || vars.rawExport === false) return []
return {
outputName: page.outputName.replace(/\.html$/, '.source.md'),
content: await page.readMarkdownContent(),
}
}
```

The filename is relative to the current page's output directory, not the layout directory.
Using the page's HTML filename helps keep destinations unique when several loose Markdown pages share a directory.
Exact duplicate destinations produce best-effort build warnings, not an override contract; avoid sharing output paths between pages or hooks.
Set `rawExport: false` in a page's frontmatter or vars to opt out of this layout's Markdown download.
Returning `[]` from a page hook does not suppress layout files; the layout itself must check the opt-out variable.

Nested hooks run outermost layout → innermost layout → selected page-level hook, and their files are additive.
If a JS/TS page module and its vars companion both export `pageOutputs`, the page module's hook wins with a warning; layout hooks still run.
Each layout hook receives the fully resolved page `vars` and only that layout renderer's `vars.dataDeps` subscriptions in `data`.
Declare data needed by the hook in the same subscriptions used by the layout render function.

Hooks may return a `{ outputName, content }` record, an array of records, or an async iterable of records, directly or through a promise.
DOMStack validates and processes each file before requesting the next record, writing it or retaining an unchanged file during watch rebuilds.
A later hook runs only after the preceding hook's files have been processed.
Files are written directly, without transactions or rollback; if a later record or hook fails, earlier writes remain in the destination.
Watch mode tracks these files for cleanup after a successful rebuild or source removal.
Support for generated `*.pages.*` pages is deferred; they skip these hooks, including inherited layout hooks.
See [Page outputs](../pages/#page-outputs) for the complete arguments, public types, path rules, and watch behavior.

## Layout variables

Layouts may also export an optional [`vars` variable provider](../pages/#variable-providers) containing defaults for pages that use the layout:
Expand Down
167 changes: 166 additions & 1 deletion docs/pages/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -319,7 +319,172 @@ export default async () => {
Page variable files have higher precedence than `global.vars.ts` variables, but lower precedence than frontmatter or `vars` exports from `ts` pages.
See [Variables](../../docs/pages/#variables) for the full variable cascade.

### Draft pages
## Page outputs

Use `pageOutputs` to publish extra files for a source-backed page, such as a Markdown download or a JSON metadata file alongside its HTML.
Export this function from a JS/TS page module, the page's directly associated vars companion, or a [layout](../layouts/#page-outputs).
DOMStack calls the function during the page build and writes the files it returns.
Return output records rather than writing to the destination yourself.

For example, this page subscribes to `siteMetadata` returned by [`global.data.ts`](../data/#global-data) and writes `article/metadata.json` beneath the destination as well as its normal HTML:

```js
// src/article/page.js
export const vars = {
title: 'An article',
dataDeps: ['siteMetadata'],
}

export default ({ vars }) => `<h1>${vars.title}</h1>`

export const pageOutputs = ({ vars, data }) => ({
outputName: './metadata.json',
content: JSON.stringify({ title: vars.title, site: data.siteMetadata }),
})
```

### Companion hooks

For Markdown and HTML pages, put `pageOutputs` in the adjacent `page.vars.js` or `page.vars.ts` companion.
Keep page variables in the companion's default export and export the hook separately:

```js
// src/article/page.vars.js (alongside page.md)
export default { title: 'An article' }

export async function* pageOutputs ({ page, vars }) {
yield {
outputName: './source.md',
content: await page.readMarkdownContent(),
}
yield {
outputName: './metadata.json',
content: JSON.stringify({ title: vars.title }),
}
}
```

For an HTML page, use the same companion export to return text or JSON rather than calling the Markdown-only `readMarkdownContent()` method.
JS/TS pages can also use a vars companion.
Only the directly associated companion provides a page-level hook; global vars and inherited directory vars do not provide hooks.
If both a JS/TS page module and its companion export `pageOutputs`, DOMStack uses the page module's hook and warns with both provider names.
The companion's hook does not run, but applicable layout hooks still run.

### Hook arguments and results

Every hook receives `{ page, vars, data }`:

- `page` is a read-only handle to the current source page, including `type`, `path`, `url`, `outputName`, `outputRelname`, `draft`, and read-only `pageFile` metadata.
Its `readMarkdownContent()` method reads the Markdown body with YAML frontmatter removed, without rendering Markdown, substituting Handlebars, or rewriting links.
The method throws for non-Markdown pages.
- `vars` contains the fully resolved page variables and is read-only.
- `data` contains only the declaring renderer's subscribed global data.
Page-module and companion hooks share the page renderer's subscriptions; each layout hook shares that specific layout renderer's subscriptions.
Declare required keys through the renderer's existing `vars.dataDeps`, `dataDeps` in a companion's default vars object, or page frontmatter.
Each layout declares its own keys through its `vars.dataDeps`.
Undeclared keys are unavailable, even if another hook subscribes to them.
See [Data subscriptions](../data/#data-subscriptions).
Comment thread
bcomnes marked this conversation as resolved.

A hook returns one `{ outputName: string, content: string }` record, an array of records, or an async iterable of records, directly or through a promise.
Use a single record for one file, an array for a fixed set, or an async generator to produce files incrementally.
`outputName` must be a non-empty file path, and `content` must be a string; serialize JSON with `JSON.stringify()`.
Bare strings, `null`, and `undefined` are not valid results.
Return `[]` or yield no records when the hook has no files to produce.

### Composition and opt-out

Applicable hooks run in outermost layout → innermost layout → selected page-level hook order.
Layout outputs and page-level outputs are additive; the page hook does not replace layout outputs.
Returning `[]` from the page hook only skips that hook's files.
To let a page opt out of a layout's files, have the layout inspect a resolved variable such as `rawExport: false` and return `[]` itself, as in the [layout example](../layouts/#page-outputs).
Each layout that supports the opt-out must check that variable.

Hooks run only when building the owning page's output, not when collection or global-data code calls `renderInnerPage()` or `renderFullPage()`.
Support for generated `*.pages.*` pages is deferred; those pages skip all `pageOutputs` hooks, including inherited layout hooks.

### Streaming results

DOMStack consumes records sequentially rather than buffering all hook results.
It validates each record and its destination, then writes the file or retains an unchanged file before requesting the next record.
An async generator resumes after `yield` only once that file has been processed, so it can release resources before preparing the next one.
Arrays use the same per-record processing, but the hook must create the array before returning it.
A later layout or page hook starts only after the preceding hook's files have been processed.

### Page-output types

Import `PageOutputsFunction<T, D>` from `@domstack/static/types.js` to type a hook, where `T` is the resolved variables shape and `D` is the declaring renderer's subscribed data shape:

```ts
// src/article/page.vars.ts
import type { PageOutputsFunction } from '@domstack/static/types.js'

type ArticleVars = { title: string }
type ArticleData = { siteMetadata: { name: string } }

export default {
title: 'An article',
dataDeps: ['siteMetadata'],
}

export const pageOutputs: PageOutputsFunction<ArticleVars, ArticleData> = ({ vars, data }) => ({
outputName: './metadata.json',
content: JSON.stringify({ title: vars.title, site: data.siteMetadata.name }),
})
```

`PageOutput` types an individual output record, and `PageOutputsResult` describes the record, array, or async iterable returned by a hook.
`PageOutputsFunctionParams<T, D>` types the argument object, and `PageOutputsPage` types its read-only `page` handle.
Types describe the values but do not subscribe to global data; keep the runtime `dataDeps` declaration.

### Output paths and failures

Output names resolve beneath the configured destination, including custom destinations:

| Output name | Resolution for a page at `docs/article/index.html` |
| --- | --- |
| `metadata.json` or `./metadata.json` | `docs/article/metadata.json` |
| `../source/article.md` | `docs/source/article.md` |
| `/raw/article.md` | `raw/article.md` at the destination root |

A leading `/` means destination-root-relative, never filesystem-absolute.
Relative paths use the current page's output directory even when a layout declares them.
Parent traversal is allowed only while the resolved target remains inside the destination.
Use portable file names; drive-letter paths, UNC paths, reserved names, and paths ending in a separator are invalid.
Targets that escape the destination, traverse symlinks, or name a directory instead of a file fail the build.

Duplicate destinations produce best-effort warnings based on build output reports, including exact duplicates between hooks and conflicts with normal HTML, other pages, templates, copied assets, or bundles.
They do not reject the build, even when content differs.
Watch warnings cover outputs observed in the current page/template phase and may miss conflicts with files from earlier builds or names that differ only in case.
Choose unique destinations; do not rely on write order or cleanup behavior for conflicting outputs.

Page outputs are written directly to the destination as their records arrive.
If a later hook, iterator step, output validation, or write fails, earlier writes remain, including updates to existing files and newly created files.
Processing stops at the failure without requesting subsequent records or invoking later hooks.
Async generators can use `try`/`finally` to release resources when iteration stops.
The page's HTML is rendered before its hooks run and written only after they succeed, so a hook failure leaves any previous HTML in place.
Writes are not transactional and are not rolled back: other pages and build phases may already have written their outputs, and filesystem write failures can leave partial updates.

### Watch behavior and ownership

Page outputs belong to the source page and appear in page build reports and, when enabled, the build manifest with kind `page-output`.
Ownership tracking and cleanup also work when public build-manifest generation is disabled.
A failed build retains previously tracked files and tracks any files successfully written or retained unchanged before the failure; it does not clean up the page's old outputs.
After a successful rebuild, DOMStack removes previously owned files no longer returned, including renamed outputs, files from removed hooks, and partial outputs retained from failed attempts.
Files written before a failure are tracked even when the initial watch build fails, so recovery, hook removal, or source deletion can clean them up.
Source deletion, source rename, or draft exclusion also removes the page's old outputs.
Adding, editing, removing, or renaming a companion updates the owning page's hook and output set.

Hooks rerun when the owning page rebuilds, including changes to applicable page or layout data subscriptions.
A dependency used only by a hook still triggers an HTML rebuild because both outputs rebuild together.
During watch rebuilds, DOMStack skips rewriting a previously written page output when its content matches and the destination's filesystem metadata has not changed.
These unchanged files remain owned by the page and keep their modification times.
Missing files or files with changed metadata are written again.
On a fresh build, page outputs are written even if identical files already exist.

An article body edit can update that article's HTML and Markdown download without rebuilding sibling pages, while a shared data change can rerun affected hooks without rewriting their unchanged files.
Use [templates](../generation/#templates) for collection-wide search indexes and feeds, and page outputs for per-page files.

## Draft pages

A complete draft page can use the same colocated files as a published page:

Expand Down
Loading