Generated Pages Files

Status: Implementation review — ready to land

Plan for adding first-class generated page support in response to the redirect-page discussion in PR #253.

PR #253 implementation review

Originally reviewed at commit 78d012e on 2026-08-29. Follow-up fixes were completed during the review.

Verdict

Ready to land. The worker boundary, generated-output lifecycle, watch behavior, error reporting, public data model, types, and documentation findings have been resolved. Generated pages remain close to regular pages while using a stable source-backed input set plus shared derived data from global.data.*.

Findings

1. Resolved: the documented blog-index example could not be sent back from the worker

README.md:1107-1117 places concrete PageData objects into vars.posts. Those objects contain functions such as resolved layout renderers.

Every page’s complete vars were added to its output record at lib/build-pages/page-builders/page-writer.js:109-120. The worker then tried to send that record back to the main thread at lib/build-pages/worker.js:9-10. Functions cannot be sent this way, so the documented example could write its HTML and then reject with a DataCloneError.

Generated-page render errors have the same root problem. lib/build-pages/index.js:560-563 sends the complete generated PageInfo as error context, including the function-valued generated.children stored at lib/build-pages/index.js:274-278. A useful render exception can therefore be replaced by an unclear worker-copy failure.

Implemented resolution:

2. Resolved: watch mode removes obsolete regular and generated page outputs

One-shot builds assume an empty destination. Watch mode previously rebuilt the pages that currently existed but did not remove files written by pages that had disappeared. This affected regular pages too, although generated pages made it easier to encounter because changing one *.pages.* file can rename or remove many outputs.

Implemented resolution:

3. Resolved: conflict detection is intentionally limited to page output paths

The generated-pages design requires generated pages not to replace regular pages or other generated pages. The implementation meets that scope by checking concrete and generated page output paths before rendering.

Templates can still target the same path as a regular or generated page. This is an existing whole-build limitation rather than behavior introduced by generated pages: regular pages and templates could already overwrite one another. Template output paths may also be chosen only after a template runs, while esbuild, static, and copied outputs are written by separate build steps. Manifest reconciliation cannot prevent these conflicts because it runs after files have been written.

Resolved for this PR by:

A duplicate-record check after the build would be too late to prevent an overwrite, so this PR does not add a partial post-write check.

4. Resolved: layout asset additions rebuild generated HTML in watch mode

For layout CSS or client add events, the watch handler built a filter from #layoutPageMap. That map contains only concrete siteData.pages, so generated pages were omitted when a concrete and generated page shared the affected layout. The new asset was built, but generated HTML did not gain its <link> or <script> reference.

Removal already reached the fallback full rebuild because the removed asset was absent from newly identified site data. The add/unlink branch now handles both directions explicitly: whenever any *.pages.* files exist, layout asset additions and removals use the same conservative full generated-page rebuild as layout source changes. Sites without pages files keep the targeted regular-page rebuild.

Regression coverage adds and removes both layout CSS and layout clients while a regular and generated page share the layout, and checks that both HTML outputs add and remove the asset references.

5. Resolved: generated-page setup errors keep their type and source context

Errors raised while importing or running a *.pages.* file, validating its definitions, or checking its output paths were caught only after the complete generated-pages resolution step. The responsible pages file was no longer known, and wrapping the error for worker transfer removed the output-conflict code and details.

Implemented resolution:

6. Resolved: public siteData.pages is intentionally discovery-only

Generated pages are downstream of regular page discovery and initialization. Every *.pages.* factory receives the same source-backed PageData[]; factories do not receive pages produced by earlier pages files. This avoids making generated output depend on pages-file processing order or creating circular page-generation dependencies.

The public siteData object remains the result of identifyPages(). Its pages array therefore contains only source-backed pages discovered from the source tree. global.data.* runs from the initialized source-backed pages, and its result is available to every *.pages.* factory. Generated pages are then created inside the page worker and combined with regular pages for templates, page functions, layouts, and rendering. They are not added back to the public discovery object.

This keeps one clear SiteData meaning rather than introducing separate concrete and expanded variants. It also avoids transferring complete generated PageInfo objects from the worker when their definitions can contain functions or other values that cannot be copied between threads.

Implemented resolution:

7. Resolved: generated-pages documentation and public types are complete

The Generated Pages documentation is now a top-level README section rather than part of Templates. It documents:

The public types were simplified before release:

The redirect security warning and meta-refresh SEO guidance remain accurate.

Objective assessment

The implementation achieves the core design in a clean one-shot build:

The generated-page build, watch behavior, and public site-data model are now intentional and covered by regression tests. The documented programmatic index builds successfully from grouped collection data returned by global.data.*, while its layout owns the rendered HTML. Runtime-only PageData[] values remain available while rendering and are left out of the page-vars snapshot returned from the worker.

8. Resolved: generated pages close issue #237

For now, the generalized generated-pages API is accepted as the resolution of issue #237. It provides the central redirect-page generation requested by the later discussion while keeping redirect output inside the normal page and layout pipeline.

The feature does not add redirect-specific destination validation or native hosting-provider redirect files. Those can be proposed separately if real-world use shows they are needed; they are not required for PR #253 to close #237.

9. Resolved during the final pass: remaining path and watch edge cases

The final complete-diff review found two smaller gaps:

Regression tests cover both cases. Generated drafts also have positive coverage with buildDrafts: true, complementing the existing default-omission coverage.

Landing checklist

Validation performed during review


Original design plan

Problem

Templates can already write arbitrary files, including redirect HTML files, _redirects, feeds, and other generated assets. They do not, however, create real DomStack pages:

The final PR comments point toward a dedicated *.pages.ts feature rather than more redirect docs or more template escape hatches.

Refined recommendation

Add a generated-pages file type, discovered as *.pages.*, but do not treat its outputs as a separate output class.

Instead:

*.pages.* files are page factories. They receive collection data derived by global.data.*, and their returned definitions expand into normal PageInfo entries before templates and final page rendering run.

This preserves the useful authoring model from templates — one file can return one output, many outputs, or an async stream of outputs — while keeping generated results inside the normal page pipeline.

Feature Purpose Output semantics
*.template.* Generate arbitrary files Caller provides final file content
*.pages.* Generate real pages Caller provides output name, vars, and children; DomStack renders through layout/page pipeline

File naming

Discover the same JS/TS module families as templates:

*.pages.ts / *.pages.mts / *.pages.cts
*.pages.js / *.pages.mjs / *.pages.cjs

Use nodeHasTS just like templateSuffixs in lib/identify-pages.js.

Examples:

src/redirects.pages.js
src/blog/indexes.pages.ts
src/tags.pages.mjs

Proposed API

A pages file exports a default function, async function, array, object, or async iterable that yields generated page definitions.

import type { PagesFunction } from '@domstack/static'

export default (async function redirectsPages ({ pages }) {
  return [
    {
      outputName: '2020/old-slug/index.html',
      vars: {
        layout: 'redirect',
        title: 'Redirecting...',
        redirectTo: '/2020/new-slug/',
      },
    },
  ]
}) satisfies PagesFunction

The generated page definition is template-like, but layout-driven: outputName chooses where to write the page, children supplies the layout child content, and vars controls page/layout variables.

type GeneratedPageDefinition<Vars = Record<string, any>, Children = string> = {
  outputName?: string // default: '<pages-file-name>/index.html'
  vars?: Vars
  children?: Children | ((params: PageFunctionParams<Vars, Children>) => Children | Promise<Children>) | undefined
  draft?: boolean
}

Rules:

Pages file parameters

Pass enough context for reflection while avoiding circular or ordering-dependent generation:

type PagesFunctionParams = {
  pages: PageData[]
  vars: Record<string, any>
  pagesFile: PagesFileInfo
  siteData: SiteData
}

pages contains only concrete/source-backed pages discovered directly from the source tree, initialized with default/global/page/builder vars and the values returned by global.data.*. It does not include generated pages from any *.pages.* file, including pages produced by earlier files in the same build.

vars contains default vars, global.vars.*, and the collection data returned by global.data.*. This gives every pages file the same stable introspection set and derived-data input.

Build pipeline

Do not run *.pages.* files inside identifyPages(). They need initialized concrete page data (page.vars, builder vars, pageInfo, render helpers), and identifyPages() should remain a file-discovery phase.

Instead, add an explicit page-expansion phase early in buildPagesDirect().

Current pipeline:

identifyPages()
  discover concrete pages
  discover layouts/templates/global assets

buildPagesDirect()
  resolve default/global vars
  resolve layouts
  initialize concrete PageData[]
  resolve global.data.* with concrete pages
  stamp globalDataVars
  render pages and templates

Proposed pipeline:

identifyPages()
  discover concrete pages
  discover layouts/templates/global assets
  discover pagesFiles (*.pages.*)

buildPagesDirect()
  resolve default/global vars
  resolve layouts

  concretePageInfos = siteData.pages
  concretePageData = initialize concrete PageData[]

  resolve global.data.* with concretePageData
  stamp globalDataVars onto concretePageData

  run pagesFiles with concretePageData + global/globalData vars + siteData
  validate generated page definitions
  convert definitions into generated PageInfo objects
  detect output conflicts against concrete pages and earlier generated pages

  generatedPageData = initialize generated PageData[]
  stamp globalDataVars onto generatedPageData
  allPages = [...concretePageData, ...generatedPageData]

  render pages/templates using siteData + allPages

The important framing is that global.data.* derives shared collection data from concrete pages, then generated outputs become ordinary pages built from that source data. Final page rendering and templates operate on the combined page list, while public siteData and watch maps remain source-backed.

Data model changes

identify-pages.js

Add:

export const pagesSuffixs = nodeHasTS
  ? ['.pages.ts', '.pages.mts', '.pages.cts', '.pages.js', '.pages.mjs', '.pages.cjs']
  : ['.pages.js', '.pages.mjs', '.pages.cjs']

Add PagesFileInfo and siteData.pagesFiles alongside siteData.templates.

Optionally distinguish the raw concrete pages from expanded pages once expansion has run:

type SiteData = {
  pages: PageInfo[]          // expanded pages after generated-page expansion
  concretePages?: PageInfo[] // source-backed pages discovered by identifyPages()
  pagesFiles: PagesFileInfo[]
}

identifyPages() can initially return pages and concretePages as the same list. The expansion phase can then produce an expandedSiteData object rather than mutating the original siteData in place.

Generated page info

Represent generated pages as regular PageInfo entries with an additional marker:

type GeneratedPageInfo = PageInfo & {
  type: 'js'
  generated: {
    pagesFile: PagesFileInfo
    vars: Record<string, any>
    children: unknown | PageFunction
  }
}

Let the existing JS page builder consume the in-memory generated payload before falling back to importing a concrete JS page module:

if (pageInfo.generated) {
  return {
    vars: pageInfo.generated.vars,
    pageLayout: typeof pageInfo.generated.children === 'function'
      ? pageInfo.generated.children
      : () => pageInfo.generated.children ?? '',
  }
}

Generated pages then follow the same PageData initialization and rendering path as concrete JavaScript pages. Generated vars and functions stay inside the page worker while rendering. When the worker returns its build report, it copies each top-level page var independently and leaves out values that cannot be copied. Generated page error information similarly leaves out vars and children.

Conflict detection

Generated pages must not silently overwrite concrete pages, loose markdown outputs, or other generated pages. Any duplicate generated/concrete page output path must throw a conflict error.

Minimum v1 conflict checks:

  1. Validate outputName is relative and cannot escape the pages file’s directory.
  2. Compute:
    • outputRelname = join(pagesFile.path, outputName)
    • path = dirname(outputRelname)
    • outputName = basename(outputRelname)
    • url = computePageUrl({ path, outputName })
  3. Reject duplicates within:
    • existing concrete siteData.pages[*].outputRelname
    • generated definitions from all pages files

Prefer hard errors for duplicate page output paths, matching the existing duplicate page-source behavior.

Watch mode integration

Generated pages should eventually make watch mode cleaner, not more special, if watch maps are rebuilt from expanded page data.

Conservative v1

Treat *.pages.* as structural page inputs:

Better follow-up

Once the build has an expandedSiteData concept, rebuild watch maps from expanded pages:

This avoids the current broad special case of “if any pages files exist, layout changed means rebuild all pages.”

Public types

Export from the dedicated types.js type entry:

Add JSDoc typedefs first, then declaration generation will expose them through the existing tsc -p declaration.tsconfig.json flow.

Documentation examples

Redirects

---
title: Current Post
redirectFrom:
  - /2020/old-slug/
---
// global.data.js validates destination-page metadata and derives `{ from, to }`.
export default function ({ pages }) {
  const redirects = []
  const redirectOwners = new Map()
  for (const page of pages) {
    const redirectFrom = page.vars.redirectFrom
    if (redirectFrom === undefined) continue

    const source = page.pageInfo.pageFile.relname
    if (!Array.isArray(redirectFrom)) throw new TypeError(`redirectFrom on "${source}" must be an array`)
    for (const from of redirectFrom) {
      if (typeof from !== 'string' || !from.startsWith('/') || from.startsWith('//')) throw new Error(`Invalid redirectFrom on "${source}"`)
      const existingSource = redirectOwners.get(from)
      if (existingSource) throw new Error(`redirectFrom "${from}" is declared by both "${existingSource}" and "${source}"`)
      redirectOwners.set(from, source)
      redirects.push({ from, to: page.pageInfo.url })
    }
  }
  return { redirects }
}

// redirects.pages.js turns the derived collection into normal pages.
export default function ({ vars }) {
  const pages = []
  for (const { from, to } of vars.redirects) {
    const relativePath = from.slice(1)
    pages.push({
      outputName: relativePath.endsWith('/') ? `${relativePath}index.html` : relativePath,
      vars: {
        layout: 'redirect',
        title: 'Redirecting...',
        redirectTo: to,
      },
    })
  }
  return pages
}
// src/redirect.layout.js
import { html, render } from 'fragtml'

export default function redirectLayout ({ vars }) {
  return render(html`<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <meta http-equiv="refresh" content="0;url=${vars.redirectTo}">
  <link rel="canonical" href="${vars.redirectTo}">
  <title>${vars.title}</title>
</head>
<body>
  <p>Redirecting to <a href="${vars.redirectTo}">${vars.redirectTo}</a></p>
</body>
</html>`)
}

Redirect metadata validation happens in global.data.* while the destination source page is known. It rejects malformed metadata, unsafe paths, and duplicate old URLs with source-specific errors. The generated-output validator remains a second path-safety check.

Blog indexes

global.data.* groups and sorts the source posts once. The pages file maps that prepared collection to normal pages, and the selected layout renders each archive:

// src/blog-indexes.pages.js
export default function ({ vars }) {
  const pages = []

  for (const { year, posts } of vars.blogIndexes) {
    pages.push({
      outputName: `blog/${year}/index.html`,
      vars: {
        layout: 'blog-index',
        title: `${year} posts`,
        posts,
      },
    })
  }

  return pages
}

Tests

Add a focused generated-pages fixture, likely test-cases/generated-pages/:

  1. Discovers *.pages.js and exposes it on siteData.pagesFiles.
  2. Collects destination-page redirectFrom metadata in global.data.js and generates redirect pages through a redirect.layout.js.
  3. global.data.js receives source-backed pages, and its returned collection data is available to pages factories and template vars.
  4. Generated blog/year indexes can use collection data derived from concrete pages.
  5. Multiple *.pages.* files each receive only concrete pages, not generated pages from other pages files.
  6. Duplicate generated/concrete output paths throw an aggregate build error.
  7. Invalid generated output paths (/absolute, ../escape, nested/../../escape) throw a clear error.
  8. Async iterable pages files work for large output sets.
  9. Watch mode: changing a *.pages.js file triggers a full page rebuild.
  10. Follow-up watch test: once expanded watch maps exist, a layout change rebuilds generated pages using that layout.

Run at minimum:

npm run test:node-test -- test-cases/generated-pages/index.test.js
npm run test:neostandard
npm run test:tsc

Then run full npm test before merging.

Design decisions

  1. *.pages.* files are page factories, not a separate output system.
    • Their outputs become regular PageInfo entries in the expanded page list.
    • Downstream systems should consume the expanded page list wherever possible.
  2. Generated pages are distinct from concrete/source-backed pages only while pages files are running.
    • The pages argument passed to *.pages.* files contains only concrete pages discovered directly from the source tree.
    • Generated pages are not passed to other pages files in the same build.
    • This avoids ordering-dependent generation.
  3. Generated pages do not support page-level style.css, client.js, or workers.
    • They participate only in global assets and layout assets.
    • This keeps generated pages focused on central page creation while concrete pages remain the place for page-local asset bundles.
  4. Generated pages pass child content directly; they do not pull in existing page files as render templates.
    • children may be static content or an inline render function.
    • Reusable presentation belongs in layouts or userland helper functions imported by the pages file.

Milestones

  1. Discovery and types: pagesSuffixs, PagesFileInfo, siteData.pagesFiles, exported JSDoc typedefs.
  2. Runtime: resolvePagesFiles(), generated page validation, and generated PageInfo support in the JS page builder.
  3. Data flow: run global.data.* from concrete pages and pass its result to generated-page factories.
  4. Expansion: combine concrete and generated pages for templates and final page rendering.
  5. Errors: duplicate generated/concrete page output conflicts and invalid generated output path errors with useful file context.
  6. Tests and docs: generated-pages fixture, README section, redirect and blog-index examples.
  7. Watch follow-up: use conservative full page rebuilds when generated outputs may change; keep targeted source-page maps for sites without pages files.