Skip to content

Add page-owned additional outputs from page and layout hooks - #323

Open
bcomnes wants to merge 6 commits into
masterfrom
issue-322-page-additional-outputs
Open

Add page-owned additional outputs from page and layout hooks#323
bcomnes wants to merge 6 commits into
masterfrom
issue-322-page-additional-outputs

Conversation

@bcomnes

@bcomnes bcomnes commented Sep 14, 2026

Copy link
Copy Markdown
Owner

Summary

Implements page-owned additional outputs from #322. Source-backed pages can emit files alongside their normal HTML through hooks in page modules, directly associated vars companions, or layouts.

This supports per-page raw Markdown exports and JSON/text sidecars without collection-wide templates. Aggregate outputs such as search indexes and LLM packs remain templates.

API

// In a Markdown page's layout or vars companion:
export async function* additionalOutputs ({ page, vars }) {
  yield {
    outputName: './source.md',
    content: await page.readMarkdownContent(),
  }

  yield {
    outputName: './metadata.json',
    content: JSON.stringify({ title: vars.title }),
  }
}
  • Supports single records, arrays, and async iterables, returned directly or through a promise.
  • Uses string content and explicit filenames; empty results are valid.
  • Provides resolved vars, a restricted source-page handle, and the declaring renderer's existing data subscriptions.
  • Composes outermost layout → innermost layout → page hook.
  • Rejects simultaneous page-module and vars-companion providers.
  • Resolves relative paths against the page's output directory and leading-slash paths against the configured destination root.
  • Runs hooks only during output building, not ordinary page rendering.
  • Defers generated *.pages.* pages, which skip additional-output hooks.

Direct writes and ownership

This feature uses the existing direct-write build flow. There are no staged builds, publication transactions, output reservations, or dependency on #288. The initially included #288 foundation has been removed in 534210b; review the current aggregate diff rather than the superseded intermediate commits.

A source-to-output mapping tracks each page's complete output set, including unchanged sidecars. Successful watch rebuilds remove stale outputs after output renames, hook removal, source deletion, and draft exclusion. Byte-identical sidecars are not rewritten. Ownership and cleanup work with the public build manifest disabled.

A small destination map emits best-effort warnings for exact duplicates observed in build reports. Duplicates do not reject the build or reserve paths. Watch warnings are limited to outputs observed in the current page/template phase; this is not a persistent cross-build conflict system or a case-alias check. Users should choose unique destinations and not depend on write order or cleanup behavior for conflicting outputs.

Additional-output hooks are consumed lazily: each yielded record is validated and written, or skipped when byte-identical, before the next record is requested. Output content is not accumulated into an array. Only report and ownership metadata is retained. If a later hook, iterator, validation, or write fails, earlier writes remain. Watch mode remembers emitted paths alongside prior ownership so a later successful rebuild can clean up partial outputs. There is no staging or rollback. Destination-boundary checks and symlink checks remain for sidecar writing and stale cleanup.

These direct-write and warning-only semantics intentionally supersede the earlier staging and collision-error requirements discussed in #322.

Generated-page streaming

*.pages.* factories now also render and write each yielded page before requesting the next definition, instead of buffering definitions and initialized pages. Source-page initialization for global data, generated-page vars/layouts/subscriptions, draft filtering, and existing generated-page conflict checks remain in place. Generated pages still skip additional-output hooks.

A later factory, validation, initialization, or render failure closes the iterator without requesting subsequent definitions. Earlier generated pages remain written and their ownership is retained for watch recovery, deletion, or empty-result cleanup.

Testing

  • Full Node suite passed twice: 281 passed, 0 failed.
  • ESLint and TypeScript checks passed.
  • Diff whitespace checks passed before committing.
  • Unit and integration coverage includes hook return forms, subscription isolation, companion providers, duplicate warnings, watch cleanup, unchanged-content checks, failure recovery, and safe stale cleanup.
  • Streaming regressions read the previous output from inside the generator before yielding another record or page, verify iterator closure, and cover cleanup after repeated and initial failures.
  • Updated the watch collision test to allow an owner to rewrite its own output during a full retry after failure; conflicting content remains rejected by the existing generated-page checks.
  • Watch tests use bounded waits for post-mutation build completion or the expected failure.
  • Updated page, layout, and generation documentation, exported public hook types, and changed the collection interface to an async generator.

Closes #322.

@coveralls

coveralls commented Sep 14, 2026

Copy link
Copy Markdown

Coverage Report for CI Build 34807984523

Coverage increased (+0.5%) to 94.698%

Details

  • Coverage increased (+0.5%) from the base build.
  • Patch coverage: 6 uncovered changes across 2 files (589 of 595 lines covered, 98.99%).
  • No coverage regressions found.

Uncovered Changes

File Changed Covered %
test-cases/page-additional-outputs/helpers.js 90 86 95.56%
index.js 79 77 97.47%
Total (12 files) 595 589 98.99%

Coverage Regressions

No coverage regressions found.


Coverage Stats

Coverage Status
Relevant Lines: 9549
Covered Lines: 9189
Line Coverage: 96.23%
Relevant Branches: 2710
Covered Branches: 2420
Branch Coverage: 89.3%
Branches in Coverage %: Yes
Coverage Strength: 390.98 hits per line

💛 - Coveralls

@bcomnes
bcomnes marked this pull request as ready for review September 14, 2026 05:01
Comment thread docs/layouts/README.md

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

## Additional outputs

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Need to rename this feature. This is a bad name.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed. My recommendation is to call the feature “page templates”, use templates as the named page/vars-companion/layout export, and use page-template for the output kind. Layout declarations would still produce page-owned files, not a separate layout-owned kind. This would rename the public API, types, and docs consistently rather than just changing the heading. The name has not been changed yet; please confirm whether that is the naming you want.

Comment thread docs/pages/README.md
For HTML and JS/TS companions, return suitable text or JSON instead of calling `readMarkdownContent()`.
The hook is a named module export, not a property of resolved vars or executable Markdown frontmatter.
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 `additionalOutputs`, the build fails with a provider-conflict error identifying both modules.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should set a prcidence order and emit a warning.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The current implementation throws when both the page module and its vars companion declare a hook. A simpler rule is page-module export > vars-companion export, matching the existing vars precedence, with a warning naming both providers and the selected one. Layout templates would remain additive and execute before the selected page-level provider. This is a proposed change, not yet applied.

Comment thread docs/pages/README.md
Page-module and companion hooks share the page renderer's subscriptions; each layout hook shares that specific layout renderer's subscriptions.
Declare these with the existing static `vars.dataDeps` array convention, or `dataDeps` in the companion's default vars object.
There is no `additionalOutputsDataDeps` export, and undeclared keys are not implicitly available.
See [Data subscriptions](../data/#data-subscriptions).

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Our iteration details are leaking into docs. This needs to be written for the user learning and not include ideas we designed out

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed. The documentation still explains discarded designs, for example the nonexistent additionalOutputsDataDeps export, rather than teaching the API. It should explain the supported export, arguments, shared subscriptions, ordering, paths, streaming, and practical failure/cleanup behavior without design-history commentary. I will align the terminology once the feature name is settled; no documentation edits have been made in this review pass.

throw new TypeError('Additional outputName must be a non-empty file path')
}
const name = outputName.replaceAll('\\', '/')
if (outputName.startsWith('\\') || name.startsWith('//') || /^[a-z]:/i.test(name)) {

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we share similar concerns with other builders? Maybe we need some shared utilities

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes. Ordinary templates already use assertInsideDest() from lib/helpers/path.js, and generated pages separately validate output names in normalizeGeneratedOutputPart(). The portable file-name checks and destination containment are good candidates for small shared path utilities. Resolution policy must remain explicit: these page outputs allow destination-root-relative leading slashes and contained parent traversal, while generated-page and existing template conventions differ. This can be shared without reintroducing a build registry or staging system.

const bytes = Buffer.from(output.content)
let unchanged = false
try {
unchanged = bytes.equals(await readFile(filepath))

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should store content hashes not re read bytes. This is going to be slow

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Correct: the current writer rereads the destination for every emitted record. We can instead hash the produced content and retain the last successfully written hash per output path in watch state, passing that state into each fresh page worker. A module-local cache would not survive worker restarts. Missing or externally changed files must not be skipped merely because a cached hash matches, and failed writes must not advance the cache; those cases need explicit tests. This would remain a small output cache, independent of the public manifest, not a transaction system. No hash-cache changes have been applied yet.

const pageFilePath = join(pageDir, page.pageInfo.outputName)

const formattedPageOutput = await page.renderFullPage()
const additionalOutputs = await writeAdditionalOutputs({

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Are we buffering outputs?

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is fixed in f5cfb0a. collectAdditionalOutputs() is an async generator, and writeAdditionalOutputs() awaits validation and writing of each yielded record before asking for the next one. The returned array contains only output metadata, not content. Generated *.pages.* factories now also render/write each yielded page before advancing. Tests read the prior output from inside the generator before yielding the next item and verify early closure on failure.

export async function pageWriter ({
dest,
page,
onAdditionalOutput,

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why aren't these available off the page?

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The callback only reports already-written output metadata so it is not lost if a later iterator yield throws; it is not another user-facing hook. It can be simplified by keeping emitted output records on the internal PageData instance and reading them from the build orchestrator on success or failure, rather than threading onAdditionalOutput through two writers. That would retain partial-output cleanup without exposing writable build state on the restricted page handle passed to user templates. This simplification has not been applied yet.

const { vars: builderVars } = await builder({ pageInfo, options: this.builderOptions })
const built = await builder({ pageInfo, options: this.builderOptions })
const { vars: builderVars } = built
if (!pageInfo.generated) {

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What is this block for? Whats generted? What is moduleHook?

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

pageInfo.generated means this page came from a *.pages.* factory, an existing page representation; the guard enforces the agreed deferral of output hooks for those pages. moduleHook is the named export loaded from the JS/TS page module, as opposed to companionHook loaded from its associated vars file. This block selects the page-level provider and records its source for diagnostics. Clearer names such as pageModuleTemplate and varsCompanionTemplate, plus extracting provider selection into a small method, would make that intent clearer and accommodate the requested precedence/warning rule.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Support page-owned additional outputs from page and layout hooks

2 participants