diff --git a/README.md b/README.md
index 1c432793..502d802f 100644
--- a/README.md
+++ b/README.md
@@ -1094,6 +1094,101 @@ await pMap(allPosts, async (page) => {
const html = renderCache.get(page.pageInfo.path) ?? ''
```
+### Generated Pages
+
+Files named `*.pages.js` (or `*.pages.ts` when TypeScript loading is available) generate real DomStack pages from one central file. They are similar to templates, but layout-driven: return one or more objects with `outputName`, optional `vars`, and optional `children`, and DomStack renders each output through the normal page/layout pipeline.
+
+Generated pages receive initialized concrete pages in their `pages` parameter. They do not receive pages generated by other `*.pages.*` files, so each pages file has a stable source-backed view of the site. After generated pages are created, they are included in the full `pages` array for `global.data.*`, templates, pages, and layouts.
+
+```js
+// src/blog-indexes.pages.js
+import { html } from 'fragtml'
+
+export default function ({ pages }) {
+ const posts = pages.filter(page => page.vars.publishDate && page.pageInfo.path.startsWith('blog/'))
+
+ return {
+ outputName: 'blog/index.html',
+ vars: {
+ layout: 'blog-index',
+ title: 'Blog index',
+ posts,
+ },
+ children: ({ vars }) => html`
${vars.title} ${vars.posts.length} posts
`,
+ }
+}
+```
+
+`outputName` is resolved relative to the `*.pages.*` file's directory and must stay relative: no leading `/` and no `..` path segments. If omitted, it defaults to `/index.html`. Generated pages use global and layout assets only; they do not have page-local `style.css`, `client.js`, or workers.
+
+### Redirect Pages
+
+Sites migrating from another platform often need redirect pages for old URLs that no longer exist. A `*.pages.*` file can centrally generate those pages while keeping the redirect HTML in a reusable layout.
+
+```js
+// src/redirects.pages.js
+// Generates one index.html per redirect entry using the redirect layout.
+
+const redirects = [
+ { from: '2020/old-slug', to: '/2020/new-slug/' },
+ { from: '2021/another-old', to: '/2021/another-new/' },
+]
+
+export default function redirectsPages () {
+ return redirects.map(({ from, to }) => ({
+ outputName: `${from}/index.html`,
+ vars: {
+ layout: 'redirect',
+ title: 'Redirecting...',
+ redirectTo: to,
+ },
+ }))
+}
+```
+
+```js
+// src/redirect.layout.js
+
+import { html, render } from 'fragtml'
+
+export default function redirectLayout ({ vars }) {
+ return render(html`
+
+
+
+
+
+ ${vars.title}
+
+
+ Redirecting to ${vars.redirectTo}
+
+`)
+}
+```
+
+The `outputName` field controls the output path. Using `${from}/index.html` creates a directory-style URL at the old path. `fragtml` escapes interpolated values by default, including attribute values and link text. Escaping does not block dangerous URL schemes like `javascript:` — keep redirect targets to known-safe URL patterns (relative paths or verified external URLs). Be careful with `from` values used in `outputName`: generated page output names must be relative and cannot contain `..` segments.
+
+**SEO note:** Meta-refresh is a client-side redirect. Search engines may not treat it as a permanent 301 redirect. For static hosting platforms that support server-side redirects, you can instead generate a `_redirects` file (Netlify, Cloudflare Pages) or `vercel.json` (Vercel) using the object template type:
+
+```js
+// src/redirects-netlify.txt.template.js
+// Generates a _redirects file for Netlify / Cloudflare Pages.
+
+const redirects = [
+ { from: '/2020/old-slug/', to: '/2020/new-slug/' },
+]
+
+export default function () {
+ return {
+ outputName: '_redirects',
+ content: redirects.map(({ from, to }) => `${from} ${to} 301`).join('\n'),
+ }
+}
+```
+
+Both approaches can coexist. Copying a directory that contains a hand-crafted `_redirects` file via `--copy` is also an option when you prefer to manage redirects outside the build.
+
## Domstack Manifest
> [!WARNING]
diff --git a/index.js b/index.js
index d9345137..64183067 100644
--- a/index.js
+++ b/index.js
@@ -3,8 +3,7 @@
* @import { Stats } from 'node:fs'
* @import { FSWatcher } from 'chokidar'
* @import { WorkerBuildStepResult } from './lib/build-pages/index.js'
-
- * @import { PageInfo, TemplateInfo } from './lib/identify-pages.js'
+ * @import { PageInfo, TemplateInfo, PagesFileInfo } from './lib/identify-pages.js'
* @import { TestBuildResult } from './types.js'
* @import { BsInstance } from '@domstack/sync'
* @import { Logger as PinoLogger } from 'pino'
@@ -35,6 +34,7 @@ import {
layoutSuffixs,
layoutStyleSuffix,
templateSuffixs,
+ pagesSuffixs,
globalVarsNames,
globalDataNames,
esbuildSettingsNames,
@@ -99,6 +99,8 @@ export class DomStack {
#pageDepMap = new Map()
/** @type {Map>} depFilepath → Set */
#templateDepMap = new Map()
+ /** @type {Map>} depFilepath → Set */
+ #pagesFileDepMap = new Map()
/** @type {Set} absolute filepaths of esbuild entry points */
#esbuildEntryPoints = new Set()
@@ -452,6 +454,19 @@ ${siteData.errors.map(err => ` ${err.message}`).join('\n')}`)
}
}
+ /**
+ * Rebuild all pages and refresh dependency maps afterward.
+ * Generated pages can change their import graph without changing the site's
+ * discovered file structure, so their full rebuild paths use this helper.
+ *
+ * @param {SiteData} siteData
+ */
+ async #runGeneratedPageBuild (siteData) {
+ const pageBuildResults = await this.#runPageBuild(siteData)
+ await this.#rebuildMaps(siteData)
+ return pageBuildResults
+ }
+
/**
* @param {() => Promise} fn
*/
@@ -478,6 +493,7 @@ ${siteData.errors.map(err => ` ${err.message}`).join('\n')}`)
const layoutFileMap = /** @type {Map} */ (new Map())
const pageDepMap = /** @type {Map>} */ (new Map())
const templateDepMap = /** @type {Map>} */ (new Map())
+ const pagesFileDepMap = /** @type {Map>} */ (new Map())
// layoutFileMap: layout filepath → layoutName
for (const layout of Object.values(siteData.layouts)) {
@@ -561,6 +577,20 @@ ${siteData.errors.map(err => ` ${err.message}`).join('\n')}`)
}
}
+ // pagesFileDepMap: dep filepath → Set
+ for (const pagesFileInfo of siteData.pagesFiles ?? []) {
+ try {
+ const deps = await find(pagesFileInfo.pagesFile.filepath)
+ for (const dep of deps) {
+ const absPath = resolve(dep)
+ if (!pagesFileDepMap.has(absPath)) pagesFileDepMap.set(absPath, new Set())
+ pagesFileDepMap.get(absPath)?.add(pagesFileInfo)
+ }
+ } catch {
+ // best-effort
+ }
+ }
+
// esbuildEntryPoints: absolute filepaths of all esbuild entry points
const esbuildEntryPoints = /** @type {Set} */ (new Set())
if (siteData.globalClient) esbuildEntryPoints.add(resolve(siteData.globalClient.filepath))
@@ -584,6 +614,7 @@ ${siteData.errors.map(err => ` ${err.message}`).join('\n')}`)
this.#layoutFileMap = layoutFileMap
this.#pageDepMap = pageDepMap
this.#templateDepMap = templateDepMap
+ this.#pagesFileDepMap = pagesFileDepMap
this.#esbuildEntryPoints = esbuildEntryPoints
}
@@ -640,6 +671,11 @@ ${siteData.errors.map(err => ` ${err.message}`).join('\n')}`)
// 7. Layout file itself → rebuild pages using that layout
if (layoutSuffixs.some(s => changedBasename.endsWith(s))) {
+ if ((siteData.pagesFiles?.length ?? 0) > 0) {
+ this.#logger.info(`"${changedBasename}" changed, rebuilding all pages...`)
+ return this.#runGeneratedPageBuild(siteData)
+ }
+
const layoutName = this.#layoutFileMap.get(changedPath)
if (layoutName) {
const affectedPages = this.#layoutPageMap.get(layoutName)
@@ -656,6 +692,10 @@ ${siteData.errors.map(err => ` ${err.message}`).join('\n')}`)
// 8. Dep of a layout
if (this.#layoutDepMap.has(changedPath)) {
+ if ((siteData.pagesFiles?.length ?? 0) > 0) {
+ this.#logger.info(`"${changedBasename}" changed, rebuilding all pages...`)
+ return this.#runGeneratedPageBuild(siteData)
+ }
const affectedLayoutNames = this.#layoutDepMap.get(changedPath) ?? new Set()
const affectedPages = new Set(/** @type {PageInfo[]} */ ([]))
for (const layoutName of affectedLayoutNames) {
@@ -673,12 +713,24 @@ ${siteData.errors.map(err => ` ${err.message}`).join('\n')}`)
if (this.#pageFileMap.has(changedPath)) {
const affectedPage = this.#pageFileMap.get(changedPath)
if (affectedPage) {
+ if ((siteData.pagesFiles?.length ?? 0) > 0) {
+ this.#logger.info(`"${changedBasename}" changed, rebuilding all pages...`)
+ return this.#runGeneratedPageBuild(siteData)
+ }
logRebuildTree(changedBasename, this.#logger, new Set([affectedPage]))
return this.#runPageBuild(siteData, [affectedPage.pageFile.filepath], [])
}
}
- // 10. Template file itself
+ // 10. Pages file itself → full page rebuild
+ if (pagesSuffixs.some(s => changedBasename.endsWith(s))) {
+ if (siteData.pagesFiles?.some(p => p.pagesFile.filepath === changedPath)) {
+ this.#logger.info(`"${changedBasename}" changed, rebuilding all pages...`)
+ return this.#runGeneratedPageBuild(siteData)
+ }
+ }
+
+ // 11. Template file itself
if (templateSuffixs.some(s => changedBasename.endsWith(s))) {
const templateInfo = siteData.templates.find(t => t.templateFile.filepath === changedPath)
if (templateInfo) {
@@ -687,17 +739,27 @@ ${siteData.errors.map(err => ` ${err.message}`).join('\n')}`)
}
}
- // 11. Dep of a page.js or page.vars
+ // 12. Dep of a page.js or page.vars
if (this.#pageDepMap.has(changedPath)) {
const affectedPages = this.#pageDepMap.get(changedPath) ?? new Set()
if (affectedPages.size > 0) {
+ if ((siteData.pagesFiles?.length ?? 0) > 0) {
+ this.#logger.info(`"${changedBasename}" changed, rebuilding all pages...`)
+ return this.#runGeneratedPageBuild(siteData)
+ }
logRebuildTree(changedBasename, this.#logger, affectedPages)
const pageFilterPaths = Array.from(affectedPages).map(p => p.pageFile.filepath)
return this.#runPageBuild(siteData, pageFilterPaths, [])
}
}
- // 12. Dep of a template file
+ // 13. Dep of a pages file → full page rebuild
+ if (this.#pagesFileDepMap.has(changedPath)) {
+ this.#logger.info(`"${changedBasename}" changed, rebuilding all pages...`)
+ return this.#runGeneratedPageBuild(siteData)
+ }
+
+ // 14. Dep of a template file
if (this.#templateDepMap.has(changedPath)) {
const affectedTemplates = this.#templateDepMap.get(changedPath) ?? new Set()
if (affectedTemplates.size > 0) {
@@ -707,7 +769,7 @@ ${siteData.errors.map(err => ` ${err.message}`).join('\n')}`)
}
}
- // 13. No matching rule — skip.
+ // 15. No matching rule — skip.
this.#logger.info(`"${changedBasename}" changed but did not match any rebuild rule, skipping.`)
}
diff --git a/lib/build-pages/index.js b/lib/build-pages/index.js
index c962a513..6afca6b5 100644
--- a/lib/build-pages/index.js
+++ b/lib/build-pages/index.js
@@ -1,13 +1,13 @@
/**
- * @import { BuilderOptions } from './page-builders/page-writer.js'
+ * @import { BuilderOptions, PageFunction } from './page-builders/page-writer.js'
* @import { TemplateReport } from './page-builders/template-builder.js'
* @import { BuildStep, SiteData, DomStackOpts } from '../builder.js'
- * @import { PageInfo, TemplateInfo } from '../identify-pages.js'
+ * @import { PageInfo, TemplateInfo, PagesFileInfo } from '../identify-pages.js'
* @import { ResolvedLayout } from './page-data.js'
*/
import { Worker } from 'worker_threads'
-import { join } from 'path'
+import { basename, dirname, isAbsolute, join, normalize, resolve } from 'path'
import pMap from 'p-map'
import { cpus } from 'os'
import { keyBy } from '../helpers/key-by.js'
@@ -15,6 +15,9 @@ import { resolveVars, resolveGlobalData } from './resolve-vars.js'
import { pageBuilders, templateBuilder } from './page-builders/index.js'
import { PageData, resolveLayout } from './page-data.js'
import { pageWriter } from './page-builders/page-writer.js'
+import { computePageUrl } from './compute-page-url.js'
+import { DomStackOutputConflictError } from '../helpers/domstack-error.js'
+import { isAsyncIterable, isPlainObject } from '../helpers/type-guards.js'
const MAX_CONCURRENCY = Math.min(cpus().length, 24)
@@ -59,6 +62,7 @@ const __dirname = import.meta.dirname
* @typedef {object} BuildPagesFilterOptions
* @property {string[] | null} [pageFilterPaths] - If set, only rebuild pages whose pageFile.filepath is in this list.
* @property {string[] | null} [templateFilterPaths] - If set, only rebuild templates whose templateFile.filepath is in this list.
+ * @property {boolean | undefined} [buildDrafts] - Include generated page definitions marked as drafts.
*/
/**
@@ -67,6 +71,48 @@ const __dirname = import.meta.dirname
* @typedef {DomStackOpts & BuildPagesFilterOptions} BuildPagesOptions
*/
+/**
+ * Parameters passed to a *.pages.* default export function.
+ *
+ * @typedef {object} PagesFunctionParams
+ * @property {PageData[]} pages - Initialized concrete/source-backed pages only.
+ * @property {Record} vars - Default and global vars, before global.data.* output.
+ * @property {PagesFileInfo} pagesFile - Info about the current *.pages.* file.
+ * @property {SiteData} siteData - Site data from identifyPages().
+ */
+
+/**
+ * Definition for one page produced by a *.pages.* file.
+ *
+ * @template {Record} [T=Record]
+ * @template [U=any]
+ * @typedef {object} GeneratedPageDefinition
+ * @property {string} [outputName] - Relative output filename, defaulting to the pages file name.
+ * @property {T} [vars] - Page vars to merge through the normal page/layout pipeline.
+ * @property {U | PageFunction} [children] - Static child content or inline render function.
+ * @property {boolean} [draft] - When true, only build if buildDrafts is enabled.
+ */
+
+/**
+ * Synchronous generated-pages function.
+ *
+ * @template {Record} [T=Record]
+ * @template [U=any]
+ * @callback PagesFunction
+ * @param {PagesFunctionParams} params
+ * @returns {GeneratedPageDefinition | GeneratedPageDefinition[] | AsyncIterable> | Promise | GeneratedPageDefinition[] | AsyncIterable>>}
+ */
+
+/**
+ * Asynchronous generated-pages function.
+ *
+ * @template {Record} [T=Record]
+ * @template [U=any]
+ * @callback AsyncPagesFunction
+ * @param {PagesFunctionParams} params
+ * @returns {Promise | GeneratedPageDefinition[] | AsyncIterable>>}
+ */
+
/**
* @typedef {BuildStep<
* 'page',
@@ -84,6 +130,7 @@ const __dirname = import.meta.dirname
* @typedef {object} WorkerErrorData
* @property {PageInfo} [page] - Page context for page var/rendering errors.
* @property {TemplateInfo} [template] - Template context for template rendering errors.
+ * @property {PagesFileInfo} [pagesFile] - Pages-file context for generated page resolution errors.
*/
/**
@@ -94,7 +141,7 @@ export { pageBuilders }
/**
* @param {WorkerErrorData} errorData
- * @returns {{ type: 'page' | 'template', path: string } | null}
+ * @returns {{ type: 'page' | 'template' | 'pages file', path: string } | null}
*/
function getWorkerErrorContext (errorData) {
if (errorData.page) {
@@ -107,6 +154,10 @@ function getWorkerErrorContext (errorData) {
return { type: 'template', path: templatePath }
}
+ if (errorData.pagesFile) {
+ return { type: 'pages file', path: errorData.pagesFile.pagesFile.relname }
+ }
+
return null
}
@@ -132,6 +183,170 @@ function restoreWorkerError (error, errorData) {
return restoredError
}
+/**
+ * @param {unknown} value
+ * @returns {GeneratedPageDefinition}
+ */
+function validateGeneratedPageDefinition (value) {
+ if (!isPlainObject(value)) {
+ throw new TypeError('Generated page definition must be an object')
+ }
+
+ if ('outputName' in value && value['outputName'] !== undefined && typeof value['outputName'] !== 'string') {
+ throw new TypeError('Generated page outputName must be a string')
+ }
+ if ('vars' in value && value['vars'] !== undefined && !isPlainObject(value['vars'])) {
+ throw new TypeError('Generated page vars must be an object')
+ }
+ if ('draft' in value && value['draft'] !== undefined && typeof value['draft'] !== 'boolean') {
+ throw new TypeError('Generated page draft must be a boolean')
+ }
+
+ return /** @type {GeneratedPageDefinition} */ (value)
+}
+
+/**
+ * @param {unknown} value
+ * @returns {Promise}
+ */
+async function collectGeneratedPageDefinitions (value) {
+ if (value == null) return []
+
+ if (Array.isArray(value)) {
+ return value.map(validateGeneratedPageDefinition)
+ }
+
+ if (isAsyncIterable(value)) {
+ /** @type {GeneratedPageDefinition[]} */
+ const definitions = []
+ for await (const definition of value) {
+ definitions.push(validateGeneratedPageDefinition(definition))
+ }
+ return definitions
+ }
+
+ return [validateGeneratedPageDefinition(value)]
+}
+
+/**
+ * @param {string} value
+ * @param {object} opts
+ * @param {string} opts.field
+ * @param {boolean} [opts.allowEmpty]
+ * @returns {string}
+ */
+function normalizeGeneratedOutputPart (value, { field, allowEmpty = false }) {
+ if (typeof value !== 'string') throw new TypeError(`Generated page ${field} must be a string`)
+ if (!allowEmpty && value.length === 0) throw new Error(`Generated page ${field} must not be empty`)
+ if (isAbsolute(value) || /^[A-Za-z]:[\\/]/.test(value)) throw new Error(`Generated page ${field} must be relative: ${value}`)
+ if (value.split(/[\\/]+/).includes('..')) throw new Error(`Generated page ${field} must not contain ".." segments: ${value}`)
+
+ const normalized = normalize(value)
+ return normalized === '.' ? '' : normalized
+}
+
+/**
+ * @param {object} params
+ * @param {GeneratedPageDefinition} params.definition
+ * @param {PagesFileInfo} params.pagesFile
+ * @param {number} params.index
+ * @returns {PageInfo}
+ */
+function generatedDefinitionToPageInfo ({ definition, pagesFile, index }) {
+ const relativeOutputName = normalizeGeneratedOutputPart(definition.outputName ?? `${pagesFile.name}/index.html`, { field: 'outputName' })
+ const outputRelname = join(pagesFile.path, relativeOutputName)
+ const generatedPath = dirname(outputRelname) === '.' ? '' : dirname(outputRelname)
+ const outputName = basename(outputRelname)
+
+ return {
+ pageFile: {
+ ...pagesFile.pagesFile,
+ basename: `${pagesFile.pagesFile.basename}#${index}`,
+ relname: `${pagesFile.pagesFile.relname}#${index}`,
+ type: 'js',
+ },
+ type: 'js',
+ path: generatedPath,
+ url: computePageUrl({ path: generatedPath, outputName }),
+ outputName,
+ outputRelname,
+ draft: Boolean(definition.draft),
+ generated: {
+ pagesFile,
+ vars: definition.vars ?? {},
+ children: definition.children,
+ },
+ }
+}
+
+/**
+ * @param {object} params
+ * @param {SiteData} params.siteData
+ * @param {PageData[]} params.concretePages
+ * @param {Record} params.globalVars
+ * @param {boolean | undefined} params.buildDrafts
+ * @returns {Promise}
+ */
+async function resolveGeneratedPageInfos ({ siteData, concretePages, globalVars, buildDrafts }) {
+ /** @type {PageInfo[]} */
+ const generatedPageInfos = []
+ /** @type {Map} */
+ const pageOutputClaims = new Map()
+
+ for (const pageInfo of siteData.pages) {
+ pageOutputClaims.set(resolve(pageInfo.outputRelname), {
+ type: 'page',
+ path: pageInfo.outputRelname,
+ })
+ }
+
+ for (const pagesFile of siteData.pagesFiles ?? []) {
+ const importResults = await import(pagesFile.pagesFile.filepath)
+ if (!('default' in importResults)) throw new Error(`Missing default export from pages file: ${pagesFile.pagesFile.relname}`)
+
+ const pagesExport = importResults.default
+ const pagesResults = typeof pagesExport === 'function'
+ ? await pagesExport({
+ pages: concretePages,
+ vars: globalVars,
+ pagesFile,
+ siteData,
+ })
+ : pagesExport
+
+ const definitions = await collectGeneratedPageDefinitions(pagesResults)
+
+ for (const [index, definition] of definitions.entries()) {
+ const generatedPageInfo = generatedDefinitionToPageInfo({ definition, pagesFile, index })
+ if (generatedPageInfo.draft && !buildDrafts) continue
+
+ const outputKey = resolve(generatedPageInfo.outputRelname)
+ const existingClaim = pageOutputClaims.get(outputKey)
+ if (existingClaim) {
+ throw new DomStackOutputConflictError(
+ `Output path conflict: ${generatedPageInfo.outputRelname} is produced by both ${existingClaim.path} and ${pagesFile.pagesFile.relname}.`,
+ {
+ outputPath: generatedPageInfo.outputRelname,
+ a: existingClaim,
+ b: {
+ type: 'page',
+ path: pagesFile.pagesFile.relname,
+ },
+ }
+ )
+ }
+
+ pageOutputClaims.set(outputKey, {
+ type: 'page',
+ path: generatedPageInfo.outputRelname,
+ })
+ generatedPageInfos.push(generatedPageInfo)
+ }
+ }
+
+ return generatedPageInfos
+}
+
/**
* Page builder glue. Most of the magic happens in the builders.
*
@@ -145,6 +360,7 @@ export function buildPages (src, dest, siteData, opts) {
const workerOpts = {
...(opts?.pageFilterPaths !== undefined ? { pageFilterPaths: opts.pageFilterPaths } : {}),
...(opts?.templateFilterPaths !== undefined ? { templateFilterPaths: opts.templateFilterPaths } : {}),
+ ...(opts?.buildDrafts !== undefined ? { buildDrafts: opts.buildDrafts } : {}),
}
return new Promise((resolve, reject) => {
@@ -248,8 +464,10 @@ export async function buildPagesDirect (_src, dest, siteData, opts) {
markdownItSettingsPath: siteData.markdownItSettings?.filepath || null
}
- // Mix in resolveVars, renderInnerPage and renderFullPage methods
- const pages = await pMap(siteData.pages, async (pageInfo) => {
+ /**
+ * @param {PageInfo} pageInfo
+ */
+ const initPageData = async (pageInfo) => {
const pageData = new PageData({
pageInfo,
globalVars,
@@ -269,9 +487,35 @@ export async function buildPagesDirect (_src, dest, siteData, opts) {
result.errors.push({ error: variableResolveError, errorData: { page: pageInfo } })
}
return pageData
- }, { concurrency: MAX_CONCURRENCY })
+ }
- // Run global.data.js after all pages are initialized — receives fully resolved PageData[]
+ // Mix in resolveVars, renderInnerPage and renderFullPage methods for concrete pages.
+ const concretePages = await pMap(siteData.pages, initPageData, { concurrency: MAX_CONCURRENCY })
+
+ if (result.errors.length > 0) return result
+
+ let generatedPageInfos = /** @type {PageInfo[]} */ ([])
+ try {
+ generatedPageInfos = await resolveGeneratedPageInfos({
+ siteData,
+ concretePages,
+ globalVars,
+ buildDrafts: opts?.buildDrafts,
+ })
+ } catch (err) {
+ if (!(err instanceof Error)) throw new Error('Non-error thrown while resolving generated pages', { cause: err })
+ const generatedPagesError = new Error(`Error resolving generated pages: ${err.message}`, { cause: { message: err.message, stack: err.stack } })
+ result.errors.push({ error: generatedPagesError })
+ }
+
+ if (result.errors.length > 0) return result
+
+ const generatedPages = await pMap(generatedPageInfos, initPageData, { concurrency: MAX_CONCURRENCY })
+ const pages = [...concretePages, ...generatedPages]
+
+ if (result.errors.length > 0) return result
+
+ // Run global.data.js after concrete and generated pages are initialized — receives fully resolved PageData[]
// so it can filter/sort by page.vars.layout, page.vars.publishDate, etc.
const globalDataVars = await resolveGlobalData({
globalDataPath: siteData.globalData?.filepath,
@@ -285,8 +529,6 @@ export async function buildPagesDirect (_src, dest, siteData, opts) {
}
}
- if (result.errors.length > 0) return result
-
/** @type {[number, number]} Divided concurrency valus */
const dividedConcurrency = MAX_CONCURRENCY % 2
? [((MAX_CONCURRENCY - 1) / 2) + 1, (MAX_CONCURRENCY - 1) / 2] // odd
diff --git a/lib/build-pages/page-builders/js/index.js b/lib/build-pages/page-builders/js/index.js
index 8696aec3..7235137a 100644
--- a/lib/build-pages/page-builders/js/index.js
+++ b/lib/build-pages/page-builders/js/index.js
@@ -13,6 +13,16 @@ import assert from 'node:assert'
export async function jsBuilder ({ pageInfo }) {
assert(pageInfo.type === 'js', 'js page builder requires "js" page type')
+ if (pageInfo.generated) {
+ const { vars, children } = pageInfo.generated
+ return {
+ vars: /** @type {Partial} */ (vars ?? {}),
+ pageLayout: typeof children === 'function'
+ ? children
+ : () => children ?? '',
+ }
+ }
+
const { default: pageLayout, vars } = await import(pageInfo.pageFile.filepath)
assert(pageLayout, 'js pages must export a page layout default export')
diff --git a/lib/helpers/domstack-error.js b/lib/helpers/domstack-error.js
index 65765252..550803c3 100644
--- a/lib/helpers/domstack-error.js
+++ b/lib/helpers/domstack-error.js
@@ -1,4 +1,10 @@
-/** @typedef { 'DOM_STACK_ERROR_DUPLICATE_PAGE' | 'DOM_STACK_ERROR_DUPLICATE_SERVICE_WORKER' } DomStackErrorCode */
+/** @typedef { 'DOM_STACK_ERROR_DUPLICATE_PAGE' | 'DOM_STACK_ERROR_DUPLICATE_SERVICE_WORKER' | 'DOM_STACK_ERROR_OUTPUT_CONFLICT' } DomStackErrorCode */
+
+/**
+ * @typedef DomStackOutputConflictErrorClaim
+ * @property {'page'} type - The kind of output producer.
+ * @property {string} path - Human-readable source or output path for the producer.
+ */
/**
* Domstack Duplicate Page Error
@@ -58,6 +64,32 @@ export class DomStackDuplicateServiceWorkerError extends Error {
}
}
+/**
+ * DomStack Output Conflict Error
+ * @extends {Error}
+ */
+export class DomStackOutputConflictError extends Error {
+ /** @type {{ outputPath: string, a: DomStackOutputConflictErrorClaim, b: DomStackOutputConflictErrorClaim }} */
+ conflict
+
+ /**
+ * @param {string} message - The error message
+ * @param {{ outputPath: string, a: DomStackOutputConflictErrorClaim, b: DomStackOutputConflictErrorClaim }} conflict - Conflict metadata
+ * @param {ErrorOptions} [opts] - The opts object from the Error class
+ */
+ constructor (message, conflict, opts) {
+ super(message, opts)
+ this.conflict = conflict
+ }
+
+ /**
+ * @returns {DomStackErrorCode}
+ */
+ get code () {
+ return 'DOM_STACK_ERROR_OUTPUT_CONFLICT'
+ }
+}
+
/** @typedef { 'DOM_STACK_WARNING_DUPLICATE_LAYOUT' } DomStackWarningCode */
/**
diff --git a/lib/identify-pages.js b/lib/identify-pages.js
index 4677d125..e5a7a3f1 100644
--- a/lib/identify-pages.js
+++ b/lib/identify-pages.js
@@ -64,6 +64,10 @@ export const templateSuffixs = nodeHasTS
? ['.template.ts', '.template.mts', '.template.cts', '.template.js', '.template.mjs', '.template.cjs']
: ['.template.js', '.template.mjs', '.template.cjs']
+export const pagesSuffixs = nodeHasTS
+ ? ['.pages.ts', '.pages.mts', '.pages.cts', '.pages.js', '.pages.mjs', '.pages.cjs']
+ : ['.pages.js', '.pages.mjs', '.pages.cjs']
+
export const globalStyleNames = ['global.css', 'global.style.css']
export const pageStyleName = 'style.css'
@@ -176,6 +180,7 @@ const shaper = ({
* @property {string} outputName - The name of the output file.
* @property {string} outputRelname - The relative name/path for the output file.
* @property {boolean} draft - If the page is marked as a draft or not. Draft pages are only included when buildDrafts is passed.
+ * @property {{ pagesFile: PagesFileInfo, vars?: Record, children?: any } | undefined} [generated] - Generated page metadata for pages produced by *.pages.* files.
*/
/**
@@ -189,6 +194,13 @@ const shaper = ({
* @typedef {PageFileAsset} ServiceWorkerInfo
*/
+/**
+ * @typedef PagesFileInfo
+ * @property {WalkerFile} pagesFile - The generated-pages file info.
+ * @property {string} path - The path of the parent dir of the pages file.
+ * @property {string} name - The derived name of the pages file.
+ */
+
/**
* Identifies the pages, layouts, templates, and other relevant data from a given source directory.
*
@@ -234,6 +246,9 @@ export async function identifyPages (src, opts = {}) {
/** @type {TemplateInfo[]} The array of discovered template files */
const templates = []
+ /** @type {PagesFileInfo[]} The array of discovered generated-pages files */
+ const pagesFiles = []
+
/** @type {PageFileAsset | undefined } */
let globalStyle
@@ -469,6 +484,18 @@ export async function identifyPages (src, opts = {}) {
})
}
+ if (pagesSuffixs.some(suffix => fileName.endsWith(suffix))) {
+ const suffix = pagesSuffixs.find(suffix => fileName.endsWith(suffix))
+ if (!suffix) throw new Error('pages suffix not found')
+ const pagesFileName = fileName.slice(0, -suffix.length)
+
+ pagesFiles.push({
+ pagesFile: fileInfo,
+ path: dir,
+ name: pagesFileName,
+ })
+ }
+
if (globalStyleNames.some(name => basename(fileName) === name)) {
if (globalStyle) {
warnings.push({
@@ -597,6 +624,7 @@ export async function identifyPages (src, opts = {}) {
defaultClient: null,
layouts,
templates,
+ pagesFiles,
pages,
warnings,
errors,
diff --git a/plans/generated-pages.md b/plans/generated-pages.md
new file mode 100644
index 00000000..61e1c5ea
--- /dev/null
+++ b/plans/generated-pages.md
@@ -0,0 +1,397 @@
+# Generated Pages Files
+
+## Status: Proposed refinement
+
+Plan for adding first-class generated page support in response to the redirect-page discussion in PR #253.
+
+---
+
+## 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:
+
+- Template outputs bypass page vars, layouts, default/global assets, and page render helpers.
+- Template outputs are not represented in `pages`, so `global.data.*`, feeds, indexes, and other introspective code cannot see them.
+- Redirect pages are conceptually pages: they should use a redirect layout, inherit vars, and appear at page URLs.
+- Some generated-page use cases need central control: redirect lists, yearly/monthly blog indexes, tag indexes, pagination, archive pages, etc.
+
+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. Their returned definitions expand into normal `PageInfo` entries and are appended to the page set before `global.data.*`, 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:
+
+```txt
+*.pages.ts / *.pages.mts / *.pages.cts
+*.pages.js / *.pages.mjs / *.pages.cjs
+```
+
+Use `nodeHasTS` just like `templateSuffixs` in `lib/identify-pages.js`.
+
+Examples:
+
+```txt
+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.
+
+```ts
+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/',
+ },
+ children: '',
+ },
+ ]
+}) 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.
+
+```ts
+type GeneratedPageDefinition, Children = any> = {
+ outputName?: string // default: '/index.html'
+ vars?: Vars
+ children?: Children | ((params: PageFunctionParams) => Children | Promise)
+ draft?: boolean
+}
+```
+
+Rules:
+
+- `outputName` is a relative output path, resolved from the `*.pages.*` file's directory, with no leading `/` and no `..` segments.
+- `outputName` defaults to `/index.html`.
+- `vars.layout` participates in normal layout resolution. If omitted, the usual default/global layout value applies.
+- `children` can be static content or an inline page-like render function.
+- Generated pages must not reference another page file as their render template.
+- Generated pages intentionally do not get page-local assets (`style.css`, `client.js`, workers). They only participate in global and layout assets.
+
+## Pages file parameters
+
+Pass enough context for reflection while avoiding circular or ordering-dependent generation:
+
+```ts
+type PagesFunctionParams = {
+ pages: PageData[]
+ vars: Record
+ pagesFile: PagesFileInfo
+ siteData: SiteData
+}
+```
+
+`pages` contains only concrete/source-backed pages discovered directly from the source tree, initialized with default/global/page/builder vars, but before `global.data.*` runs. It does not include generated pages from any `*.pages.*` file, including pages produced by earlier files in the same build.
+
+This gives every pages file the same stable introspection set.
+
+## 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:
+
+```txt
+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:
+
+```txt
+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[]
+
+ run pagesFiles with concretePageData + global vars + siteData
+ validate generated page definitions
+ convert definitions into generated PageInfo objects
+ detect output conflicts against concrete pages and earlier generated pages
+
+ expandedSiteData = {
+ ...siteData,
+ concretePages: concretePageInfos,
+ pages: [...concretePageInfos, ...generatedPageInfos],
+ }
+
+ generatedPageData = initialize generated PageData[]
+ allPages = [...concretePageData, ...generatedPageData]
+
+ resolve global.data.* with allPages
+ stamp globalDataVars onto allPages
+ render pages/templates using expandedSiteData + allPages
+```
+
+The important framing is that generated outputs become ordinary pages as soon as they have been expanded into `GeneratedPageInfo` objects. From that point forward, rendering, global data, templates, reports, and watch maps should operate on the expanded page list.
+
+## Data model changes
+
+### `identify-pages.js`
+
+Add:
+
+```js
+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:
+
+```ts
+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:
+
+```ts
+type GeneratedPageInfo = PageInfo & {
+ type: 'js'
+ generated: {
+ pagesFile: PagesFileInfo
+ vars: Record
+ 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:
+
+```js
+if (pageInfo.generated) {
+ return {
+ vars: pageInfo.generated.vars,
+ pageLayout: typeof pageInfo.generated.children === 'function'
+ ? pageInfo.generated.children
+ : () => pageInfo.generated.children ?? '',
+ }
+}
+```
+
+`PageData.init()` can then continue to resolve layout and assets through the
+existing JS page builder contract. Generated origin is metadata, not a separate
+page type.
+
+## 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:
+
+- Add/change/unlink of a `*.pages.*` file → full page rebuild and rebuild maps.
+- Dependency of a `*.pages.*` file → full page rebuild.
+- Layout changes may need a full page rebuild until generated pages are included in layout watch maps.
+
+### Better follow-up
+
+Once the build has an `expandedSiteData` concept, rebuild watch maps from expanded pages:
+
+- `#layoutPageMap` should include generated pages by resolving their final `vars.layout`.
+- A layout change can then target both concrete and generated pages using that layout.
+- `#pageFileMap` can include generated page pseudo-file paths only if targeted rebuilds need them; otherwise pages-file changes remain structural.
+- `#pagesFileDepMap` tracks dependencies imported by pages files and can conservatively trigger full page rebuilds.
+
+This avoids the current broad special case of “if any pages files exist, layout changed means rebuild all pages.”
+
+## Public types
+
+Export from `index.js`:
+
+- `PagesFunction`
+- `AsyncPagesFunction`
+- `PagesFunctionParams`
+- `GeneratedPageDefinition`
+- `PagesFileInfo`
+
+Add JSDoc typedefs first, then declaration generation will expose them through the existing `tsc -p declaration.tsconfig.json` flow.
+
+## Documentation examples
+
+### Redirects
+
+```js
+// src/redirects.pages.js
+const redirects = [
+ { from: '2020/old-slug', to: '/2020/new-slug/' },
+]
+
+export default function () {
+ return redirects.map(({ from, to }) => ({
+ outputName: `${from}/index.html`,
+ vars: {
+ layout: 'redirect',
+ title: 'Redirecting...',
+ redirectTo: to,
+ },
+ }))
+}
+```
+
+```js
+// src/redirect.layout.js
+import { html, render } from 'fragtml'
+
+export default function redirectLayout ({ vars }) {
+ return render(html`
+
+
+
+
+
+ ${vars.title}
+
+
+ Redirecting to ${vars.redirectTo}
+
+`)
+}
+```
+
+Docs should still mention validating redirect targets, but that security note belongs in the redirect-layout example rather than in the generated-pages core API.
+
+### Blog indexes
+
+```js
+// src/blog-indexes.pages.js
+export default function ({ pages }) {
+ const years = new Map()
+
+ for (const page of pages) {
+ const date = page.vars.publishDate
+ if (!date || !page.pageInfo.path.startsWith('blog/')) continue
+ const year = new Date(date).getFullYear().toString()
+ years.set(year, [...(years.get(year) ?? []), page])
+ }
+
+ return [...years].map(([year, posts]) => ({
+ outputName: `blog/${year}/index.html`,
+ vars: { layout: 'blog-index', title: `${year} posts`, posts },
+ }))
+}
+```
+
+## Tests
+
+Add a focused generated-pages fixture, likely `test-cases/generated-pages/`:
+
+1. Discovers `*.pages.js` and exposes it on `siteData.pagesFiles`.
+2. Generates redirect pages that render through a `redirect.layout.js`.
+3. Generated pages appear in `global.data.js` and in template `pages` introspection.
+4. Generated blog/year indexes can inspect 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:
+
+```sh
+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. Expansion: create `expandedSiteData` where `pages` contains concrete + generated pages.
+4. Pipeline: run `global.data.*`, templates, and page rendering against expanded pages.
+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: rebuild maps from expanded page data so generated pages participate in layout-targeted rebuilds.
diff --git a/test-cases/generated-pages/index.test.js b/test-cases/generated-pages/index.test.js
new file mode 100644
index 00000000..5d8925ce
--- /dev/null
+++ b/test-cases/generated-pages/index.test.js
@@ -0,0 +1,291 @@
+import { test } from 'node:test'
+import assert from 'node:assert'
+import { mkdir, mkdtemp, readFile, readdir, rm, writeFile } from 'node:fs/promises'
+import { dirname, join } from 'node:path'
+import * as cheerio from 'cheerio'
+import { DomStack, testBuild } from '../../index.js'
+
+const __dirname = import.meta.dirname
+const fixturePrefix = '.tmp-'
+
+async function cleanupTempFixtures () {
+ const entries = await readdir(__dirname, { withFileTypes: true })
+ await Promise.all(entries
+ .filter(entry => entry.isDirectory() && entry.name.startsWith(fixturePrefix))
+ .map(entry => rm(join(__dirname, entry.name), { recursive: true, force: true })))
+}
+
+test.before(cleanupTempFixtures)
+test.after(cleanupTempFixtures)
+
+/**
+ * @param {string} src
+ * @param {string} relname
+ * @param {string} content
+ */
+async function writeFixtureFile (src, relname, content) {
+ const filepath = join(src, relname)
+ await mkdir(dirname(filepath), { recursive: true })
+ await writeFile(filepath, content)
+}
+
+/**
+ * @param {Record} files
+ * @param {(paths: { src: string, dest: string }) => Promise} run
+ */
+async function withTempFixture (files, run) {
+ const root = await mkdtemp(join(__dirname, fixturePrefix))
+ const src = join(root, 'src')
+ const dest = join(root, 'dist')
+ await mkdir(src, { recursive: true })
+
+ for (const [relname, content] of Object.entries(files)) {
+ await writeFixtureFile(src, relname, content)
+ }
+
+ try {
+ await run({ src, dest })
+ } finally {
+ await rm(root, { recursive: true, force: true })
+ }
+}
+
+const minimalRootLayout = `import { html, raw, render } from 'fragtml'
+
+export default function rootLayout ({ vars, children }) {
+ return render(html\`\${vars.title} \${typeof children === 'string' ? raw(children) : children} \`)
+}
+`
+
+const minimalGlobalVars = `export default { layout: 'root', title: 'Test' }
+`
+
+/**
+ * @param {unknown} error
+ * @returns {string}
+ */
+function aggregateErrorMessage (error) {
+ if (!(error instanceof Error)) return String(error)
+ const aggregate = /** @type {Error & { errors?: Error[] }} */ (error)
+ return String(aggregate.errors?.[0]?.message ?? aggregate.message)
+}
+
+test.describe('generated pages', () => {
+ test('builds generated pages through layouts and exposes them to global data and templates', async (t) => {
+ const src = join(__dirname, './src')
+ const build = await testBuild(src)
+ const { results, readOutput } = build
+
+ t.after(async () => {
+ await build.cleanup()
+ })
+
+ assert.equal(results.siteData.pagesFiles.length, 4, 'four pages files are discovered')
+
+ const redirectCases = [
+ { from: 'old-url', to: '/new-url/', destination: 'new-url/index.html', heading: 'New URL' },
+ { from: 'docs/old-guide', to: '/guides/current/', destination: 'guides/current/index.html', heading: 'Current Guide' },
+ { from: 'company', to: '/about/', destination: 'about/index.html', heading: 'About' },
+ ]
+
+ for (const { from, to, destination, heading } of redirectCases) {
+ const redirectHtml = await readOutput(`${from}/index.html`)
+ assert.match(redirectHtml, new RegExp(` `), `${from} renders through the redirect layout`)
+ assert.match(redirectHtml, new RegExp(`${to} `), `${from} links to its canonical destination`)
+ assert.match(await readOutput(destination), new RegExp(`]*>${heading} `), `${to} is backed by a concrete page`)
+ }
+
+ const blogIndexHtml = await readOutput('blog/2024/index.html')
+ const blogIndexDoc = cheerio.load(blogIndexHtml)
+ assert.equal(blogIndexDoc('#post-count').text(), '1', 'generated blog index can inspect concrete blog pages')
+
+ const introspectionHtml = await readOutput('generated-introspection/index.html')
+ const introspectionDoc = cheerio.load(introspectionHtml)
+ assert.equal(introspectionDoc('#saw-generated').text(), 'false', 'pages files receive concrete pages only')
+ assert.equal(introspectionDoc('meta[name="generated-page-count"]').attr('content'), '6', 'global.data sees generated pages after pages files run')
+
+ const stylesheetHrefs = Array.from(introspectionDoc('link[rel="stylesheet"]')).map(link => introspectionDoc(link).attr('href') ?? '')
+ assert.ok(stylesheetHrefs.some(href => href.startsWith('/global-') && href.endsWith('.css')), 'generated page includes global stylesheet')
+ assert.ok(stylesheetHrefs.some(href => href.startsWith('/root.layout-') && href.endsWith('.css')), 'generated page includes layout stylesheet')
+ assert.ok(!stylesheetHrefs.some(href => href.startsWith('./style-')), 'generated page does not include page-local stylesheet')
+
+ const scriptSrcs = Array.from(introspectionDoc('script[type="module"]')).map(script => introspectionDoc(script).attr('src') ?? '')
+ assert.ok(scriptSrcs.some(src => src.startsWith('/global.client-') && src.endsWith('.js')), 'generated page includes global client')
+ assert.ok(scriptSrcs.some(src => src.startsWith('/root.layout.client-') && src.endsWith('.js')), 'generated page includes layout client')
+ assert.ok(!scriptSrcs.some(src => src.startsWith('./client-')), 'generated page does not include page-local client')
+
+ const asyncHtml = await readOutput('async-generated/index.html')
+ assert.match(asyncHtml, /async generated page/, 'async iterable pages files are supported')
+
+ const summary = JSON.parse(await readOutput('summary.json'))
+ assert.equal(summary.generatedPageCount, 6, 'template vars include global.data generated page count')
+ assert.equal(summary.generatedPagesInTemplate, 6, 'template pages include generated pages')
+ })
+
+ test('includes generated pages in the domstack manifest as page entries', async () => {
+ await withTempFixture({
+ 'root.layout.js': minimalRootLayout,
+ 'global.vars.js': minimalGlobalVars,
+ 'archive.pages.js': `export default {
+ outputName: 'archive/index.html',
+ vars: {
+ layout: 'root',
+ title: 'Archive',
+ archiveYear: 2024,
+ manifestRole: 'generated-index',
+ },
+ children: 'Generated archive
',
+}
+`,
+ }, async ({ src, dest }) => {
+ const domstack = new DomStack(src, dest, {
+ domstackManifest: {
+ manifestVars: ['archiveYear'],
+ },
+ })
+ const results = await domstack.build()
+ const entry = results.domstackManifest?.entries.find(entry => entry.outputRelname === 'archive/index.html')
+
+ assert.ok(entry, 'generated page is present in the domstack manifest')
+ assert.equal(entry.kind, 'page')
+ assert.equal(entry.url, '/archive/')
+ assert.equal(entry.sourceRelname, 'archive.pages.js#0')
+ assert.equal(entry.pagePath, 'archive')
+ assert.equal(entry.pageUrl, '/archive/')
+ assert.deepEqual(entry.page, {
+ path: 'archive',
+ url: '/archive/',
+ })
+ assert.equal(entry.role, 'generated-index', 'generated page vars can override the manifest role')
+ assert.deepEqual(entry.manifestVars, {
+ archiveYear: 2024,
+ }, 'selected generated page vars are exposed in the manifest')
+ assert.match(entry.revision ?? '', /^[a-f0-9]{64}$/, 'generated page content is revisioned')
+ })
+ })
+
+ test('throws a conflict error for generated pages that collide with concrete pages', async () => {
+ await withTempFixture({
+ 'root.layout.js': minimalRootLayout,
+ 'global.vars.js': minimalGlobalVars,
+ 'README.md': '# Concrete root page\n',
+ 'conflict.pages.js': `export default function () {
+ return { outputName: 'index.html', vars: { title: 'Generated root' }, children: 'generated' }
+}
+`,
+ }, async ({ src, dest }) => {
+ const domstack = new DomStack(src, dest)
+ await assert.rejects(
+ () => domstack.build(),
+ error => {
+ assert.match(aggregateErrorMessage(error), /Output path conflict/)
+ return true
+ }
+ )
+ })
+ })
+
+ test('rejects invalid definitions returned in arrays', async () => {
+ await withTempFixture({
+ 'root.layout.js': minimalRootLayout,
+ 'global.vars.js': minimalGlobalVars,
+ 'invalid.pages.js': 'export default [{ outputName: "valid/index.html" }, 42]\n',
+ }, async ({ src, dest }) => {
+ const domstack = new DomStack(src, dest)
+ await assert.rejects(
+ () => domstack.build(),
+ error => {
+ assert.match(aggregateErrorMessage(error), /Generated page definition must be an object/)
+ return true
+ }
+ )
+ })
+ })
+
+ test('throws a clear error for invalid generated page paths', async () => {
+ await withTempFixture({
+ 'root.layout.js': minimalRootLayout,
+ 'global.vars.js': minimalGlobalVars,
+ 'invalid.pages.js': `export default function () {
+ return { outputName: '../outside/index.html', vars: { title: 'Invalid' }, children: 'invalid' }
+}
+`,
+ }, async ({ src, dest }) => {
+ const domstack = new DomStack(src, dest)
+ await assert.rejects(
+ () => domstack.build(),
+ error => {
+ assert.match(aggregateErrorMessage(error), /must not contain "\.\." segments/)
+ return true
+ }
+ )
+ })
+ })
+
+ test('rebuilds generated pages when a concrete page changes in watch mode', { timeout: 15_000 }, async () => {
+ await withTempFixture({
+ 'root.layout.js': minimalRootLayout,
+ 'global.vars.js': minimalGlobalVars,
+ 'page.js': 'export default ({ vars }) => vars.title\n',
+ 'page.vars.js': "export default { title: 'First title' }\n",
+ 'watch-indexes.pages.js': `export default function ({ pages }) {
+ const title = pages[0].vars.title
+ return { outputName: 'watch-generated/index.html', children: () => title }
+}
+`,
+ }, async ({ src, dest }) => {
+ const domstack = new DomStack(src, dest)
+ try {
+ await domstack.watch({ serve: false })
+ const outputPath = join(dest, 'watch-generated/index.html')
+ assert.match(await readFile(outputPath, 'utf8'), /First title/)
+
+ await writeFile(join(src, 'page.vars.js'), "export default { title: 'Updated title' }\n")
+ await new Promise(resolve => setTimeout(resolve, 800))
+ await domstack.settled()
+
+ assert.match(await readFile(outputPath, 'utf8'), /Updated title/)
+ } finally {
+ if (domstack.watching) await domstack.stopWatching()
+ }
+ })
+ })
+
+ test('refreshes pages-file dependency trees in watch mode', { timeout: 15_000 }, async () => {
+ await withTempFixture({
+ 'root.layout.js': minimalRootLayout,
+ 'global.vars.js': minimalGlobalVars,
+ 'generated-value.js': "export const value = 'First value'\n",
+ 'watched.pages.js': `export default {
+ outputName: 'watched/index.html',
+ children: 'Initial value',
+}
+`,
+ }, async ({ src, dest }) => {
+ const domstack = new DomStack(src, dest)
+ try {
+ await domstack.watch({ serve: false })
+ const outputPath = join(dest, 'watched/index.html')
+ assert.match(await readFile(outputPath, 'utf8'), /Initial value/)
+
+ await writeFile(join(src, 'watched.pages.js'), `import { value } from './generated-value.js'
+
+export default {
+ outputName: 'watched/index.html',
+ children: value + ' after pages edit',
+}
+`)
+ await new Promise(resolve => setTimeout(resolve, 800))
+ await domstack.settled()
+ assert.match(await readFile(outputPath, 'utf8'), /First value after pages edit/)
+
+ await writeFile(join(src, 'generated-value.js'), "export const value = 'Updated dependency'\n")
+ await new Promise(resolve => setTimeout(resolve, 800))
+ await domstack.settled()
+ assert.match(await readFile(outputPath, 'utf8'), /Updated dependency after pages edit/)
+ } finally {
+ if (domstack.watching) await domstack.stopWatching()
+ }
+ })
+ })
+})
diff --git a/test-cases/generated-pages/src/README.md b/test-cases/generated-pages/src/README.md
new file mode 100644
index 00000000..dd2f75cc
--- /dev/null
+++ b/test-cases/generated-pages/src/README.md
@@ -0,0 +1,7 @@
+---
+title: Home
+---
+
+# Home
+
+Concrete home page.
diff --git a/test-cases/generated-pages/src/about/README.md b/test-cases/generated-pages/src/about/README.md
new file mode 100644
index 00000000..b97b51cd
--- /dev/null
+++ b/test-cases/generated-pages/src/about/README.md
@@ -0,0 +1,7 @@
+---
+title: About
+---
+
+# About
+
+This concrete page replaces `/company/`.
diff --git a/test-cases/generated-pages/src/async.pages.js b/test-cases/generated-pages/src/async.pages.js
new file mode 100644
index 00000000..bcccc182
--- /dev/null
+++ b/test-cases/generated-pages/src/async.pages.js
@@ -0,0 +1,10 @@
+export default async function * asyncPages () {
+ yield {
+ outputName: 'async-generated/index.html',
+ vars: {
+ layout: 'root',
+ title: 'Async generated',
+ },
+ children: 'async generated page
',
+ }
+}
diff --git a/test-cases/generated-pages/src/blog/post-one/page.md b/test-cases/generated-pages/src/blog/post-one/page.md
new file mode 100644
index 00000000..2c853f82
--- /dev/null
+++ b/test-cases/generated-pages/src/blog/post-one/page.md
@@ -0,0 +1,8 @@
+---
+title: Post One
+publishDate: 2024-01-02
+---
+
+# Post One
+
+A concrete blog post.
diff --git a/test-cases/generated-pages/src/concrete-only.pages.js b/test-cases/generated-pages/src/concrete-only.pages.js
new file mode 100644
index 00000000..bda90991
--- /dev/null
+++ b/test-cases/generated-pages/src/concrete-only.pages.js
@@ -0,0 +1,26 @@
+/**
+ * @import { PageFunction, PagesFunction } from '#types'
+ * @import { HtmlResult } from 'fragtml/types.js'
+ */
+
+import { html } from 'fragtml'
+
+/** @type {PageFunction<{ sawGenerated: boolean, concreteCount: number }, HtmlResult>} */
+const renderConcreteOnlyPage = ({ vars }) => html`
+ ${String(vars.sawGenerated)}
+ ${vars.concreteCount}
+`
+
+/** @type {PagesFunction} */
+export default function concreteOnlyPages ({ pages }) {
+ return {
+ outputName: 'generated-introspection/index.html',
+ vars: {
+ layout: 'root',
+ title: 'Generated introspection',
+ sawGenerated: pages.some(page => Boolean(page.pageInfo.generated)),
+ concreteCount: pages.length,
+ },
+ children: renderConcreteOnlyPage,
+ }
+}
diff --git a/test-cases/generated-pages/src/global.client.js b/test-cases/generated-pages/src/global.client.js
new file mode 100644
index 00000000..8e4ffaae
--- /dev/null
+++ b/test-cases/generated-pages/src/global.client.js
@@ -0,0 +1 @@
+globalThis.generatedPagesGlobalClient = true
diff --git a/test-cases/generated-pages/src/global.css b/test-cases/generated-pages/src/global.css
new file mode 100644
index 00000000..0be0f750
--- /dev/null
+++ b/test-cases/generated-pages/src/global.css
@@ -0,0 +1 @@
+body { font-family: system-ui, sans-serif; }
diff --git a/test-cases/generated-pages/src/global.data.js b/test-cases/generated-pages/src/global.data.js
new file mode 100644
index 00000000..2fdf54e7
--- /dev/null
+++ b/test-cases/generated-pages/src/global.data.js
@@ -0,0 +1,10 @@
+/**
+ * @import { GlobalDataFunction } from '#types'
+ */
+
+/** @type {GlobalDataFunction<{ generatedPageCount: number }>} */
+export default function globalData ({ pages }) {
+ return {
+ generatedPageCount: pages.filter(page => Boolean(page.pageInfo.generated)).length,
+ }
+}
diff --git a/test-cases/generated-pages/src/global.vars.js b/test-cases/generated-pages/src/global.vars.js
new file mode 100644
index 00000000..a62f359b
--- /dev/null
+++ b/test-cases/generated-pages/src/global.vars.js
@@ -0,0 +1,4 @@
+export default {
+ layout: 'root',
+ siteName: 'Generated Pages Test',
+}
diff --git a/test-cases/generated-pages/src/guides/current/README.md b/test-cases/generated-pages/src/guides/current/README.md
new file mode 100644
index 00000000..99a58d01
--- /dev/null
+++ b/test-cases/generated-pages/src/guides/current/README.md
@@ -0,0 +1,7 @@
+---
+title: Current Guide
+---
+
+# Current Guide
+
+This concrete guide replaces `/docs/old-guide/`.
diff --git a/test-cases/generated-pages/src/indexes.pages.js b/test-cases/generated-pages/src/indexes.pages.js
new file mode 100644
index 00000000..6b4c7711
--- /dev/null
+++ b/test-cases/generated-pages/src/indexes.pages.js
@@ -0,0 +1,27 @@
+/**
+ * @import { PageFunction, PagesFunction } from '#types'
+ * @import { HtmlResult } from 'fragtml/types.js'
+ */
+
+import { html } from 'fragtml'
+
+/** @type {PageFunction<{ title: string, postCount: number }, HtmlResult>} */
+const renderIndexPage = ({ vars }) => html`
+ ${vars.title}
+ ${vars.postCount}
+`
+
+/** @type {PagesFunction} */
+export default function indexesPages ({ pages }) {
+ const posts = pages.filter(page => page.vars.publishDate && page.pageInfo.path.startsWith('blog/'))
+
+ return {
+ outputName: 'blog/2024/index.html',
+ vars: {
+ layout: 'root',
+ title: '2024 posts',
+ postCount: posts.length,
+ },
+ children: renderIndexPage,
+ }
+}
diff --git a/test-cases/generated-pages/src/new-url/README.md b/test-cases/generated-pages/src/new-url/README.md
new file mode 100644
index 00000000..72853e1b
--- /dev/null
+++ b/test-cases/generated-pages/src/new-url/README.md
@@ -0,0 +1,7 @@
+---
+title: New URL
+---
+
+# New URL
+
+This concrete page replaces `/old-url/`.
diff --git a/test-cases/generated-pages/src/redirect.layout.js b/test-cases/generated-pages/src/redirect.layout.js
new file mode 100644
index 00000000..64f506dd
--- /dev/null
+++ b/test-cases/generated-pages/src/redirect.layout.js
@@ -0,0 +1,21 @@
+/**
+ * @import { LayoutFunction } from '#types'
+ */
+
+import { html, render } from 'fragtml'
+
+/** @type {LayoutFunction<{ title: string, redirectTo: string }, unknown, string>} */
+export default function redirectLayout ({ vars }) {
+ return render(html`
+
+
+
+
+
+ ${vars.title}
+
+
+ Redirecting to ${vars.redirectTo}
+
+`)
+}
diff --git a/test-cases/generated-pages/src/redirects.pages.js b/test-cases/generated-pages/src/redirects.pages.js
new file mode 100644
index 00000000..817b124e
--- /dev/null
+++ b/test-cases/generated-pages/src/redirects.pages.js
@@ -0,0 +1,17 @@
+const redirects = [
+ { from: 'old-url', to: '/new-url/' },
+ { from: 'docs/old-guide', to: '/guides/current/' },
+ { from: 'company', to: '/about/' },
+]
+
+export default function redirectsPages () {
+ return redirects.map(({ from, to }) => ({
+ outputName: `${from}/index.html`,
+ vars: {
+ layout: 'redirect',
+ title: 'Redirecting...',
+ redirectTo: to,
+ },
+ children: '',
+ }))
+}
diff --git a/test-cases/generated-pages/src/root.layout.client.js b/test-cases/generated-pages/src/root.layout.client.js
new file mode 100644
index 00000000..dc4b2756
--- /dev/null
+++ b/test-cases/generated-pages/src/root.layout.client.js
@@ -0,0 +1 @@
+globalThis.generatedPagesRootLayoutClient = true
diff --git a/test-cases/generated-pages/src/root.layout.css b/test-cases/generated-pages/src/root.layout.css
new file mode 100644
index 00000000..edaa3afc
--- /dev/null
+++ b/test-cases/generated-pages/src/root.layout.css
@@ -0,0 +1 @@
+main { display: block; }
diff --git a/test-cases/generated-pages/src/root.layout.js b/test-cases/generated-pages/src/root.layout.js
new file mode 100644
index 00000000..e925af4f
--- /dev/null
+++ b/test-cases/generated-pages/src/root.layout.js
@@ -0,0 +1,23 @@
+/**
+ * @import { LayoutFunction } from '#types'
+ * @import { HtmlResult } from 'fragtml/types.js'
+ */
+
+import { html, raw, render } from 'fragtml'
+
+/** @type {LayoutFunction<{ title: string, generatedPageCount?: number }, string | HtmlResult, string>} */
+export default function rootLayout ({ vars, styles = [], scripts = [], children }) {
+ return render(html`
+
+
+
+ ${vars.title}
+ ${styles.map(href => html` `)}
+ ${scripts.map(src => html``)}
+
+
+
+ ${typeof children === 'string' ? raw(children) : children}
+
+`)
+}
diff --git a/test-cases/generated-pages/src/summary.template.js b/test-cases/generated-pages/src/summary.template.js
new file mode 100644
index 00000000..157ea89e
--- /dev/null
+++ b/test-cases/generated-pages/src/summary.template.js
@@ -0,0 +1,14 @@
+/**
+ * @import { TemplateFunction } from '#types'
+ */
+
+/** @type {TemplateFunction<{ generatedPageCount: number }>} */
+export default async function summaryTemplate ({ pages, vars }) {
+ return {
+ outputName: 'summary.json',
+ content: JSON.stringify({
+ generatedPageCount: vars.generatedPageCount,
+ generatedPagesInTemplate: pages.filter(page => Boolean(page.pageInfo.generated)).length,
+ }, null, 2),
+ }
+}
diff --git a/types.ts b/types.ts
index b2196131..56b0700a 100644
--- a/types.ts
+++ b/types.ts
@@ -7,8 +7,12 @@ export type { BuildOptions } from 'esbuild'
export type { DomStackOpts, Results, SiteData } from './lib/builder.js'
export type {
AsyncGlobalDataFunction,
+ AsyncPagesFunction,
+ GeneratedPageDefinition,
GlobalDataFunction,
GlobalDataFunctionParams,
+ PagesFunction,
+ PagesFunctionParams,
} from './lib/build-pages/index.js'
export type {
AsyncLayoutFunction,
@@ -30,7 +34,7 @@ export type {
TemplateFunctionParams,
TemplateOutputOverride,
} from './lib/build-pages/page-builders/template-builder.js'
-export type { PageInfo, ServiceWorkerInfo, TemplateInfo } from './lib/identify-pages.js'
+export type { PageInfo, PagesFileInfo, ServiceWorkerInfo, TemplateInfo } from './lib/identify-pages.js'
export type {
DomstackManifest,
DomstackManifestEntry,