diff --git a/README.md b/README.md
index 5e111a0..5defe01 100644
--- a/README.md
+++ b/README.md
@@ -1043,6 +1043,106 @@ 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
+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 }) => `
${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
+
+export default function redirectLayout ({ vars }) {
+ return `
+
+
+
+
+
+ ${escapeXml(vars.title)}
+
+
+ Redirecting to ${escapeXml(vars.redirectTo)}
+
+`
+}
+
+function escapeXml (str) {
+ return str
+ .replace(/&/g, '&')
+ .replace(//g, '>')
+ .replace(/"/g, '"')
+ .replace(/'/g, ''')
+}
+```
+
+The `outputName` field controls the output path. Using `${from}/index.html` creates a directory-style URL at the old path. The `escapeXml` helper prevents HTML injection in attribute values. Note that `escapeXml` alone 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.
+
## Global Assets
There are a few important (and optional) global assets that live anywhere in the `src` directory. If duplicate named files that match the global asset file name pattern are found, a build error will occur until the duplicate file error is resolved.
diff --git a/index.js b/index.js
index bf7d47b..220bb70 100644
--- a/index.js
+++ b/index.js
@@ -8,9 +8,9 @@
* @import { TemplateAsyncIterator } from './lib/build-pages/page-builders/template-builder.js'
* @import { TemplateOutputOverride } from './lib/build-pages/page-builders/template-builder.js'
* @import { TemplateFunctionParams } from './lib/build-pages/page-builders/template-builder.js'
- * @import { GlobalDataFunction, AsyncGlobalDataFunction, WorkerBuildStepResult, GlobalDataFunctionParams } from './lib/build-pages/index.js'
+ * @import { GlobalDataFunction, AsyncGlobalDataFunction, WorkerBuildStepResult, GlobalDataFunctionParams, PagesFunction, AsyncPagesFunction, PagesFunctionParams, GeneratedPageDefinition } from './lib/build-pages/index.js'
* @import { BuildOptions, BuildContext } from 'esbuild'
- * @import { PageInfo, TemplateInfo } from './lib/identify-pages.js'
+ * @import { PageInfo, TemplateInfo, PagesFileInfo } from './lib/identify-pages.js'
*/
import { once } from 'events'
import assert from 'node:assert'
@@ -36,6 +36,7 @@ import {
layoutSuffixs,
layoutStyleSuffix,
templateSuffixs,
+ pagesSuffixs,
globalVarsNames,
globalDataNames,
esbuildSettingsNames,
@@ -115,6 +116,10 @@ export { PageData } from './lib/build-pages/page-data.js'
* @typedef {TemplateInfo} TemplateInfo
*/
+/**
+ * @typedef {PagesFileInfo} PagesFileInfo
+ */
+
/**
* @template {Record} Vars - The type of variables passed to the layout function
* @template [PageReturn=any] PageReturn - The return type of the page function
@@ -126,6 +131,10 @@ export { PageData } from './lib/build-pages/page-data.js'
* @typedef {GlobalDataFunctionParams} GlobalDataFunctionParams
*/
+/**
+ * @typedef {PagesFunctionParams} PagesFunctionParams
+ */
+
/**
* @template {Record} Vars - The type of variables passed to the page function
* @template [PageReturn=any] PageReturn - The return type of the page function
@@ -137,6 +146,24 @@ export { PageData } from './lib/build-pages/page-data.js'
* @typedef {TemplateFunctionParams} TemplateFunctionParams
*/
+/**
+ * @template {Record} [Vars=Record] - The type of variables for the generated pages function
+ * @template [Children=any]
+ * @typedef {PagesFunction} PagesFunction
+ */
+
+/**
+ * @template {Record} [Vars=Record] - The type of variables for the async generated pages function
+ * @template [Children=any]
+ * @typedef {AsyncPagesFunction} AsyncPagesFunction
+ */
+
+/**
+ * @template {Record} [Vars=Record] - The type of variables for a generated page
+ * @template [Children=any]
+ * @typedef {GeneratedPageDefinition} GeneratedPageDefinition
+ */
+
/**
* @typedef TestBuildResult
* @property {string} dest - Temporary destination directory used for the build.
@@ -181,6 +208,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()
@@ -533,6 +562,7 @@ export class DomStack {
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)) {
@@ -616,6 +646,20 @@ export class DomStack {
}
}
+ // 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))
@@ -638,6 +682,7 @@ export class DomStack {
this.#layoutFileMap = layoutFileMap
this.#pageDepMap = pageDepMap
this.#templateDepMap = templateDepMap
+ this.#pagesFileDepMap = pagesFileDepMap
this.#esbuildEntryPoints = esbuildEntryPoints
}
@@ -687,6 +732,11 @@ export class DomStack {
// 7. Layout file itself → rebuild pages using that layout
if (layoutSuffixs.some(s => changedBasename.endsWith(s))) {
+ if ((siteData.pagesFiles?.length ?? 0) > 0) {
+ console.log(`"${changedBasename}" changed, rebuilding all pages...`)
+ return this.#runPageBuild(siteData)
+ }
+
const layoutName = this.#layoutFileMap.get(changedPath)
if (layoutName) {
const affectedPages = this.#layoutPageMap.get(layoutName)
@@ -703,6 +753,10 @@ export class DomStack {
// 8. Dep of a layout
if (this.#layoutDepMap.has(changedPath)) {
+ if ((siteData.pagesFiles?.length ?? 0) > 0) {
+ console.log(`"${changedBasename}" changed, rebuilding all pages...`)
+ return this.#runPageBuild(siteData)
+ }
const affectedLayoutNames = this.#layoutDepMap.get(changedPath) ?? new Set()
const affectedPages = new Set(/** @type {PageInfo[]} */ ([]))
for (const layoutName of affectedLayoutNames) {
@@ -725,7 +779,15 @@ export class DomStack {
}
}
- // 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)) {
+ console.log(`"${changedBasename}" changed, rebuilding all pages...`)
+ return this.#runPageBuild(siteData)
+ }
+ }
+
+ // 11. Template file itself
if (templateSuffixs.some(s => changedBasename.endsWith(s))) {
const templateInfo = siteData.templates.find(t => t.templateFile.filepath === changedPath)
if (templateInfo) {
@@ -734,7 +796,7 @@ export class DomStack {
}
}
- // 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) {
@@ -744,7 +806,13 @@ export class DomStack {
}
}
- // 12. Dep of a template file
+ // 13. Dep of a pages file → full page rebuild
+ if (this.#pagesFileDepMap.has(changedPath)) {
+ console.log(`"${changedBasename}" changed, rebuilding all pages...`)
+ return this.#runPageBuild(siteData)
+ }
+
+ // 14. Dep of a template file
if (this.#templateDepMap.has(changedPath)) {
const affectedTemplates = this.#templateDepMap.get(changedPath) ?? new Set()
if (affectedTemplates.size > 0) {
@@ -754,7 +822,7 @@ export class DomStack {
}
}
- // 13. No matching rule — skip.
+ // 15. No matching rule — skip.
console.log(`"${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 1549810..4023c6a 100644
--- a/lib/build-pages/index.js
+++ b/lib/build-pages/index.js
@@ -1,12 +1,12 @@
/**
* @import { BuilderOptions } from './page-builders/page-writer.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'
@@ -14,6 +14,8 @@ 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'
const MAX_CONCURRENCY = Math.min(cpus().length, 24)
@@ -52,6 +54,48 @@ const __dirname = import.meta.dirname
* @returns {Promise}
*/
+/**
+ * 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 | import('./page-builders/page-writer.js').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',
@@ -68,6 +112,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.
*/
/**
@@ -87,7 +132,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) {
@@ -100,6 +145,10 @@ function getWorkerErrorContext (errorData) {
return { type: 'template', path: templatePath }
}
+ if (errorData.pagesFile) {
+ return { type: 'pages file', path: errorData.pagesFile.pagesFile.relname }
+ }
+
return null
}
@@ -125,6 +174,168 @@ function restoreWorkerError (error, errorData) {
return restoredError
}
+/**
+ * @param {unknown} value
+ * @returns {value is AsyncIterable}
+ */
+function isAsyncIterable (value) {
+ return typeof value === 'object' && value !== null && Symbol.asyncIterator in value && typeof value[Symbol.asyncIterator] === 'function'
+}
+
+/**
+ * @param {unknown} value
+ * @returns {value is GeneratedPageDefinition}
+ */
+function isGeneratedPageDefinition (value) {
+ return typeof value === 'object' && value !== null
+}
+
+/**
+ * @param {unknown} value
+ * @returns {Promise}
+ */
+async function collectGeneratedPageDefinitions (value) {
+ if (value == null) return []
+
+ if (Array.isArray(value)) {
+ return /** @type {GeneratedPageDefinition[]} */ (value)
+ }
+
+ if (isAsyncIterable(value)) {
+ /** @type {GeneratedPageDefinition[]} */
+ const definitions = []
+ for await (const definition of value) {
+ definitions.push(/** @type {GeneratedPageDefinition} */ (definition))
+ }
+ return definitions
+ }
+
+ if (isGeneratedPageDefinition(value)) {
+ return [value]
+ }
+
+ throw new Error(`Pages file returned unknown return type: ${typeof 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: 'generated',
+ },
+ type: 'generated',
+ 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.
*
@@ -233,8 +444,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,
@@ -254,9 +467,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,
@@ -270,8 +509,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/generated/index.js b/lib/build-pages/page-builders/generated/index.js
new file mode 100644
index 0000000..40bd5a4
--- /dev/null
+++ b/lib/build-pages/page-builders/generated/index.js
@@ -0,0 +1,27 @@
+/**
+ * @import { PageBuilderType } from '../page-writer.js'
+ */
+
+import assert from 'node:assert'
+
+/**
+ * Build a generated page from data returned by a *.pages.* file.
+ * Generated pages use global/layout assets only; they do not have page-local assets.
+ *
+ * @template {Record} T - The type of variables for the page
+ * @template [U=any] U - The return type of the pageLayout function
+ * @type {PageBuilderType}
+ */
+export async function generatedBuilder ({ pageInfo }) {
+ assert(pageInfo.type === 'generated', 'generated builder requires a "generated" page type')
+ assert(pageInfo.generated, 'generated page requires generated metadata')
+
+ const generated = pageInfo.generated
+
+ return {
+ vars: generated.vars ?? {},
+ pageLayout: typeof generated.children === 'function'
+ ? generated.children
+ : () => generated.children ?? '',
+ }
+}
diff --git a/lib/build-pages/page-builders/index.js b/lib/build-pages/page-builders/index.js
index 837be34..fca8e9d 100644
--- a/lib/build-pages/page-builders/index.js
+++ b/lib/build-pages/page-builders/index.js
@@ -1,10 +1,12 @@
import { mdBuilder } from './md/index.js'
import { jsBuilder } from './js/index.js'
import { htmlBuilder } from './html/index.js'
+import { generatedBuilder } from './generated/index.js'
export { templateBuilder } from './template-builder.js'
export const pageBuilders = {
md: mdBuilder,
js: jsBuilder,
html: htmlBuilder,
+ generated: generatedBuilder,
}
diff --git a/lib/helpers/domstack-error.js b/lib/helpers/domstack-error.js
index bb8a6d3..8ec480f 100644
--- a/lib/helpers/domstack-error.js
+++ b/lib/helpers/domstack-error.js
@@ -1,4 +1,10 @@
-/** @typedef { 'DOM_STACK_ERROR_DUPLICATE_PAGE' } DomStackErrorCode */
+/** @typedef { 'DOM_STACK_ERROR_DUPLICATE_PAGE' | '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
@@ -31,6 +37,32 @@ export class DomStackDuplicatePageError 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 cec555a..06662ea 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'
@@ -140,7 +144,7 @@ const shaper = ({
*/
/**
- * @typedef {'js' | 'md' | 'html'} PageTypes
+ * @typedef {'js' | 'md' | 'html' | 'generated'} PageTypes
*/
/**
@@ -162,6 +166,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.
*/
/**
@@ -171,6 +176,13 @@ const shaper = ({
* @property {string} outputName - The derived output name of the template file. Might be overridden.
*/
+/**
+ * @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.
*
@@ -216,6 +228,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
@@ -427,6 +442,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({
@@ -544,6 +571,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 0000000..0ebc3ac
--- /dev/null
+++ b/plans/generated-pages.md
@@ -0,0 +1,390 @@
+# 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: 'generated'
+ generated: {
+ pagesFile: PagesFileInfo
+ vars: Record
+ children: unknown | PageFunction
+ }
+}
+```
+
+Add a generated page builder to `pageBuilders`:
+
+```js
+pageBuilders.generated = async ({ pageInfo }) => ({
+ 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 builder contract. Generated pages are special only at the point where their `PageInfo` is created.
+
+## 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
+export default function redirectLayout ({ vars }) {
+ return `
+
+
+
+
+
+ ${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, generated `PageInfo`, `pageBuilders.generated`.
+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 0000000..a72f6fc
--- /dev/null
+++ b/test-cases/generated-pages/index.test.js
@@ -0,0 +1,144 @@
+import { test } from 'node:test'
+import assert from 'node:assert'
+import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'
+import { dirname, join } from 'node:path'
+import { tmpdir } from 'node:os'
+import * as cheerio from 'cheerio'
+import { DomStack, testBuild } from '../../index.js'
+
+const __dirname = import.meta.dirname
+
+/**
+ * @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(tmpdir(), 'domstack-generated-pages-'))
+ 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 = `export default function rootLayout ({ vars, children }) {
+ return '' + vars.title + ' ' + 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 redirectHtml = await readOutput('old-url/index.html')
+ assert.match(redirectHtml, / /, 'redirect page renders through redirect layout')
+ assert.match(redirectHtml, /\/new-url\/<\/a>/, 'redirect target is rendered')
+
+ 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'), '4', '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, 4, 'template vars include global.data generated page count')
+ assert.equal(summary.generatedPagesInTemplate, 4, 'template pages include generated pages')
+ })
+
+ 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('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
+ }
+ )
+ })
+ })
+})
diff --git a/test-cases/generated-pages/src/README.md b/test-cases/generated-pages/src/README.md
new file mode 100644
index 0000000..dd2f75c
--- /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/async.pages.js b/test-cases/generated-pages/src/async.pages.js
new file mode 100644
index 0000000..bcccc18
--- /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 0000000..2c853f8
--- /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 0000000..eb532fb
--- /dev/null
+++ b/test-cases/generated-pages/src/concrete-only.pages.js
@@ -0,0 +1,14 @@
+// @ts-nocheck
+
+export default function concreteOnlyPages ({ pages }) {
+ return {
+ outputName: 'generated-introspection/index.html',
+ vars: {
+ layout: 'root',
+ title: 'Generated introspection',
+ sawGenerated: pages.some(page => page.pageInfo.type === 'generated'),
+ concreteCount: pages.length,
+ },
+ children: ({ vars }) => `${vars.sawGenerated}
${vars.concreteCount}
`,
+ }
+}
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 0000000..8e4ffaa
--- /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 0000000..0be0f75
--- /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 0000000..482ae9d
--- /dev/null
+++ b/test-cases/generated-pages/src/global.data.js
@@ -0,0 +1,7 @@
+// @ts-nocheck
+
+export default function globalData ({ pages }) {
+ return {
+ generatedPageCount: pages.filter(page => page.pageInfo.type === '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 0000000..a62f359
--- /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/indexes.pages.js b/test-cases/generated-pages/src/indexes.pages.js
new file mode 100644
index 0000000..9df5a2f
--- /dev/null
+++ b/test-cases/generated-pages/src/indexes.pages.js
@@ -0,0 +1,15 @@
+// @ts-nocheck
+
+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: ({ vars }) => `${vars.title} ${vars.postCount}
`,
+ }
+}
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 0000000..0610b73
--- /dev/null
+++ b/test-cases/generated-pages/src/redirect.layout.js
@@ -0,0 +1,16 @@
+// @ts-nocheck
+
+export default function redirectLayout ({ vars }) {
+ return `
+
+
+
+
+
+ ${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 0000000..fb30f19
--- /dev/null
+++ b/test-cases/generated-pages/src/redirects.pages.js
@@ -0,0 +1,15 @@
+const redirects = [
+ { from: 'old-url', to: '/new-url/' },
+]
+
+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 0000000..dc4b275
--- /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 0000000..edaa3af
--- /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 0000000..b4a31bb
--- /dev/null
+++ b/test-cases/generated-pages/src/root.layout.js
@@ -0,0 +1,17 @@
+// @ts-nocheck
+
+export default function rootLayout ({ vars, styles = [], scripts = [], children }) {
+ return `
+
+
+
+ ${vars.title}
+ ${styles.map(href => ` `).join('\n ')}
+ ${scripts.map(src => ``).join('\n ')}
+
+
+
+ ${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 0000000..da6d473
--- /dev/null
+++ b/test-cases/generated-pages/src/summary.template.js
@@ -0,0 +1,11 @@
+// @ts-nocheck
+
+export default function summaryTemplate ({ pages, vars }) {
+ return {
+ outputName: 'summary.json',
+ content: JSON.stringify({
+ generatedPageCount: vars.generatedPageCount,
+ generatedPagesInTemplate: pages.filter(page => page.pageInfo.type === 'generated').length,
+ }, null, 2),
+ }
+}