You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Allow page modules, directly associated page vars companion modules, and layout modules to declare additional output files alongside the normal page HTML, using the explicit output-record model already used by templates.
The layout defines the policy; the source page owns the outputs. This should be a generic mechanism, not a Markdown-specific feature or an untracked filesystem side effect in a renderer.
Concrete use case: Oro docs raw Markdown
The Oro website keeps authored docs in Markdown and publishes both rendered HTML and raw Markdown at existing URLs such as /runtime/docs/source/....
With @domstack/static 12.0.0-beta.5, raw exports currently come from collection-level templates. Each template subscribes to its collection export data and returns all raw files. A body-only edit to one Runtime article produced these actual watcher writes:
Output
Files written
Edited article HTML
1
Runtime raw Markdown copies
131
Runtime search JSON
1
Runtime LLM pack
1
Total
134
Only one raw Markdown copy changed: 130 raw writes were unnecessary. The template builder writes each returned output unconditionally. Raw export processing is only reading the Markdown body with YAML frontmatter stripped; it does not require rendering, rewriting links, or normalizing the body.
Search JSON and LLM packs are genuinely collection-wide aggregates and should remain templates. Raw Markdown is naturally a per-page output.
Agreed API (to be implemented)
// docs.layout.tsexportconstadditionalOutputs=async({ page, vars })=>{constmarkdown=awaitpage.readMarkdownContent()return[{outputName: rawOutputPath(vars),content: markdown,},]}exportdefaultdocsLayout
Here rawOutputPath is an application helper derived from the current document metadata, not a proposed built-in. The hook receives { page, vars, data }: fully resolved page vars, explicitly subscribed global data as described below, and a read-only handle for the current source page. The handle exposes page metadata and readMarkdownContent(), but not rendering methods, direct output-writing methods, or another consumer's subscribed data. readMarkdownContent() throws for non-Markdown pages. The exact metadata fields and public types still need to be formalized.
A page module could export the same hook:
// page.tsexportconstadditionalOutputs=({ vars })=>[{outputName: "./metadata.json",content: JSON.stringify({title: vars.title}),},]exportdefaultpage
Markdown pages would inherit the hook from their layout, without executable frontmatter or a companion module for every article. A layout hook must receive the current source page, not the layout source file.
Page vars companion hooks
A source-backed page's directly associated vars companion module may export additionalOutputs as a named module export. This supports per-page behavior for Markdown and HTML without introducing a new companion-file convention or requiring a dedicated layout, and is also supported consistently for JS/TS pages.
// In the page's existing vars companion module:exportconstadditionalOutputs=async({ page, vars })=>[{outputName: './source.md',content: awaitpage.readMarkdownContent(),},{outputName: './metadata.json',content: JSON.stringify({title: vars.title}),},]
This example is for a Markdown page; readMarkdownContent() still throws for other page types. Existing vars exports retain their current format and behavior. Resolved vars remain data: the hook is a named module export, not a property in the resolved vars object or executable Markdown frontmatter.
The companion hook occupies the single page-level position after all applicable layout hooks and receives the same { page, vars, data } contract, fully resolved vars, and declared data as the owning page renderer.
If both a JS/TS page module and its associated vars companion export additionalOutputs, fail with a clear provider-conflict error identifying both modules. Do not silently choose a provider or add another composition order.
Only the directly associated page vars companion is a page-level hook provider. Global vars and inherited directory vars are not hook providers; this feature does not introduce another inheritance mechanism beyond layouts.
Layouts remain the preferred place for shared policy, such as exporting raw Markdown for an entire docs section. A companion module is optional and is only needed for page-specific behavior.
Use the existing vars companion rather than a new file: these modules already configure the page, and keeping its hook, vars, and subscriptions together avoids another naming convention, discovery rule, and watch dependency. A broader page-configuration file convention can be considered separately if additional lifecycle features eventually justify it.
Return forms
Support a single explicit output record, an array of records, or an async iterable of records, returned directly or through a promise. Initially, content supports strings, matching the existing template output-record implementation. Bare string returns are not supported because additional outputs have no implicit filename. An empty array or an iterator that yields nothing declares no additional outputs for that hook.
exportasyncfunction*additionalOutputs({ page, vars }){yield{outputName: './source.md',content: awaitpage.readMarkdownContent(),}yield{outputName: './metadata.json',content: JSON.stringify({title: vars.title}),}}
Arrays and async iterables have identical validation, collision, ownership, and failure guarantees. If an iterator throws after yielding records, its earlier results must not already have replaced live outputs.
Agreed semantics
Reuse the explicit { outputName, content } template output-record shape, with the return forms and string content described above.
The normal HTML output remains unchanged; the hook adds files rather than replacing the page renderer.
Relative output names resolve against the current page output directory, even when declared in a layout.
For this new API, a leading / means destination-root-relative: /runtime/docs/source/article.md resolves beneath the configured destination, never to a filesystem-absolute destination. metadata.json and ./metadata.json are page-output-directory-relative; ../source/article.md is allowed only if it remains inside the destination. Reject escapes and invalid file targets. This does not change existing template path semantics.
Execute and combine hooks in outermost layout → innermost layout → page order. All outputs are additive. Duplicate destinations fail, even when their content is identical. There is no built-in opt-out or override mechanism in v1: layout hooks can inspect application-defined resolved vars, such as rawExport: false, and return []. A page hook returning [] does not suppress layout outputs.
Track all additional outputs under the source page in build reports and the build manifest, retaining declaring-layout/hook provenance for diagnostics where useful. Internal ownership and cleanup must work independently of whether public build-manifest generation or writing is enabled.
On successful rebuild, remove previously owned outputs no longer returned, including changes to output paths or removal of the hook.
Deletion, draft exclusion, or other removal of the source page must also clean up its owned outputs.
Apply destination-boundary validation and collision handling, including conflicts with normal HTML, other pages, templates, copied assets, and bundles. Reuse the output ownership, collision detection, and staging infrastructure from Detect conflicting output paths across build steps #288 rather than introducing a separate mechanism.
Preserve ownership records even when an unchanged file does not need to be written. Do not implement this by omitting unchanged records from the returned output list.
Hook, iterator, validation, and collision failures fail the build, publish none of the staged page-phase outputs, perform no stale-output cleanup, and preserve prior ownership. Stage outputs until the page phase succeeds; do not publish each yield directly. Full transactional rollback for filesystem I/O failures during publication is outside the initial scope.
Execute hooks only during the owning page's output-build phase, not inside renderInnerPage() or renderFullPage(). Collection/global-data code may call those rendering methods without executing output hooks.
Data access and incremental builds
The hook needs access to the current page source and resolved vars without subscribing to an entire collection. Requiring collection export data in the shared layout would recreate body-edit-to-all-pages invalidation.
Hooks share the declaring renderer's existing subscriptions using the vars.dataDeps static-array convention; there is no separate additionalOutputsDataDeps export in v1. A page hook, whether exported by the page module or its directly associated vars companion, receives the same declared data as the page renderer. Each layout hook receives the same declared data as that particular layout renderer, not subscriptions from the source page or other layouts. Do not implicitly expose all global data.
// docs.layout.jsexportconstvars={dataDeps: ['siteMetadata'],}exportconstadditionalOutputs=({ vars, data })=>[{outputName: './metadata.json',content: JSON.stringify({title: vars.title,site: data.siteMetadata,}),}]exportdefaultfunctiondocsLayout({ children }){returnchildren}
The owning page rebuilds when any applicable page/layout subscription changes. A dependency used only by an additional-output hook therefore also triggers HTML rebuilding; that is intentional because these outputs rebuild together initially. Separate hook subscriptions or scheduling may be considered later, but are not required now. Raw Markdown export needs no global-data subscription: it uses the current source page and resolved vars.
An initial implementation can run applicable hooks whenever the owning page rebuilds, with content-aware writes so unchanged sidecars are not rewritten. Separate source-only hook invalidation can be a later optimization.
Expected body-edit flow:
one source page changes
→ rebuild its HTML
→ run its additional-output hooks
→ update its one raw Markdown file
A shared navigation/layout change may still rerun hooks for multiple pages, but identical Markdown should not be rewritten. This proposal does not by itself solve unrelated global-data computation that reads/renders all docs to prepare search data.
Support source-backed JS/TS page-module hooks, named hooks in directly associated page vars companion modules for JS/TS/Markdown/HTML pages, and applicable layout hooks for source-backed pages. Markdown/HTML pages do not require executable frontmatter; individual companion modules are optional when shared layout policy is sufficient. Extend companion-module loading to retain and validate the named hook alongside existing vars resolution, without embedding it in resolved vars.
Defer generated pages from *.pages.* factories. In v1, generated pages explicitly skip additional-output hooks, including inherited layout hooks, rather than acquiring accidental partial support.
Source review at fetched master 612313d found that PageData.readMarkdownContent(), resolved layout chains, restricted data subscriptions, and per-page output-record arrays already provide useful integration points. readMarkdownContent() reads the body with frontmatter removed and does not render Markdown.
Current template writing is unconditional and can publish records before a later iterator failure. Reuse its record/return model, not those publication semantics. Byte-identical sidecars should be skipped at publication to the real destination, not merely compared against a fresh staging directory.
At that reviewed master revision, regular source pages lack complete targeted stale-output reconciliation, and general cross-producer collision prevention is not yet present. Detect conflicting output paths across build steps #288 is the intended foundation. Reconcile each successfully rebuilt source page's complete output set, including zero-sidecar results and unchanged files; preserve untouched owners during targeted rebuilds.
Independent source-only hook invalidation, generated-page factory integration, broad template/HTML write optimization, and full filesystem-failure rollback are deferred. This feature does not require optimizing collection-wide aggregates.
Acceptance scenarios
A Markdown page produces normal HTML plus a raw body export through its layout hook.
A page module can produce a JSON sidecar through the same mechanism.
A Markdown page can produce a raw-body export through a named hook in its directly associated vars companion; HTML and JS/TS pages can use companion hooks for suitable outputs as well. Companion hooks receive the owning page's resolved vars and subscribed data and run after layout hooks.
Declaring hooks in both a JS/TS page module and its vars companion fails with a clear error identifying both providers. Global or inherited directory vars do not become hook providers.
Editing, adding, removing, or renaming a page vars companion or removing its hook updates the owning page's output set in watch mode and cleans up stale sidecars after a successful rebuild.
Single-record, array, and async-iterable hooks are supported; empty results are valid, malformed records fail, and an iterator failure after earlier yields does not publish partial results.
Editing one article body updates that article HTML and raw export without rewriting sibling raw exports.
A navigation-only rebuild does not rewrite byte-identical raw exports.
Page deletion, output rename, draft exclusion, and hook removal clean up stale owned files in watch mode.
Nested layout and page hooks compose in outermost → innermost → page order, with duplicate destinations reported clearly even when content matches.
Page and layout hooks share only their respective renderer subscriptions; hook-only data use invalidates the owning page without exposing undeclared keys or another renderer's subscriptions.
Calling page rendering methods from collection/global-data code does not execute additional-output hooks.
Generated *.pages.* pages skip additional-output hooks in the initial implementation.
Page-directory-relative and leading-slash destination-root-relative paths work with custom build destinations and no hardcoded public/ paths. Escapes and invalid file targets are rejected, and conflicts with HTML, templates, assets, bundles, and other sidecars fail before publication.
Hook failures are surfaced, prior ownership survives failed builds, and clean builds and recovered watch rebuilds converge on the same output set.
Ownership and stale-output cleanup work with public build-manifest generation disabled.
Alternatives
Markdown-specific output option: solves the immediate problem but not JSON/text sidecars or other alternate representations.
Direct writes in layout code: easy initially, but bypasses output ownership, cleanup, and collision handling.
Skip identical template writes: useful independently and would remove the unnecessary disk writes in this example, but still regenerates a collection-sized output list rather than modeling page-owned artifacts.
For Oro, this would replace six collection-level raw-source templates with shared layout policy while retaining aggregate search and LLM templates.
Summary
Allow page modules, directly associated page vars companion modules, and layout modules to declare additional output files alongside the normal page HTML, using the explicit output-record model already used by templates.
The layout defines the policy; the source page owns the outputs. This should be a generic mechanism, not a Markdown-specific feature or an untracked filesystem side effect in a renderer.
Concrete use case: Oro docs raw Markdown
The Oro website keeps authored docs in Markdown and publishes both rendered HTML and raw Markdown at existing URLs such as
/runtime/docs/source/....With
@domstack/static12.0.0-beta.5, raw exports currently come from collection-level templates. Each template subscribes to its collection export data and returns all raw files. A body-only edit to one Runtime article produced these actual watcher writes:Only one raw Markdown copy changed: 130 raw writes were unnecessary. The template builder writes each returned output unconditionally. Raw export processing is only reading the Markdown body with YAML frontmatter stripped; it does not require rendering, rewriting links, or normalizing the body.
Search JSON and LLM packs are genuinely collection-wide aggregates and should remain templates. Raw Markdown is naturally a per-page output.
Agreed API (to be implemented)
Here
rawOutputPathis an application helper derived from the current document metadata, not a proposed built-in. The hook receives{ page, vars, data }: fully resolved page vars, explicitly subscribed global data as described below, and a read-only handle for the current source page. The handle exposes page metadata andreadMarkdownContent(), but not rendering methods, direct output-writing methods, or another consumer's subscribed data.readMarkdownContent()throws for non-Markdown pages. The exact metadata fields and public types still need to be formalized.A page module could export the same hook:
Markdown pages would inherit the hook from their layout, without executable frontmatter or a companion module for every article. A layout hook must receive the current source page, not the layout source file.
Page vars companion hooks
A source-backed page's directly associated vars companion module may export
additionalOutputsas a named module export. This supports per-page behavior for Markdown and HTML without introducing a new companion-file convention or requiring a dedicated layout, and is also supported consistently for JS/TS pages.This example is for a Markdown page;
readMarkdownContent()still throws for other page types. Existing vars exports retain their current format and behavior. Resolved vars remain data: the hook is a named module export, not a property in the resolved vars object or executable Markdown frontmatter.{ page, vars, data }contract, fully resolved vars, and declared data as the owning page renderer.additionalOutputs, fail with a clear provider-conflict error identifying both modules. Do not silently choose a provider or add another composition order.Use the existing vars companion rather than a new file: these modules already configure the page, and keeping its hook, vars, and subscriptions together avoids another naming convention, discovery rule, and watch dependency. A broader page-configuration file convention can be considered separately if additional lifecycle features eventually justify it.
Return forms
Support a single explicit output record, an array of records, or an async iterable of records, returned directly or through a promise. Initially,
contentsupports strings, matching the existing template output-record implementation. Bare string returns are not supported because additional outputs have no implicit filename. An empty array or an iterator that yields nothing declares no additional outputs for that hook.For example, a layout may use an async generator:
Arrays and async iterables have identical validation, collision, ownership, and failure guarantees. If an iterator throws after yielding records, its earlier results must not already have replaced live outputs.
Agreed semantics
{ outputName, content }template output-record shape, with the return forms and string content described above./means destination-root-relative:/runtime/docs/source/article.mdresolves beneath the configured destination, never to a filesystem-absolute destination.metadata.jsonand./metadata.jsonare page-output-directory-relative;../source/article.mdis allowed only if it remains inside the destination. Reject escapes and invalid file targets. This does not change existing template path semantics.rawExport: false, and return[]. A page hook returning[]does not suppress layout outputs.renderInnerPage()orrenderFullPage(). Collection/global-data code may call those rendering methods without executing output hooks.Data access and incremental builds
The hook needs access to the current page source and resolved vars without subscribing to an entire collection. Requiring collection export data in the shared layout would recreate body-edit-to-all-pages invalidation.
Hooks share the declaring renderer's existing subscriptions using the
vars.dataDepsstatic-array convention; there is no separateadditionalOutputsDataDepsexport in v1. A page hook, whether exported by the page module or its directly associated vars companion, receives the same declared data as the page renderer. Each layout hook receives the same declared data as that particular layout renderer, not subscriptions from the source page or other layouts. Do not implicitly expose all global data.The owning page rebuilds when any applicable page/layout subscription changes. A dependency used only by an additional-output hook therefore also triggers HTML rebuilding; that is intentional because these outputs rebuild together initially. Separate hook subscriptions or scheduling may be considered later, but are not required now. Raw Markdown export needs no global-data subscription: it uses the current source page and resolved vars.
An initial implementation can run applicable hooks whenever the owning page rebuilds, with content-aware writes so unchanged sidecars are not rewritten. Separate source-only hook invalidation can be a later optimization.
Expected body-edit flow:
A shared navigation/layout change may still rerun hooks for multiple pages, but identical Markdown should not be rewritten. This proposal does not by itself solve unrelated global-data computation that reads/renders all docs to prepare search data.
Related dependency/ownership work: #289.
Initial scope and implementation context
*.pages.*factories. In v1, generated pages explicitly skip additional-output hooks, including inherited layout hooks, rather than acquiring accidental partial support.612313dfound thatPageData.readMarkdownContent(), resolved layout chains, restricted data subscriptions, and per-page output-record arrays already provide useful integration points.readMarkdownContent()reads the body with frontmatter removed and does not render Markdown.Acceptance scenarios
*.pages.*pages skip additional-output hooks in the initial implementation.public/paths. Escapes and invalid file targets are rejected, and conflicts with HTML, templates, assets, bundles, and other sidecars fail before publication.Alternatives
For Oro, this would replace six collection-level raw-source templates with shared layout policy while retaining aggregate search and LLM templates.