From 78d012eb08576d0bacd1cabb0a2b2f25634eca48 Mon Sep 17 00:00:00 2001 From: Bret Comnes Date: Fri, 26 Jun 2026 21:18:03 -0700 Subject: [PATCH 1/7] Implement generated pages files --- README.md | 95 +++++ index.js | 74 +++- lib/build-pages/index.js | 262 +++++++++++- lib/build-pages/page-builders/js/index.js | 10 + lib/helpers/domstack-error.js | 34 +- lib/identify-pages.js | 28 ++ plans/generated-pages.md | 397 ++++++++++++++++++ test-cases/generated-pages/index.test.js | 291 +++++++++++++ test-cases/generated-pages/src/README.md | 7 + .../generated-pages/src/about/README.md | 7 + test-cases/generated-pages/src/async.pages.js | 10 + .../generated-pages/src/blog/post-one/page.md | 8 + .../src/concrete-only.pages.js | 26 ++ .../generated-pages/src/global.client.js | 1 + test-cases/generated-pages/src/global.css | 1 + test-cases/generated-pages/src/global.data.js | 10 + test-cases/generated-pages/src/global.vars.js | 4 + .../src/guides/current/README.md | 7 + .../generated-pages/src/indexes.pages.js | 27 ++ .../generated-pages/src/new-url/README.md | 7 + .../generated-pages/src/redirect.layout.js | 21 + .../generated-pages/src/redirects.pages.js | 17 + .../generated-pages/src/root.layout.client.js | 1 + .../generated-pages/src/root.layout.css | 1 + test-cases/generated-pages/src/root.layout.js | 23 + .../generated-pages/src/summary.template.js | 14 + types.ts | 6 +- 27 files changed, 1371 insertions(+), 18 deletions(-) create mode 100644 plans/generated-pages.md create mode 100644 test-cases/generated-pages/index.test.js create mode 100644 test-cases/generated-pages/src/README.md create mode 100644 test-cases/generated-pages/src/about/README.md create mode 100644 test-cases/generated-pages/src/async.pages.js create mode 100644 test-cases/generated-pages/src/blog/post-one/page.md create mode 100644 test-cases/generated-pages/src/concrete-only.pages.js create mode 100644 test-cases/generated-pages/src/global.client.js create mode 100644 test-cases/generated-pages/src/global.css create mode 100644 test-cases/generated-pages/src/global.data.js create mode 100644 test-cases/generated-pages/src/global.vars.js create mode 100644 test-cases/generated-pages/src/guides/current/README.md create mode 100644 test-cases/generated-pages/src/indexes.pages.js create mode 100644 test-cases/generated-pages/src/new-url/README.md create mode 100644 test-cases/generated-pages/src/redirect.layout.js create mode 100644 test-cases/generated-pages/src/redirects.pages.js create mode 100644 test-cases/generated-pages/src/root.layout.client.js create mode 100644 test-cases/generated-pages/src/root.layout.css create mode 100644 test-cases/generated-pages/src/root.layout.js create mode 100644 test-cases/generated-pages/src/summary.template.js 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, From bd93bb932b1ab26871b9d9589febd6fbced14fc8 Mon Sep 17 00:00:00 2001 From: Bret Comnes Date: Sat, 29 Aug 2026 15:20:41 -0700 Subject: [PATCH 2/7] Fix generated page worker results --- README.md | 2 + lib/build-pages/index.js | 30 +++- lib/build-pages/page-builders/js/index.js | 11 +- lib/build-pages/page-builders/page-writer.js | 30 +++- lib/builder.js | 2 +- lib/domstack-manifest/index.js | 4 +- plans/generated-pages.md | 160 ++++++++++++++++++- test-cases/generated-pages/index.test.js | 95 +++++++++++ 8 files changed, 313 insertions(+), 21 deletions(-) diff --git a/README.md b/README.md index 502d802f..c8f5518e 100644 --- a/README.md +++ b/README.md @@ -1389,6 +1389,8 @@ export default { } ``` +The `vars` passed to a `manifestVars` function are a snapshot of page vars that can be copied from the page worker. Top-level values used only while rendering, such as functions or `PageData` objects, are left out of this snapshot. + Only values selected by `manifestVars` are copied into public manifest entries. Root `policy` is emitted once on the manifest. This avoids leaking arbitrary page vars while still letting service workers, Workbox hooks, and deployment tools consume a stable manifest-level policy shape. ### Manifest built hooks diff --git a/lib/build-pages/index.js b/lib/build-pages/index.js index 6afca6b5..c4ee969b 100644 --- a/lib/build-pages/index.js +++ b/lib/build-pages/index.js @@ -56,8 +56,8 @@ const __dirname = import.meta.dirname */ /** - * Internal options for filtering which pages/templates to rebuild. - * Uses arrays (not Sets) so they can be structured-cloned across the worker boundary. + * Internal options sent to the page worker. + * Uses arrays (not Sets) so the values can be copied to the worker. * * @typedef {object} BuildPagesFilterOptions * @property {string[] | null} [pageFilterPaths] - If set, only rebuild pages whose pageFile.filepath is in this list. @@ -87,7 +87,7 @@ const __dirname = import.meta.dirname * @template {Record} [T=Record] * @template [U=any] * @typedef {object} GeneratedPageDefinition - * @property {string} [outputName] - Relative output filename, defaulting to the pages file name. + * @property {string} [outputName] - Relative output filename, defaulting to `/index.html`. * @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. @@ -139,6 +139,21 @@ const __dirname = import.meta.dirname export { pageBuilders } +/** + * Remove generated vars and rendering functions before returning page error + * information from the worker. Concrete PageInfo objects are already copyable. + * + * @param {PageInfo} pageInfo + * @returns {PageInfo} + */ +function pageInfoForWorker (pageInfo) { + if (!pageInfo.generated) return pageInfo + return { + ...pageInfo, + generated: { pagesFile: pageInfo.generated.pagesFile }, + } +} + /** * @param {WorkerErrorData} errorData * @returns {{ type: 'page' | 'template' | 'pages file', path: string } | null} @@ -484,7 +499,7 @@ export async function buildPagesDirect (_src, dest, siteData, opts) { if (!(err instanceof Error)) throw new Error('Non-error thrown while resolving vars', { cause: err }) const variableResolveError = new Error('Error resolving page vars', { cause: { message: err.message, stack: err.stack } }) // I can't put stuff on the error, the worker swallows it for some reason. - result.errors.push({ error: variableResolveError, errorData: { page: pageInfo } }) + result.errors.push({ error: variableResolveError, errorData: { page: pageInfoForWorker(pageInfo) } }) } return pageData } @@ -558,9 +573,12 @@ export async function buildPagesDirect (_src, dest, siteData, opts) { result.report.pages.push({ pageFilePath: buildResult.pageFilePath }) result.outputs.push(...buildResult.outputs) } catch (err) { - const buildError = new Error('Error building page', { cause: err }) + const cause = err instanceof Error + ? { message: err.message, stack: err.stack } + : { message: `Non-error thrown (${err === null ? 'null' : typeof err})` } + const buildError = new Error('Error building page', { cause }) // I can't put stuff on the error, the worker swallows it for some reason. - result.errors.push({ error: buildError, errorData: { page: page.pageInfo } }) + result.errors.push({ error: buildError, errorData: { page: pageInfoForWorker(page.pageInfo) } }) } }, { concurrency: dividedConcurrency[0] }), pMap(templatesToRender, async (template) => { diff --git a/lib/build-pages/page-builders/js/index.js b/lib/build-pages/page-builders/js/index.js index 7235137a..53c7f62a 100644 --- a/lib/build-pages/page-builders/js/index.js +++ b/lib/build-pages/page-builders/js/index.js @@ -1,14 +1,17 @@ /** - * @import { PageBuilderType } from '../page-writer.js' + * @import { PageInfo } from '../../../identify-pages.js' + * @import { PageBuilderResult } from '../page-writer.js' */ import assert from 'node:assert' /** - * Build all of the bundles using esbuild. + * Resolve a JavaScript page module. * @template {Record} T - The type of variables for the page - * @template [U=any] U - The return type of the pageLayout function - * @type {PageBuilderType} + * @template [U=any] U - The return type of the page function + * @param {object} params + * @param {PageInfo} params.pageInfo + * @returns {Promise>} */ export async function jsBuilder ({ pageInfo }) { assert(pageInfo.type === 'js', 'js page builder requires "js" page type') diff --git a/lib/build-pages/page-builders/page-writer.js b/lib/build-pages/page-builders/page-writer.js index bf6b49dc..b3262fdd 100644 --- a/lib/build-pages/page-builders/page-writer.js +++ b/lib/build-pages/page-builders/page-writer.js @@ -1,6 +1,6 @@ /** * @import { PageInfo } from '../../identify-pages.js' - * @import { PageData } from '../page-data.js' + * @import { PageData as PageDataClass } from '../page-data.js' * @import { DomstackManifestRecord } from '../../domstack-manifest/index.js' */ @@ -17,7 +17,7 @@ import { createDomstackManifestRecord } from '../../domstack-manifest/index.js' * @template {Record} T * @template [U=any] U - The return type of the page function (defaults to any) * @template [V=string] V - The return type of the layout function (defaults to string) - * @typedef {PageData} PageData + * @typedef {PageDataClass} PageData */ /** @@ -102,6 +102,8 @@ export async function pageWriter ({ const pageFilePath = join(pageDir, page.pageInfo.outputName) const formattedPageOutput = await page.renderFullPage({ pages }) + const vars = page.vars + const manifestRole = extractManifestRole(vars) await mkdir(pageDir, { recursive: true }) await writeFile(pageFilePath, formattedPageOutput) @@ -116,8 +118,8 @@ export async function pageWriter ({ sourceRelname: page.pageInfo.pageFile.relname, pagePath: page.pageInfo.path, pageUrl: page.pageInfo.url, - pageVars: page.vars, - manifestRole: extractManifestRole(page.vars), + pageVars: copyPageVars(vars), + ...(manifestRole ? { manifestRole } : {}), page: { path: page.pageInfo.path, url: page.pageInfo.url, @@ -158,6 +160,26 @@ export async function pageWriter ({ return { pageFilePath, outputs } } +/** + * Copy top-level vars that can be sent from the page worker. Runtime-only values + * such as PageData arrays stay available during rendering but are left out here. + * + * @param {Record} vars + * @returns {Record} + */ +function copyPageVars (vars) { + /** @type {Record} */ + const copied = {} + for (const [key, value] of Object.entries(vars)) { + try { + copied[key] = structuredClone(value) + } catch { + // This value is only available while rendering inside the page worker. + } + } + return copied +} + /** * Reads the optional page-level role override copied to the public manifest. * diff --git a/lib/builder.js b/lib/builder.js index 71c94d13..37b85439 100644 --- a/lib/builder.js +++ b/lib/builder.js @@ -73,7 +73,7 @@ import { /** * The data generated about the site generate dby identifyPages - * @typedef {Awaited>} SiteData + * @typedef {Awaited>} SiteData */ /** diff --git a/lib/domstack-manifest/index.js b/lib/domstack-manifest/index.js index 02537ef6..ff22511b 100644 --- a/lib/domstack-manifest/index.js +++ b/lib/domstack-manifest/index.js @@ -204,7 +204,7 @@ export function getDomstackManifestSchemaId (version) { * @property {string} [pagePath] - Source-relative page path associated with page-owned output. * @property {string} [pageUrl] - Canonical public URL for the page associated with this output. * @property {string} [templatePath] - Source-relative template path associated with template output. - * @property {Record} [pageVars] - Internal page variables available to manifest option transforms. + * @property {Record} [pageVars] - Copyable top-level page variables available to manifest option transforms. * @property {string} [manifestRole] - Explicit user-provided role for this output. * @property {Record} [manifestVars] - Explicit page/app variables to expose on this manifest entry. @@ -431,7 +431,7 @@ export async function reconcileDomstackManifest ({ dest, records = [], entries: * @param {string} [params.pagePath] - Source-relative page path for page-owned outputs. * @param {string} [params.pageUrl] - Canonical public page URL for page-owned outputs. * @param {string} [params.templatePath] - Source-relative template path for template outputs. - * @param {Record} [params.pageVars] - Internal page variables available to manifest option transforms. + * @param {Record} [params.pageVars] - Copyable top-level page variables available to manifest option transforms. * @param {string} [params.manifestRole] - Explicit user-provided role for this output. * @param {Record} [params.manifestVars] - Explicit page/app variables to expose on this manifest entry. diff --git a/plans/generated-pages.md b/plans/generated-pages.md index 61e1c5ea..e962537b 100644 --- a/plans/generated-pages.md +++ b/plans/generated-pages.md @@ -1,11 +1,160 @@ # Generated Pages Files -## Status: Proposed refinement +## Status: Implementation review — changes requested Plan for adding first-class generated page support in response to the redirect-page discussion in PR #253. +## PR #253 implementation review + +Reviewed commit `78d012e` on 2026-08-29. + +### Verdict + +Do not land PR #253 yet. The core design is sound and the clean-build happy path works, but the current implementation has worker-boundary and generated-output lifecycle problems that should be fixed first. Watch behavior, error reporting, and public documentation also need another pass. + +### Findings + +#### 1. Resolved: the documented blog-index example could not be sent back from the worker + +`README.md:1107-1117` places concrete `PageData` objects into `vars.posts`. Those objects contain functions such as resolved layout renderers. + +Every page's complete vars were added to its output record at `lib/build-pages/page-builders/page-writer.js:109-120`. The worker then tried to send that record back to the main thread at `lib/build-pages/worker.js:9-10`. Functions cannot be sent this way, so the documented example could write its HTML and then reject with a `DataCloneError`. + +Generated-page render errors have the same root problem. `lib/build-pages/index.js:560-563` sends the complete generated `PageInfo` as error context, including the function-valued `generated.children` stored at `lib/build-pages/index.js:274-278`. A useful render exception can therefore be replaced by an unclear worker-copy failure. + +Implemented resolution: + +- Generated pages remain regular `PageInfo` objects handled by the existing JS page builder. +- Rendering functions and complete vars remain available inside the page worker. +- Output records return a snapshot of page vars by copying each top-level value independently. Values that cannot be copied, such as `PageData[]`, are left out of the snapshot. +- Generated page error information omits `generated.vars` and `generated.children` before it is returned from the worker. +- Manifest allowlists and functions continue to use the returned page-vars snapshot in the main thread. +- Regression tests cover the README pattern with `PageData[]` in generated vars, generated render errors, allowlisted manifest vars, and function manifest transforms using post-render values. + +#### 2. High: removed or renamed generated outputs remain on disk + +When a pages file changes, `index.js:464-467` rebuilds the current pages and dependency maps, but it does not compare the new output set with the previous one. `ensureDest()` only creates directories, and `pageWriter()` only writes outputs that exist in the current build. + +Focused reproductions confirmed that: + +- Renaming `old/index.html` to `new/index.html` leaves both files. +- Removing a returned definition leaves its old route. +- Changing an existing definition to `draft: true` leaves the previously published file. +- Removing the entire `*.pages.*` file leaves its generated outputs. + +This is particularly risky for redirects because an intentionally removed redirect can continue being served. It exposes a broader existing output-lifecycle limitation, but dynamic page factories make the problem much easier to encounter. + +Before landing, track output records from the previous successful generated build and safely remove outputs that are no longer claimed. Add rename, removal, pages-file deletion, and draft-transition tests. + +#### 3. Medium: conflict detection does not cover templates or other output producers + +The conflict map at `lib/build-pages/index.js:293-343` contains only concrete and generated pages. Pages and templates are then written concurrently at `lib/build-pages/index.js:549-584`. + +A generated page and template targeting the same path can both succeed, with the final content depending on which asynchronous write wins. Manifest reconciliation may warn afterward when enabled, but the destination file has already been overwritten. + +The original plan calls page-to-page checks the minimum v1 scope, while the PR summary more broadly says that it detects output conflicts. Either: + +- Centralize output claims across pages and templates before writing, with a longer-term path to include esbuild, static, and copied outputs; or +- Narrow the public wording to "page-output conflicts" and track generalized output conflict handling separately. + +At minimum, duplicate emitted output records should fail the build rather than silently accept last-writer-wins behavior. + +#### 4. Medium: adding or removing a layout asset leaves generated HTML stale in watch mode + +For layout CSS or client add/unlink events, `index.js:386-399` builds a filter from `#layoutPageMap`. That map contains only concrete `siteData.pages`, so the filter at `lib/build-pages/index.js:537-540` omits generated pages. + +When concrete and generated pages share a layout, adding `root.layout.css` updates the concrete HTML while the generated HTML remains unchanged. Removing the asset has the inverse stale-reference problem. + +Layout source changes already trigger a full generated-page rebuild. Layout asset add/unlink should use the same conservative behavior whenever `siteData.pagesFiles` is non-empty, with tests for adding and removing both layout CSS and layout clients. + +#### 5. Medium: generated-page errors lose their type and source context + +`DomStackOutputConflictError` defines a useful code and structured conflict metadata at `lib/helpers/domstack-error.js:71-90`, but `lib/build-pages/index.js:505-508` immediately wraps it in a generic `Error`. + +Caller-visible errors consequently lose: + +- `DOM_STACK_ERROR_OUTPUT_CONFLICT` +- The `conflict` object +- The pages file that produced an invalid definition or path + +`WorkerErrorData.pagesFile` is declared at `lib/build-pages/index.js:128-134` but is never populated. + +Catch resolution errors per pages file and transfer a small serializable context object. Tests should assert error codes, source filenames, and both conflict producers rather than only matching message text. + +#### 6. Design gap: generated pages are not added to public `siteData.pages` + +Generated pages exist only in the worker-local array at `lib/build-pages/index.js:513-514`. The returned `results.siteData.pages` remains the concrete discovery list from `lib/identify-pages.js:612-628`. + +This differs from the expanded-site model later in this plan and means: + +- Programmatic `results.siteData.pages` is concrete-only. +- Watch maps remain concrete-only and require broad generated-page special cases. +- The initial `Pages:` build total excludes generated pages, although `Pages built:` includes them. +- No generated `PageInfo` graph is available to programmatic callers after the build. + +Either implement an explicit `expandedSiteData`/`concretePages` model or deliberately define and document `siteData.pages` as discovery-only. Add a test that locks in the chosen public behavior. + +#### 7. Documentation and public types need another pass + +In addition to fixing the broken primary example: + +- Promote Generated Pages from a Templates subsection to its own top-level feature section. +- Document static object and array exports, async functions, async iterables, and all supported module suffixes. +- Document the `vars`, `pagesFile`, and `siteData` factory parameters. +- Document generated `draft: true` behavior and its relationship to `--drafts`/`buildDrafts`. +- Add `PagesFunction`, `AsyncPagesFunction`, `PagesFunctionParams`, `GeneratedPageDefinition`, and `PagesFileInfo` to the README type catalog. +- Import public types from `@domstack/static/types.js`, not the package root. +- Correct the `GeneratedPageDefinition.outputName` JSDoc to say it defaults to `/index.html`. +- Revisit `AsyncPagesFunction`: its required `Promise` return cannot annotate the supported `async function*` form in `test-cases/generated-pages/src/async.pages.js`. A `PagesAsyncIterator` type aligned with `TemplateAsyncIterator` would be clearer. +- Consider making `PagesFunctionParams` generic so incoming global vars can be typed separately from generated page vars before the public API is frozen. + +The redirect security warning and meta-refresh SEO guidance are accurate. + +### Objective assessment + +The implementation achieves the core design in a clean one-shot build: + +- Discovers the intended `*.pages.*` module families. +- Gives every factory a stable view of initialized concrete pages. +- Supports object, array, promise, and async-iterable results. +- Runs generated pages through normal vars, layout, global asset, and manifest processing. +- Exposes generated pages to global data, templates, page functions, and layouts. +- Validates definitions and output paths. +- Detects generated-to-concrete and generated-to-generated page conflicts. +- Rebuilds generated pages for normal pages-file and imported-dependency changes. + +The objective is still only partially complete in watch mode and in the public data model. The documented programmatic index now builds successfully: its `PageData[]` remains available while rendering and is left out of the page-vars snapshot returned from the worker. + +PR #253 says it closes issue #237. The generalized page-factory mechanism satisfies the later PR discussion, but the original issue also asks for native redirect declarations and target-existence validation. This implementation does not validate redirect destinations or natively emit hosting-provider redirect configuration. Either explicitly accept this generalized API as the resolution of #237 or leave the issue open for those remaining capabilities. + +### Landing checklist + +- [x] Make generated-page success and error results safe to send from the worker. +- [x] Validate the README index example with a worker-boundary regression test. +- [ ] Reconcile and remove obsolete generated outputs. +- [ ] Rebuild generated pages when layout assets are added or removed. +- [ ] Preserve output-conflict codes, metadata, and pages-file context. +- [ ] Decide and document the scope of output conflict detection. +- [ ] Decide whether returned `siteData.pages` is concrete-only or expanded. +- [ ] Finalize generated-pages type names and generics. +- [ ] Complete the README API and type documentation. +- [ ] Decide whether this generalized feature fully closes issue #237. + +### Validation performed during review + +- `npm run test:node-test -- test-cases/generated-pages/index.test.js` — passed. +- `npm run test:node-test` — passed. +- `npm run test:tsc` — passed. +- `npm run build:declaration` after cleaning generated declarations — passed. +- Focused ESLint over all changed JavaScript and TypeScript files — passed. +- `git diff --check` — passed. +- Root `npm run test:neostandard` in the review checkout was polluted by malformed fixtures under ignored `.delta/worktrees`, not by PR changes. +- The original worker-copy reproduction now passes. Focused reproductions still confirm stale generated outputs, the layout-asset watch gap, and the generated/template output race. + --- +## Original design plan + ## Problem Templates can already write arbitrary files, including redirect HTML files, `_redirects`, feeds, and other generated assets. They do not, however, create real DomStack pages: @@ -223,9 +372,12 @@ if (pageInfo.generated) { } ``` -`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. +Generated pages then follow the same `PageData` initialization and rendering +path as concrete JavaScript pages. Generated vars and functions stay inside the +page worker while rendering. When the worker returns its build report, it copies +each top-level page var independently and leaves out values that cannot be +copied. Generated page error information similarly leaves out `vars` and +`children`. ## Conflict detection diff --git a/test-cases/generated-pages/index.test.js b/test-cases/generated-pages/index.test.js index 5d8925ce..a2ec5401 100644 --- a/test-cases/generated-pages/index.test.js +++ b/test-cases/generated-pages/index.test.js @@ -122,6 +122,65 @@ test.describe('generated pages', () => { assert.equal(summary.generatedPagesInTemplate, 6, 'template pages include generated pages') }) + test('returns copyable generated vars and keeps PageData values inside the worker', async () => { + await withTempFixture({ + 'root.layout.js': minimalRootLayout, + 'global.vars.js': minimalGlobalVars, + 'README.md': '# Concrete page\n', + 'indexes.pages.js': `export default function indexesPages ({ pages }) { + return { + outputName: 'generated-index/index.html', + vars: { + title: 'Generated index', + posts: pages, + }, + children: ({ vars }) => \`

\${vars.posts.length}

\`, + } +} +`, + }, async ({ src, dest }) => { + const domstack = new DomStack(src, dest) + const results = await domstack.build() + const output = await readFile(join(dest, 'generated-index/index.html'), 'utf8') + const outputRecord = results.pageBuildResults?.outputs.find(output => output.outputRelname === 'generated-index/index.html') + + assert.match(output, /

1<\/p>/, 'generated page renders with concrete PageData values in vars') + assert.ok(outputRecord, 'generated page emits an output record') + assert.equal(outputRecord.pageVars?.['title'], 'Generated index', 'copyable page vars are returned') + assert.equal(Object.hasOwn(outputRecord.pageVars ?? {}, 'posts'), false, 'PageData values stay inside the worker') + }) + }) + + test('returns generated render errors without sending render state', async () => { + await withTempFixture({ + 'root.layout.js': minimalRootLayout, + 'global.vars.js': minimalGlobalVars, + 'broken.pages.js': `export default { + outputName: 'broken/index.html', + children () { + throw new Error('generated boom', { cause: () => {} }) + }, +} +`, + }, async ({ src, dest }) => { + const domstack = new DomStack(src, dest) + await assert.rejects( + () => domstack.build(), + error => { + const aggregate = /** @type {Error & { errors?: Array }} */ (error) + const generatedError = aggregate.errors?.find(error => error.page?.generated) + + assert.ok(generatedError, 'build includes the generated page error') + assert.match(generatedError.message, /page: "broken"/) + assert.equal(generatedError.page?.generated?.pagesFile?.pagesFile?.relname, 'broken.pages.js') + assert.notEqual(generatedError.name, 'DataCloneError') + assert.equal(/** @type {{ message?: string } | undefined} */ (generatedError.cause)?.message, 'generated boom') + return true + } + ) + }) + }) + test('includes generated pages in the domstack manifest as page entries', async () => { await withTempFixture({ 'root.layout.js': minimalRootLayout, @@ -145,8 +204,10 @@ test.describe('generated pages', () => { }) const results = await domstack.build() const entry = results.domstackManifest?.entries.find(entry => entry.outputRelname === 'archive/index.html') + const outputRecord = results.pageBuildResults?.outputs.find(output => output.outputRelname === 'archive/index.html') assert.ok(entry, 'generated page is present in the domstack manifest') + assert.equal(outputRecord?.pageVars?.['archiveYear'], 2024, 'copyable page vars are returned from the worker') assert.equal(entry.kind, 'page') assert.equal(entry.url, '/archive/') assert.equal(entry.sourceRelname, 'archive.pages.js#0') @@ -164,6 +225,40 @@ test.describe('generated pages', () => { }) }) + test('supports function manifest transforms with generated vars', async () => { + await withTempFixture({ + 'root.layout.js': minimalRootLayout, + 'global.vars.js': minimalGlobalVars, + 'archive.pages.js': `export default { + outputName: 'archive/index.html', + vars: { + title: 'Archive', + archive: { year: 2024 }, + }, + children ({ vars }) { + vars.archive.year = 2025 + return '

Generated archive

' + }, +} +`, + }, async ({ src, dest }) => { + const domstack = new DomStack(src, dest, { + domstackManifest: { + manifestVars: ({ vars }) => { + const archive = /** @type {{ year: number } | undefined} */ (vars['archive']) + return archive ? { archiveLabel: String(archive.year) } : {} + }, + }, + }) + const results = await domstack.build() + const entry = results.domstackManifest?.entries.find(entry => entry.outputRelname === 'archive/index.html') + const outputRecord = results.pageBuildResults?.outputs.find(output => output.outputRelname === 'archive/index.html') + + assert.deepEqual(entry?.manifestVars, { archiveLabel: '2025' }) + assert.deepEqual(outputRecord?.pageVars?.['archive'], { year: 2025 }, 'function transforms receive complete post-render page vars') + }) + }) + test('throws a conflict error for generated pages that collide with concrete pages', async () => { await withTempFixture({ 'root.layout.js': minimalRootLayout, From e02258e682e55cb9b7374edb464308ce28fbcae1 Mon Sep 17 00:00:00 2001 From: Bret Comnes Date: Sat, 29 Aug 2026 15:36:09 -0700 Subject: [PATCH 3/7] Clean up obsolete page outputs in watch mode --- index.js | 37 ++++++++++++++++ plans/generated-pages.md | 25 ++++++----- test-cases/generated-pages/index.test.js | 54 ++++++++++++++++++++++++ 3 files changed, 103 insertions(+), 13 deletions(-) diff --git a/index.js b/index.js index 64183067..be59dfca 100644 --- a/index.js +++ b/index.js @@ -24,6 +24,7 @@ import { inspect } from 'util' import { createServer } from '@domstack/sync' import { find } from '@11ty/dependency-tree-typescript' +import { assertInsideDest } from './lib/helpers/path.js' import { getCopyGlob } from './lib/build-static/index.js' import { getCopyDirs } from './lib/build-copy/index.js' import { builder } from './lib/builder.js' @@ -103,6 +104,8 @@ export class DomStack { #pagesFileDepMap = new Map() /** @type {Set} absolute filepaths of esbuild entry points */ #esbuildEntryPoints = new Set() + /** @type {Set} destination-relative outputs from the last successful full page build */ + #pageOutputRelnames = new Set() // Serialized lock so concurrent chokidar events don't pile up /** @type {Promise} */ @@ -201,6 +204,7 @@ export class DomStack { siteData, pageBuildResults, } + this.#pageOutputRelnames = getPageOutputRelnames(pageBuildResults.outputs) buildLogger(report, this.#logger) this.#logger.info('Initial JS, CSS and Page Build Complete') } catch (err) { @@ -443,6 +447,7 @@ ${siteData.errors.map(err => ` ${err.message}`).join('\n')}`) }) } const isFiltered = pageFilterPaths !== null || templateFilterPaths !== null + if (!isFiltered) await this.#removeObsoletePageOutputs(pageBuildResults.outputs) buildLogger( isFiltered ? pageBuildResults : { warnings: pageBuildResults.warnings, siteData, pageBuildResults }, this.#logger, @@ -454,6 +459,28 @@ ${siteData.errors.map(err => ` ${err.message}`).join('\n')}`) } } + /** + * Remove page files that were emitted by the previous successful full build + * but are no longer claimed by the current page or template build. + * + * @param {DomstackManifestRecord[]} outputs + */ + async #removeObsoletePageOutputs (outputs) { + const currentOutputRelnames = new Set(outputs.map(output => output.outputRelname)) + const currentPageOutputRelnames = getPageOutputRelnames(outputs) + const dest = resolve(this.#dest) + + await Promise.all(Array.from(this.#pageOutputRelnames, async outputRelname => { + if (currentOutputRelnames.has(outputRelname)) return + const filepath = resolve(dest, outputRelname) + assertInsideDest(dest, filepath) + if (filepath === dest) throw new Error('Refusing to remove the build destination') + await rm(filepath, { force: true }) + })) + + this.#pageOutputRelnames = currentPageOutputRelnames + } + /** * Rebuild all pages and refresh dependency maps afterward. * Generated pages can change their import graph without changing the site's @@ -798,6 +825,16 @@ ${siteData.errors.map(err => ` ${err.message}`).join('\n')}`) } } +/** + * @param {DomstackManifestRecord[]} outputs + * @returns {Set} + */ +function getPageOutputRelnames (outputs) { + return new Set(outputs + .filter(output => output.kind === 'page') + .map(output => output.outputRelname)) +} + /** * Build a DomStack site into a temporary directory for isolated tests. * diff --git a/plans/generated-pages.md b/plans/generated-pages.md index e962537b..74446850 100644 --- a/plans/generated-pages.md +++ b/plans/generated-pages.md @@ -31,20 +31,19 @@ Implemented resolution: - Manifest allowlists and functions continue to use the returned page-vars snapshot in the main thread. - Regression tests cover the README pattern with `PageData[]` in generated vars, generated render errors, allowlisted manifest vars, and function manifest transforms using post-render values. -#### 2. High: removed or renamed generated outputs remain on disk +#### 2. Resolved: watch mode removes obsolete regular and generated page outputs -When a pages file changes, `index.js:464-467` rebuilds the current pages and dependency maps, but it does not compare the new output set with the previous one. `ensureDest()` only creates directories, and `pageWriter()` only writes outputs that exist in the current build. +One-shot builds assume an empty destination. Watch mode previously rebuilt the pages that currently existed but did not remove files written by pages that had disappeared. This affected regular pages too, although generated pages made it easier to encounter because changing one `*.pages.*` file can rename or remove many outputs. -Focused reproductions confirmed that: - -- Renaming `old/index.html` to `new/index.html` leaves both files. -- Removing a returned definition leaves its old route. -- Changing an existing definition to `draft: true` leaves the previously published file. -- Removing the entire `*.pages.*` file leaves its generated outputs. - -This is particularly risky for redirects because an intentionally removed redirect can continue being served. It exposes a broader existing output-lifecycle limitation, but dynamic page factories make the problem much easier to encounter. +Implemented resolution: -Before landing, track output records from the previous successful generated build and safely remove outputs that are no longer claimed. Add rename, removal, pages-file deletion, and draft-transition tests. +- The `DomStack` watch instance keeps a set of page output paths from the latest successful full page build. +- After the next successful full page build, it removes previous page files that are no longer claimed by any current page or template output. +- Regular and generated pages use the same cleanup because both emit normal `kind: 'page'` output records. +- Failed and filtered builds do not remove files or replace the saved set because their output lists are incomplete. +- The saved set is replaced after each successful cleanup, so memory use stays proportional to the current number of pages rather than growing across rebuilds. +- Cleanup resolves each recorded path inside the destination and refuses to remove the destination itself. +- Regression coverage includes a regular page removal plus generated output rename, definition removal, transition to `draft: true`, and deletion of the entire `*.pages.*` file. #### 3. Medium: conflict detection does not cover templates or other output producers @@ -131,7 +130,7 @@ PR #253 says it closes issue #237. The generalized page-factory mechanism satisf - [x] Make generated-page success and error results safe to send from the worker. - [x] Validate the README index example with a worker-boundary regression test. -- [ ] Reconcile and remove obsolete generated outputs. +- [x] Reconcile and remove obsolete regular and generated page outputs in watch mode. - [ ] Rebuild generated pages when layout assets are added or removed. - [ ] Preserve output-conflict codes, metadata, and pages-file context. - [ ] Decide and document the scope of output conflict detection. @@ -149,7 +148,7 @@ PR #253 says it closes issue #237. The generalized page-factory mechanism satisf - Focused ESLint over all changed JavaScript and TypeScript files — passed. - `git diff --check` — passed. - Root `npm run test:neostandard` in the review checkout was polluted by malformed fixtures under ignored `.delta/worktrees`, not by PR changes. -- The original worker-copy reproduction now passes. Focused reproductions still confirm stale generated outputs, the layout-asset watch gap, and the generated/template output race. +- The original worker-copy reproduction now passes. Obsolete regular and generated page outputs are now removed in watch mode. Focused reproductions still confirm the layout-asset watch gap and the generated/template output race. --- diff --git a/test-cases/generated-pages/index.test.js b/test-cases/generated-pages/index.test.js index a2ec5401..1a8cdef3 100644 --- a/test-cases/generated-pages/index.test.js +++ b/test-cases/generated-pages/index.test.js @@ -346,6 +346,60 @@ test.describe('generated pages', () => { }) }) + test('removes obsolete regular and generated page outputs in watch mode', { timeout: 20_000 }, async () => { + await withTempFixture({ + 'root.layout.js': minimalRootLayout, + 'global.vars.js': minimalGlobalVars, + 'regular/page.html': '

Regular page

', + 'changing.pages.js': `export default [ + { outputName: 'old/index.html', children: 'Old generated page' }, + { outputName: 'removed/index.html', children: 'Removed generated page' }, + { outputName: 'drafted/index.html', children: 'Published generated page' }, +] +`, + }, async ({ src, dest }) => { + const domstack = new DomStack(src, dest) + try { + await domstack.watch({ serve: false }) + const oldOutputPath = join(dest, 'old/index.html') + const newOutputPath = join(dest, 'new/index.html') + const removedOutputPath = join(dest, 'removed/index.html') + const draftedOutputPath = join(dest, 'drafted/index.html') + const regularOutputPath = join(dest, 'regular/index.html') + + assert.match(await readFile(oldOutputPath, 'utf8'), /Old generated page/) + assert.match(await readFile(removedOutputPath, 'utf8'), /Removed generated page/) + assert.match(await readFile(draftedOutputPath, 'utf8'), /Published generated page/) + assert.match(await readFile(regularOutputPath, 'utf8'), /Regular page/) + + await writeFile(join(src, 'changing.pages.js'), `export default [ + { outputName: 'new/index.html', children: 'Renamed generated page' }, + { outputName: 'drafted/index.html', children: 'Draft generated page', draft: true }, +] +`) + await new Promise(resolve => setTimeout(resolve, 800)) + await domstack.settled() + + assert.match(await readFile(newOutputPath, 'utf8'), /Renamed generated page/) + await assert.rejects(() => readFile(oldOutputPath, 'utf8'), { code: 'ENOENT' }) + await assert.rejects(() => readFile(removedOutputPath, 'utf8'), { code: 'ENOENT' }) + await assert.rejects(() => readFile(draftedOutputPath, 'utf8'), { code: 'ENOENT' }) + + await rm(join(src, 'regular/page.html')) + await new Promise(resolve => setTimeout(resolve, 800)) + await domstack.settled() + await assert.rejects(() => readFile(regularOutputPath, 'utf8'), { code: 'ENOENT' }) + + await rm(join(src, 'changing.pages.js')) + await new Promise(resolve => setTimeout(resolve, 800)) + await domstack.settled() + await assert.rejects(() => readFile(newOutputPath, 'utf8'), { code: 'ENOENT' }) + } 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, From c86fc04c71da65adb418fb88286925d8ff0b0446 Mon Sep 17 00:00:00 2001 From: Bret Comnes Date: Sat, 29 Aug 2026 15:52:27 -0700 Subject: [PATCH 4/7] Fix generated page layout asset rebuilds --- index.js | 5 ++ plans/generated-pages.md | 29 +++++----- test-cases/generated-pages/index.test.js | 67 ++++++++++++++++++++++++ 3 files changed, 87 insertions(+), 14 deletions(-) diff --git a/index.js b/index.js index be59dfca..d3749f8f 100644 --- a/index.js +++ b/index.js @@ -388,6 +388,11 @@ ${siteData.errors.map(err => ` ${err.message}`).join('\n')}`) logRebuildTree(changedBasename, this.#logger, new Set(siteData.pages)) await this.#runPageBuild(siteData) } else if (layoutClientSuffixs.some(s => changedBasename.endsWith(s)) || changedBasename.endsWith(layoutStyleSuffix)) { + if ((siteData.pagesFiles?.length ?? 0) > 0) { + this.#logger.info(`"${changedBasename}" ${event}, rebuilding all pages...`) + return this.#runGeneratedPageBuild(siteData) + } + // Layout asset: rebuild pages using that layout const layoutName = Object.values(siteData.layouts).find(l => l.layoutClient?.filepath === changedPath || l.layoutStyle?.filepath === changedPath diff --git a/plans/generated-pages.md b/plans/generated-pages.md index 74446850..814be855 100644 --- a/plans/generated-pages.md +++ b/plans/generated-pages.md @@ -45,26 +45,27 @@ Implemented resolution: - Cleanup resolves each recorded path inside the destination and refuses to remove the destination itself. - Regression coverage includes a regular page removal plus generated output rename, definition removal, transition to `draft: true`, and deletion of the entire `*.pages.*` file. -#### 3. Medium: conflict detection does not cover templates or other output producers +#### 3. Resolved: conflict detection is intentionally limited to page output paths -The conflict map at `lib/build-pages/index.js:293-343` contains only concrete and generated pages. Pages and templates are then written concurrently at `lib/build-pages/index.js:549-584`. +The generated-pages design requires generated pages not to replace regular pages or other generated pages. The implementation meets that scope by checking concrete and generated page output paths before rendering. -A generated page and template targeting the same path can both succeed, with the final content depending on which asynchronous write wins. Manifest reconciliation may warn afterward when enabled, but the destination file has already been overwritten. +Templates can still target the same path as a regular or generated page. This is an existing whole-build limitation rather than behavior introduced by generated pages: regular pages and templates could already overwrite one another. Template output paths may also be chosen only after a template runs, while esbuild, static, and copied outputs are written by separate build steps. Manifest reconciliation cannot prevent these conflicts because it runs after files have been written. -The original plan calls page-to-page checks the minimum v1 scope, while the PR summary more broadly says that it detects output conflicts. Either: +Resolved for this PR by: -- Centralize output claims across pages and templates before writing, with a longer-term path to include esbuild, static, and copied outputs; or -- Narrow the public wording to "page-output conflicts" and track generalized output conflict handling separately. +- Narrowing the PR summary to say it detects conflicts between generated pages and regular or other generated pages. +- Keeping the generated-page checks aligned with the original minimum v1 scope in the “Conflict detection” section below. +- Tracking shared conflict detection across templates and other build steps separately in [issue #288](https://github.com/bcomnes/domstack/issues/288). -At minimum, duplicate emitted output records should fail the build rather than silently accept last-writer-wins behavior. +A duplicate-record check after the build would be too late to prevent an overwrite, so this PR does not add a partial post-write check. -#### 4. Medium: adding or removing a layout asset leaves generated HTML stale in watch mode +#### 4. Resolved: layout asset additions rebuild generated HTML in watch mode -For layout CSS or client add/unlink events, `index.js:386-399` builds a filter from `#layoutPageMap`. That map contains only concrete `siteData.pages`, so the filter at `lib/build-pages/index.js:537-540` omits generated pages. +For layout CSS or client add events, the watch handler built a filter from `#layoutPageMap`. That map contains only concrete `siteData.pages`, so generated pages were omitted when a concrete and generated page shared the affected layout. The new asset was built, but generated HTML did not gain its `` or `').join('') + + '' + children + '' +} +` + /** * @param {unknown} error * @returns {string} @@ -346,6 +354,65 @@ test.describe('generated pages', () => { }) }) + test('rebuilds generated pages when layout assets are added or removed in watch mode', { timeout: 25_000 }, async () => { + await withTempFixture({ + 'root.layout.js': assetAwareRootLayout, + 'global.vars.js': minimalGlobalVars, + 'page.js': "export default () => 'Regular page'\n", + 'layout-assets.pages.js': `export default { + outputName: 'generated/index.html', + children: 'Generated page', +} +`, + }, async ({ src, dest }) => { + const domstack = new DomStack(src, dest) + const regularOutputPath = join(dest, 'index.html') + const generatedOutputPath = join(dest, 'generated/index.html') + + const waitForRebuild = async () => { + await new Promise(resolve => setTimeout(resolve, 800)) + await domstack.settled() + } + + /** + * @param {string} assetName + * @param {boolean} expected + */ + const assertAssetReference = async (assetName, expected) => { + const [regularHtml, generatedHtml] = await Promise.all([ + readFile(regularOutputPath, 'utf8'), + readFile(generatedOutputPath, 'utf8'), + ]) + assert.equal(regularHtml.includes(assetName), expected, `regular page ${expected ? 'includes' : 'omits'} ${assetName}`) + assert.equal(generatedHtml.includes(assetName), expected, `generated page ${expected ? 'includes' : 'omits'} ${assetName}`) + } + + try { + await domstack.watch({ serve: false }) + await assertAssetReference('root.layout.css', false) + await assertAssetReference('root.layout.client.js', false) + + await writeFile(join(src, 'root.layout.css'), 'body { color: red }\n') + await waitForRebuild() + await assertAssetReference('root.layout.css', true) + + await rm(join(src, 'root.layout.css')) + await waitForRebuild() + await assertAssetReference('root.layout.css', false) + + await writeFile(join(src, 'root.layout.client.js'), 'globalThis.layoutClientLoaded = true\n') + await waitForRebuild() + await assertAssetReference('root.layout.client.js', true) + + await rm(join(src, 'root.layout.client.js')) + await waitForRebuild() + await assertAssetReference('root.layout.client.js', false) + } finally { + if (domstack.watching) await domstack.stopWatching() + } + }) + }) + test('removes obsolete regular and generated page outputs in watch mode', { timeout: 20_000 }, async () => { await withTempFixture({ 'root.layout.js': minimalRootLayout, From 732e941c46978347f6c63e76f15e7841d000731c Mon Sep 17 00:00:00 2001 From: Bret Comnes Date: Sat, 29 Aug 2026 17:20:40 -0700 Subject: [PATCH 5/7] Preserve generated page error context --- lib/build-pages/index.js | 115 +++++++++++++---------- lib/build-pages/resolve-vars.js | 6 +- lib/helpers/domstack-error.js | 2 +- plans/generated-pages.md | 22 ++--- test-cases/generated-pages/index.test.js | 97 +++++++++++++++++-- 5 files changed, 170 insertions(+), 72 deletions(-) diff --git a/lib/build-pages/index.js b/lib/build-pages/index.js index c4ee969b..e991fc79 100644 --- a/lib/build-pages/index.js +++ b/lib/build-pages/index.js @@ -60,8 +60,8 @@ const __dirname = import.meta.dirname * Uses arrays (not Sets) so the values can be copied to the worker. * * @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 {string[] | null | undefined} [pageFilterPaths] - If set, only rebuild pages whose pageFile.filepath is in this list. + * @property {string[] | null | undefined} [templateFilterPaths] - If set, only rebuild templates whose templateFile.filepath is in this list. * @property {boolean | undefined} [buildDrafts] - Include generated page definitions marked as drafts. */ @@ -128,9 +128,11 @@ const __dirname = import.meta.dirname /** * Error metadata sent back from the page build worker. * @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. + * @property {PageInfo | undefined} [page] - Page context for page var/rendering errors. + * @property {TemplateInfo | undefined} [template] - Template context for template rendering errors. + * @property {PagesFileInfo | undefined} [pagesFile] - Pages-file context for generated page resolution errors. + * @property {DomStackOutputConflictError['code'] | undefined} [code] - Stable generated-page conflict error code. + * @property {DomStackOutputConflictError['conflict'] | undefined} [conflict] - Generated-page conflict details. */ /** @@ -311,51 +313,57 @@ async function resolveGeneratedPageInfos ({ siteData, concretePages, globalVars, for (const pageInfo of siteData.pages) { pageOutputClaims.set(resolve(pageInfo.outputRelname), { type: 'page', - path: pageInfo.outputRelname, + path: pageInfo.pageFile.relname, }) } 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, - }, - } - ) + try { + 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) + const generatedClaim = { + type: /** @type {const} */ ('page'), + path: generatedPageInfo.pageFile.relname, + } + if (existingClaim) { + throw new DomStackOutputConflictError( + `Output path conflict: ${generatedPageInfo.outputRelname} is produced by both ${existingClaim.path} and ${generatedClaim.path}.`, + { + outputPath: generatedPageInfo.outputRelname, + a: existingClaim, + b: generatedClaim, + } + ) + } + + pageOutputClaims.set(outputKey, generatedClaim) + generatedPageInfos.push(generatedPageInfo) } - - pageOutputClaims.set(outputKey, { - type: 'page', - path: generatedPageInfo.outputRelname, - }) - generatedPageInfos.push(generatedPageInfo) + } catch (err) { + const error = err instanceof Error + ? err + : new Error('Non-error thrown while resolving generated pages', { cause: err }) + Object.assign(error, { pagesFile }) + throw error } } @@ -373,9 +381,9 @@ export function buildPages (src, dest, siteData, opts) { // neither of which can be structured-cloned. /** @type {BuildPagesFilterOptions} */ const workerOpts = { - ...(opts?.pageFilterPaths !== undefined ? { pageFilterPaths: opts.pageFilterPaths } : {}), - ...(opts?.templateFilterPaths !== undefined ? { templateFilterPaths: opts.templateFilterPaths } : {}), - ...(opts?.buildDrafts !== undefined ? { buildDrafts: opts.buildDrafts } : {}), + pageFilterPaths: opts?.pageFilterPaths, + templateFilterPaths: opts?.templateFilterPaths, + buildDrafts: opts?.buildDrafts, } return new Promise((resolve, reject) => { @@ -520,7 +528,16 @@ export async function buildPagesDirect (_src, dest, siteData, opts) { } 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 }) + generatedPagesError.name = err.name + const pagesFile = /** @type {PagesFileInfo | undefined} */ ('pagesFile' in err ? err.pagesFile : undefined) + const outputConflictError = err instanceof DomStackOutputConflictError ? err : undefined + /** @type {WorkerErrorData} */ + const errorData = { + pagesFile, + code: outputConflictError?.code, + conflict: outputConflictError?.conflict, + } + result.errors.push({ error: generatedPagesError, errorData }) } if (result.errors.length > 0) return result diff --git a/lib/build-pages/resolve-vars.js b/lib/build-pages/resolve-vars.js index 6ba037ab..b0f46986 100644 --- a/lib/build-pages/resolve-vars.js +++ b/lib/build-pages/resolve-vars.js @@ -25,7 +25,7 @@ export async function resolveVarsExport (maybeVars, errorLabel) { * Resolve variables by importing them from a specified path. * * @param {object} params - * @param {string} [params.varsPath] - Path to the file containing the variables. + * @param {string | undefined} [params.varsPath] - Path to the file containing the variables. * @param {string} [params.key='default'] - The key to extract from the imported module. Default: 'default' * @returns {Promise} - Returns the resolved variables. If the imported variable is a function, it executes and returns its result. Otherwise, it returns the variable directly. */ @@ -50,7 +50,7 @@ export async function resolveVars ({ * Returns an empty object if no file is provided or the file exports nothing useful. * * @param {object} params - * @param {string} [params.globalDataPath] - Path to the global.data file. + * @param {string | undefined} [params.globalDataPath] - Path to the global.data file. * @param {PageData[]} params.pages - Initialized PageData array. * @returns {Promise} */ @@ -75,7 +75,7 @@ export async function resolveGlobalData ({ globalDataPath, pages }) { * Resolve variables by importing them from a specified path. * * @param {object} params - * @param {string} [params.varsPath] - Path to the file containing the variables. + * @param {string | undefined} [params.varsPath] - Path to the file containing the variables. * @returns {Promise} */ export async function resolvePostVars ({ diff --git a/lib/helpers/domstack-error.js b/lib/helpers/domstack-error.js index 550803c3..a8086f22 100644 --- a/lib/helpers/domstack-error.js +++ b/lib/helpers/domstack-error.js @@ -83,7 +83,7 @@ export class DomStackOutputConflictError extends Error { } /** - * @returns {DomStackErrorCode} + * @returns {'DOM_STACK_ERROR_OUTPUT_CONFLICT'} */ get code () { return 'DOM_STACK_ERROR_OUTPUT_CONFLICT' diff --git a/plans/generated-pages.md b/plans/generated-pages.md index 814be855..658494de 100644 --- a/plans/generated-pages.md +++ b/plans/generated-pages.md @@ -67,19 +67,19 @@ Removal already reached the fallback full rebuild because the removed asset was Regression coverage adds and removes both layout CSS and layout clients while a regular and generated page share the layout, and checks that both HTML outputs add and remove the asset references. -#### 5. Medium: generated-page errors lose their type and source context +#### 5. Resolved: generated-page setup errors keep their type and source context -`DomStackOutputConflictError` defines a useful code and structured conflict metadata at `lib/helpers/domstack-error.js:71-90`, but `lib/build-pages/index.js:505-508` immediately wraps it in a generic `Error`. +Errors raised while importing or running a `*.pages.*` file, validating its definitions, or checking its output paths were caught only after the complete generated-pages resolution step. The responsible pages file was no longer known, and wrapping the error for worker transfer removed the output-conflict code and details. -Caller-visible errors consequently lose: - -- `DOM_STACK_ERROR_OUTPUT_CONFLICT` -- The `conflict` object -- The pages file that produced an invalid definition or path - -`WorkerErrorData.pagesFile` is declared at `lib/build-pages/index.js:128-134` but is never populated. +Implemented resolution: -Catch resolution errors per pages file and transfer a small serializable context object. Tests should assert error codes, source filenames, and both conflict producers rather than only matching message text. +- Generated-page resolution remembers the pages file currently being processed and attaches it to failures before they leave the resolver. +- The worker continues to return a safe plain `Error`, so non-copyable values in a user error's `cause` cannot replace the useful failure with a worker-copy error. +- The existing `errorData` object now carries `pagesFile` plus the output-conflict code and details when applicable. +- The main thread's existing error restoration adds the pages-file name to the message and exposes `pagesFile`, `code`, and `conflict` on the caller-visible error. +- Built-in error names such as `TypeError` are retained. +- Conflict details now identify concrete page source files and generated definitions by `#`, while `conflict.outputPath` separately identifies the duplicated output. +- Regression tests cover a pages function throwing with a non-copyable cause, invalid definitions and paths, generated-to-concrete conflicts, and generated-to-generated conflicts. #### 6. Design gap: generated pages are not added to public `siteData.pages` @@ -133,7 +133,7 @@ PR #253 says it closes issue #237. The generalized page-factory mechanism satisf - [x] Validate the README index example with a worker-boundary regression test. - [x] Reconcile and remove obsolete regular and generated page outputs in watch mode. - [x] Rebuild generated pages when layout assets are added or removed. -- [ ] Preserve output-conflict codes, metadata, and pages-file context. +- [x] Preserve output-conflict codes, metadata, and pages-file context. - [x] Define conflict detection as generated-to-regular and generated-to-generated page checks; track whole-build conflicts in issue #288. - [ ] Decide whether returned `siteData.pages` is concrete-only or expanded. - [ ] Finalize generated-pages type names and generics. diff --git a/test-cases/generated-pages/index.test.js b/test-cases/generated-pages/index.test.js index 699a2312..782bd126 100644 --- a/test-cases/generated-pages/index.test.js +++ b/test-cases/generated-pages/index.test.js @@ -70,12 +70,21 @@ const assetAwareRootLayout = `export default function rootLayout ({ styles = [], /** * @param {unknown} error - * @returns {string} + * @returns {Error & { + * code?: string, + * conflict?: { + * outputPath: string, + * a: { type: string, path: string }, + * b: { type: string, path: string } + * }, + * pagesFile?: { pagesFile: { relname: 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) +function firstGeneratedPagesError (error) { + if (!(error instanceof AggregateError)) throw new TypeError('Expected an AggregateError') + const generatedError = error.errors[0] + if (!(generatedError instanceof Error)) throw new TypeError('Expected a generated-pages Error') + return generatedError } test.describe('generated pages', () => { @@ -189,6 +198,32 @@ test.describe('generated pages', () => { }) }) + test('returns pages-file context when a generated-pages function throws', async () => { + await withTempFixture({ + 'root.layout.js': minimalRootLayout, + 'global.vars.js': minimalGlobalVars, + 'broken.pages.js': `export default function () { + throw new Error('pages factory boom', { cause: () => {} }) +} +`, + }, async ({ src, dest }) => { + const domstack = new DomStack(src, dest) + await assert.rejects( + () => domstack.build(), + error => { + const generatedError = firstGeneratedPagesError(error) + + assert.match(generatedError.message, /pages factory boom/) + assert.match(generatedError.message, /pages file: "broken\.pages\.js"/) + assert.equal(generatedError.pagesFile?.pagesFile.relname, 'broken.pages.js') + assert.notEqual(generatedError.name, 'DataCloneError') + assert.equal(/** @type {{ message?: string } | undefined} */ (generatedError.cause)?.message, 'pages factory boom') + return true + } + ) + }) + }) + test('includes generated pages in the domstack manifest as page entries', async () => { await withTempFixture({ 'root.layout.js': minimalRootLayout, @@ -281,7 +316,44 @@ test.describe('generated pages', () => { await assert.rejects( () => domstack.build(), error => { - assert.match(aggregateErrorMessage(error), /Output path conflict/) + const generatedError = firstGeneratedPagesError(error) + + assert.match(generatedError.message, /Output path conflict/) + assert.match(generatedError.message, /pages file: "conflict\.pages\.js"/) + assert.equal(generatedError.code, 'DOM_STACK_ERROR_OUTPUT_CONFLICT') + assert.deepEqual(generatedError.conflict, { + outputPath: 'index.html', + a: { type: 'page', path: 'README.md' }, + b: { type: 'page', path: 'conflict.pages.js#0' }, + }) + assert.equal(generatedError.pagesFile?.pagesFile.relname, 'conflict.pages.js') + return true + } + ) + }) + }) + + test('throws a conflict error with both generated page sources', async () => { + await withTempFixture({ + 'root.layout.js': minimalRootLayout, + 'global.vars.js': minimalGlobalVars, + 'first.pages.js': "export default { outputName: 'shared/index.html' }\n", + 'second.pages.js': "export default { outputName: 'shared/index.html' }\n", + }, async ({ src, dest }) => { + const domstack = new DomStack(src, dest) + await assert.rejects( + () => domstack.build(), + error => { + const generatedError = firstGeneratedPagesError(error) + const conflictingSources = [ + generatedError.conflict?.a.path, + generatedError.conflict?.b.path, + ].sort() + + assert.equal(generatedError.code, 'DOM_STACK_ERROR_OUTPUT_CONFLICT') + assert.equal(generatedError.conflict?.outputPath, 'shared/index.html') + assert.deepEqual(conflictingSources, ['first.pages.js#0', 'second.pages.js#0']) + assert.equal(`${generatedError.pagesFile?.pagesFile.relname}#0`, generatedError.conflict?.b.path) return true } ) @@ -298,7 +370,12 @@ test.describe('generated pages', () => { await assert.rejects( () => domstack.build(), error => { - assert.match(aggregateErrorMessage(error), /Generated page definition must be an object/) + const generatedError = firstGeneratedPagesError(error) + + assert.match(generatedError.message, /Generated page definition must be an object/) + assert.match(generatedError.message, /pages file: "invalid\.pages\.js"/) + assert.equal(generatedError.name, 'TypeError') + assert.equal(generatedError.pagesFile?.pagesFile.relname, 'invalid.pages.js') return true } ) @@ -318,7 +395,11 @@ test.describe('generated pages', () => { await assert.rejects( () => domstack.build(), error => { - assert.match(aggregateErrorMessage(error), /must not contain "\.\." segments/) + const generatedError = firstGeneratedPagesError(error) + + assert.match(generatedError.message, /must not contain "\.\." segments/) + assert.match(generatedError.message, /pages file: "invalid\.pages\.js"/) + assert.equal(generatedError.pagesFile?.pagesFile.relname, 'invalid.pages.js') return true } ) From 998dba711f57e90089ab1b2f7c5cee44638344bf Mon Sep 17 00:00:00 2001 From: Bret Comnes Date: Sat, 29 Aug 2026 17:46:07 -0700 Subject: [PATCH 6/7] Document generated page discovery model --- README.md | 2 ++ index.js | 2 +- lib/builder.js | 7 ++++++- lib/identify-pages.js | 1 + plans/generated-pages.md | 23 +++++++++++++---------- test-cases/generated-pages/index.test.js | 2 ++ 6 files changed, 25 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index c8f5518e..ad29e679 100644 --- a/README.md +++ b/README.md @@ -1100,6 +1100,8 @@ Files named `*.pages.js` (or `*.pages.ts` when TypeScript loading is available) 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. +`siteData` remains the discovery result from `identifyPages()`, both in a pages factory and in the public `results.siteData` returned by a build. Its `siteData.pages` array contains only source-backed pages. Generated pages are created later inside the page worker and are not added to this public discovery list. + ```js // src/blog-indexes.pages.js import { html } from 'fragtml' diff --git a/index.js b/index.js index d3749f8f..69b1d0f3 100644 --- a/index.js +++ b/index.js @@ -943,7 +943,7 @@ function buildLogger (results, logger, dest) { if ('siteData' in results && results.siteData) { // Full build: show site totals const layoutCount = Object.keys(results.siteData.layouts).length - logger.info(`Pages: ${results.siteData.pages.length} Layouts: ${layoutCount} Templates: ${results.siteData.templates.length}`) + logger.info(`Source pages: ${results.siteData.pages.length} Layouts: ${layoutCount} Templates: ${results.siteData.templates.length}`) const outputs = results.pageBuildResults?.outputs if (outputs) { const summary = summarizePageDomstackManifests(outputs) diff --git a/lib/builder.js b/lib/builder.js index 37b85439..a60be66d 100644 --- a/lib/builder.js +++ b/lib/builder.js @@ -72,7 +72,12 @@ import { */ /** - * The data generated about the site generate dby identifyPages + * Site discovery data returned by identifyPages(). + * + * `pages` contains source-backed pages discovered from the source tree. Generated + * pages are created later in the page worker and are not added to this discovery + * result. + * * @typedef {Awaited>} SiteData */ diff --git a/lib/identify-pages.js b/lib/identify-pages.js index e5a7a3f1..420ecb65 100644 --- a/lib/identify-pages.js +++ b/lib/identify-pages.js @@ -625,6 +625,7 @@ export async function identifyPages (src, opts = {}) { layouts, templates, pagesFiles, + /** Source-backed pages discovered from the source tree. */ pages, warnings, errors, diff --git a/plans/generated-pages.md b/plans/generated-pages.md index 658494de..9a7e2b6b 100644 --- a/plans/generated-pages.md +++ b/plans/generated-pages.md @@ -81,18 +81,21 @@ Implemented resolution: - Conflict details now identify concrete page source files and generated definitions by `#`, while `conflict.outputPath` separately identifies the duplicated output. - Regression tests cover a pages function throwing with a non-copyable cause, invalid definitions and paths, generated-to-concrete conflicts, and generated-to-generated conflicts. -#### 6. Design gap: generated pages are not added to public `siteData.pages` +#### 6. Resolved: public `siteData.pages` is intentionally discovery-only -Generated pages exist only in the worker-local array at `lib/build-pages/index.js:513-514`. The returned `results.siteData.pages` remains the concrete discovery list from `lib/identify-pages.js:612-628`. +Generated pages are downstream of regular page discovery and initialization. Every `*.pages.*` factory receives the same source-backed `PageData[]`; factories do not receive pages produced by earlier pages files. This avoids making generated output depend on pages-file processing order or creating circular page-generation dependencies. -This differs from the expanded-site model later in this plan and means: +The public `siteData` object remains the result of `identifyPages()`. Its `pages` array therefore contains only source-backed pages discovered from the source tree. Generated pages are created later inside the page worker, then combined with regular pages for `global.data.*`, templates, page functions, layouts, and rendering. They are not added back to the public discovery object. -- Programmatic `results.siteData.pages` is concrete-only. -- Watch maps remain concrete-only and require broad generated-page special cases. -- The initial `Pages:` build total excludes generated pages, although `Pages built:` includes them. -- No generated `PageInfo` graph is available to programmatic callers after the build. +This keeps one clear `SiteData` meaning rather than introducing separate concrete and expanded variants. It also avoids transferring complete generated `PageInfo` objects from the worker when their definitions can contain functions or other values that cannot be copied between threads. -Either implement an explicit `expandedSiteData`/`concretePages` model or deliberately define and document `siteData.pages` as discovery-only. Add a test that locks in the chosen public behavior. +Implemented resolution: + +- Documented the discovery-only meaning on the public `SiteData` type and in the Generated Pages README section. +- Clarified that the `siteData` factory parameter and returned `results.siteData` follow the same rule. +- Renamed the initial build summary count from `Pages:` to `Source pages:`; `Pages built:` continues to include regular and generated pages. +- Added regression coverage confirming returned `results.siteData.pages` contains only the five source-backed fixture pages while generated pages are still built and exposed to downstream render steps. +- Kept watch maps source-backed. Generated-page sites intentionally use full page rebuilds for changes that can alter arbitrary generated outputs. #### 7. Documentation and public types need another pass @@ -123,7 +126,7 @@ The implementation achieves the core design in a clean one-shot build: - Detects generated-to-concrete and generated-to-generated page conflicts. - Rebuilds generated pages for normal pages-file and imported-dependency changes. -The objective is still only partially complete in watch mode and in the public data model. The documented programmatic index now builds successfully: its `PageData[]` remains available while rendering and is left out of the page-vars snapshot returned from the worker. +The generated-page build, watch behavior, and public site-data model are now intentional and covered by regression tests. The documented programmatic index builds successfully: its `PageData[]` remains available while rendering and is left out of the page-vars snapshot returned from the worker. PR #253 says it closes issue #237. The generalized page-factory mechanism satisfies the later PR discussion, but the original issue also asks for native redirect declarations and target-existence validation. This implementation does not validate redirect destinations or natively emit hosting-provider redirect configuration. Either explicitly accept this generalized API as the resolution of #237 or leave the issue open for those remaining capabilities. @@ -135,7 +138,7 @@ PR #253 says it closes issue #237. The generalized page-factory mechanism satisf - [x] Rebuild generated pages when layout assets are added or removed. - [x] Preserve output-conflict codes, metadata, and pages-file context. - [x] Define conflict detection as generated-to-regular and generated-to-generated page checks; track whole-build conflicts in issue #288. -- [ ] Decide whether returned `siteData.pages` is concrete-only or expanded. +- [x] Define returned `siteData.pages` as source-backed discovery data. - [ ] Finalize generated-pages type names and generics. - [ ] Complete the README API and type documentation. - [ ] Decide whether this generalized feature fully closes issue #237. diff --git a/test-cases/generated-pages/index.test.js b/test-cases/generated-pages/index.test.js index 782bd126..bf8e7d7e 100644 --- a/test-cases/generated-pages/index.test.js +++ b/test-cases/generated-pages/index.test.js @@ -98,6 +98,8 @@ test.describe('generated pages', () => { }) assert.equal(results.siteData.pagesFiles.length, 4, 'four pages files are discovered') + assert.equal(results.siteData.pages.length, 5, 'siteData.pages contains the five source-backed pages') + assert.equal(results.siteData.pages.some(page => Boolean(page.generated)), false, 'siteData.pages remains discovery-only') const redirectCases = [ { from: 'old-url', to: '/new-url/', destination: 'new-url/index.html', heading: 'New URL' }, From 6011b3744e15f5777f76e17fa2022c4a8074eea5 Mon Sep 17 00:00:00 2001 From: Bret Comnes Date: Sat, 29 Aug 2026 17:57:37 -0700 Subject: [PATCH 7/7] Document generated pages API --- README.md | 136 +++++++++++++++--- lib/build-pages/index.js | 31 ++-- plans/generated-pages.md | 39 ++--- test-cases/generated-pages/index.test.js | 29 ++++ test-cases/generated-pages/src/async.pages.js | 3 + .../generated-pages/src/indexes.pages.js | 20 ++- types.ts | 1 - 7 files changed, 202 insertions(+), 57 deletions(-) diff --git a/README.md b/README.md index ad29e679..6df41d9e 100644 --- a/README.md +++ b/README.md @@ -1094,34 +1094,129 @@ await pMap(allPosts, async (page) => { const html = renderCache.get(page.pageInfo.path) ?? '' ``` -### Generated Pages +## 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 files create real DomStack pages from one central module. They are similar to templates, but layout-driven: each generated definition supplies page vars and children, then DomStack renders it through the normal page and 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. +Supported filenames are: -`siteData` remains the discovery result from `identifyPages()`, both in a pages factory and in the public `results.siteData` returned by a build. Its `siteData.pages` array contains only source-backed pages. Generated pages are created later inside the page worker and are not added to this public discovery list. +- `*.pages.js`, `*.pages.mjs`, and `*.pages.cjs` +- `*.pages.ts`, `*.pages.mts`, and `*.pages.cts` when the current Node.js runtime supports TypeScript loading -```js -// src/blog-indexes.pages.js +### Generated-pages exports + +A generated-pages module can default export: + +| Export | Use when | +|---|---| +| One `GeneratedPageDefinition` object | The module always creates one page | +| An array of definitions | The module always creates a fixed set of pages and needs no build context | +| A normal or `async` function | Definitions depend on source pages, global vars, or other discovery data | +| An async iterable, usually returned by `async function*` | Pages are discovered incrementally or the total is not known in advance | + +Static objects and arrays do not receive factory parameters: + +```ts +// src/legal.pages.ts +import type { GeneratedPageDefinition } from '@domstack/static/types.js' + +export default [ + { + outputName: 'terms/index.html', + vars: { layout: 'legal', title: 'Terms' }, + children: 'Terms of service', + }, + { + outputName: 'privacy/index.html', + vars: { layout: 'legal', title: 'Privacy' }, + children: 'Privacy policy', + }, +] satisfies GeneratedPageDefinition[] +``` + +For one static page, export a single object with the same shape instead of an array. + +Use `PagesFunction` for normal functions, `async` functions, and async generators. Its type parameters are the generated page vars, the generated children type, and the default/global vars received by the factory: + +```ts +// src/blog-indexes.pages.ts import { html } from 'fragtml' +import type { HtmlResult } from 'fragtml/types.js' +import type { PageData, PagesFunction } from '@domstack/static/types.js' + +type SiteVars = { + siteName: string +} + +type BlogIndexVars = { + layout: string + title: string + posts: PageData>[] +} -export default function ({ pages }) { +const blogIndexes: PagesFunction = ({ pages, vars }) => { 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', + title: `${vars.siteName} blog`, posts, }, children: ({ vars }) => html`

${vars.title}

${vars.posts.length} posts

`, } } + +export default blogIndexes ``` -`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. +The same type describes an async generator without requiring a separate function type: + +```ts +import type { PagesFunction } from '@domstack/static/types.js' + +const archivePages: PagesFunction = async function * ({ pages }) { + const years = new Set(pages.flatMap(page => { + return page.vars.publishDate ? [new Date(page.vars.publishDate).getFullYear()] : [] + })) + + for (const year of years) { + yield { + outputName: `blog/${year}/index.html`, + vars: { layout: 'archive', year }, + } + } +} + +export default archivePages +``` + +### Generated-pages factory parameters + +Functions receive one object with: + +| Parameter | Contents | +|---|---| +| `pages` | Initialized source-backed `PageData[]`. Generated pages from this or other pages files are not included. | +| `vars` | Default and global vars, before values returned by `global.data.*` are added. | +| `pagesFile` | Information about the current file. `name` is the filename without its `.pages.*` suffix, `path` is its source-relative directory, and `pagesFile` contains the underlying file information. | +| `siteData` | Discovery data returned by `identifyPages()`. Its `siteData.pages` array is also source-backed only. | + +Every pages file receives the same source-backed page list, so generated output does not depend on pages-file processing order. After all definitions are collected, generated pages join the full `pages` array passed to `global.data.*`, templates, page functions, and layouts. + +The public `results.siteData` returned by a build remains discovery data. Generated pages are created later inside the page worker and are not added to `results.siteData.pages`. + +### Generated page definitions + +| Field | Behavior | +|---|---| +| `outputName` | Output path relative to the pages file's directory. It must not be absolute or contain `..` segments. Defaults to `/index.html`. | +| `vars` | Page-level vars merged with the normal default, global, layout, and builder vars. | +| `children` | Static child content or an inline `PageFunction` rendered before the layout. | +| `draft` | When `true`, the page is omitted unless the CLI uses `--drafts` or a programmatic build uses `buildDrafts: true`. | + +Generated pages use global and layout assets. They do not have page-local `style.css`, `client.js`, or worker entries because they do not have their own source-page directory. ### Redirect Pages @@ -1712,7 +1807,7 @@ Use this to filter the domstack manifest before hooks receive it, before domstac ```js /** - * @import { DomstackManifestEntry } from '@domstack/static' + * @import { DomstackManifestEntry } from '@domstack/static/types.js' */ export default { @@ -1971,22 +2066,26 @@ import type { AsyncPageFunction, TemplateFunction, TemplateAsyncIterator, + PagesFunction, // Data/param types PageData, PageInfo, TemplateInfo, + PagesFileInfo, + GeneratedPageDefinition, LayoutFunctionParams, GlobalDataFunctionParams, PageFunctionParams, TemplateFunctionParams, + PagesFunctionParams, } from '@domstack/static/types.js' ``` -> **Note:** All function types have both synchronous and asynchronous variants (e.g., `LayoutFunction` and `AsyncLayoutFunction`). Use the async variants when your function is an `async` function. +> **Note:** Page, layout, and global-data functions have synchronous and asynchronous variants. `PagesFunction` covers normal functions, `async` functions, and async generators because generated-pages factories can return definitions, promises, or async iterables. -They are all generic and accept a variable template that you can develop and share between files. +The function types are generic and accept variable shapes that you can develop and share between files. -The data and param types (`PageData`, `PageInfo`, `TemplateInfo`, `*FunctionParams`) are useful when you want to annotate variables or helper functions that receive these objects without using the function types directly: +The data and parameter types (`PageData`, `PageInfo`, `TemplateInfo`, `PagesFileInfo`, `GeneratedPageDefinition`, and `*FunctionParams`) are useful when you want to annotate variables or helper functions that receive these objects without using the function types directly: ```ts import type { GlobalDataFunctionParams, PageData, PageInfo } from '@domstack/static/types.js' @@ -1999,9 +2098,9 @@ function getPublishedPages({ pages }: GlobalDataFunctionParams): PageData[] { } ``` -#### Advanced Type Parameters for PageFunction and LayoutFunction +#### Advanced type parameters -`PageFunction` and `LayoutFunction` support additional template parameters for precise return type control: +`PageFunction`, `LayoutFunction`, and `PagesFunction` support additional type parameters for precise input and return type control: **PageFunction** - `T` - The type of variables passed to the page (required) @@ -2012,7 +2111,12 @@ function getPublishedPages({ pages }: GlobalDataFunctionParams): PageData[] { - `U` - The type of content received from pages as `children` (optional, defaults to `any`) - `V` - The return type of the layout function (optional, defaults to `string`) -This allows pages to return custom types (like VDOM or JSON) while ensuring layouts produce HTML strings: +**PagesFunction** +- `T` - The vars added to generated pages (optional, defaults to `Record`) +- `U` - The static children or inline page-function return type (optional, defaults to `any`) +- `V` - The default and global vars received by the pages factory (optional, defaults to `Record`) + +This allows pages to return custom types (like VDOM or JSON), ensures layouts produce HTML strings, and keeps generated-page vars separate from the vars used to create them: ```ts // Define custom types diff --git a/lib/build-pages/index.js b/lib/build-pages/index.js index e991fc79..93f6c8ec 100644 --- a/lib/build-pages/index.js +++ b/lib/build-pages/index.js @@ -74,18 +74,19 @@ const __dirname = import.meta.dirname /** * Parameters passed to a *.pages.* default export function. * + * @template {Record} [T=Record] - Default and global vars available to the factory. * @typedef {object} PagesFunctionParams - * @property {PageData[]} pages - Initialized concrete/source-backed pages only. - * @property {Record} vars - Default and global vars, before global.data.* output. + * @property {PageData[]} pages - Initialized source-backed pages only, before generated pages are created. + * @property {T} 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(). + * @property {SiteData} siteData - Discovery data from identifyPages(); siteData.pages is source-backed only. */ /** * Definition for one page produced by a *.pages.* file. * - * @template {Record} [T=Record] - * @template [U=any] + * @template {Record} [T=Record] - Vars added to the generated page. + * @template [U=any] - Static children or the return type of the inline page function. * @typedef {object} GeneratedPageDefinition * @property {string} [outputName] - Relative output filename, defaulting to `/index.html`. * @property {T} [vars] - Page vars to merge through the normal page/layout pipeline. @@ -94,25 +95,17 @@ const __dirname = import.meta.dirname */ /** - * Synchronous generated-pages function. + * A generated-pages factory. The same type covers normal functions, async + * functions, and async generators. * - * @template {Record} [T=Record] - * @template [U=any] + * @template {Record} [T=Record] - Vars added to each generated page. + * @template [U=any] - Static children or the return type of each inline page function. + * @template {Record} [V=Record] - Default and global vars available to the factory. * @callback PagesFunction - * @param {PagesFunctionParams} params + * @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', diff --git a/plans/generated-pages.md b/plans/generated-pages.md index 9a7e2b6b..7e430922 100644 --- a/plans/generated-pages.md +++ b/plans/generated-pages.md @@ -97,21 +97,27 @@ Implemented resolution: - Added regression coverage confirming returned `results.siteData.pages` contains only the five source-backed fixture pages while generated pages are still built and exposed to downstream render steps. - Kept watch maps source-backed. Generated-page sites intentionally use full page rebuilds for changes that can alter arbitrary generated outputs. -#### 7. Documentation and public types need another pass +#### 7. Resolved: generated-pages documentation and public types are complete -In addition to fixing the broken primary example: +The Generated Pages documentation is now a top-level README section rather than part of Templates. It documents: -- Promote Generated Pages from a Templates subsection to its own top-level feature section. -- Document static object and array exports, async functions, async iterables, and all supported module suffixes. -- Document the `vars`, `pagesFile`, and `siteData` factory parameters. -- Document generated `draft: true` behavior and its relationship to `--drafts`/`buildDrafts`. -- Add `PagesFunction`, `AsyncPagesFunction`, `PagesFunctionParams`, `GeneratedPageDefinition`, and `PagesFileInfo` to the README type catalog. -- Import public types from `@domstack/static/types.js`, not the package root. -- Correct the `GeneratedPageDefinition.outputName` JSDoc to say it defaults to `/index.html`. -- Revisit `AsyncPagesFunction`: its required `Promise` return cannot annotate the supported `async function*` form in `test-cases/generated-pages/src/async.pages.js`. A `PagesAsyncIterator` type aligned with `TemplateAsyncIterator` would be clearer. -- Consider making `PagesFunctionParams` generic so incoming global vars can be typed separately from generated page vars before the public API is frozen. +- All supported `.pages.js`, `.pages.mjs`, `.pages.cjs`, `.pages.ts`, `.pages.mts`, and `.pages.cts` filenames, including the Node.js TypeScript-loading requirement. +- Static object and array exports, normal and async factory functions, and async iterables. +- The `pages`, `vars`, `pagesFile`, and `siteData` factory parameters and when generated pages join the downstream `PageData[]`. +- Every generated definition field, output path rules, asset behavior, and `draft: true` with `--drafts` or `buildDrafts: true`. +- `PagesFunction`, `PagesFunctionParams`, `GeneratedPageDefinition`, and `PagesFileInfo` in the public type catalog. +- Public type imports from `@domstack/static/types.js` rather than the runtime package entry. -The redirect security warning and meta-refresh SEO guidance are accurate. +The public types were simplified before release: + +- `PagesFunction` now explicitly covers normal functions, async functions, and async generators. Its existing return union already describes direct definitions, promises, and async iterables. +- The overlapping `AsyncPagesFunction` was removed instead of adding another `PagesAsyncIterator` type. One factory type accurately describes every supported function form with fewer nearly identical names. +- `PagesFunctionParams` is generic, and `PagesFunction` has a separate third generic for the default/global vars received by the factory. Generated-page vars and factory input vars can therefore be typed independently. +- `GeneratedPageDefinition.outputName` documents its `/index.html` default. +- Type-checked fixtures cover an async generator and separately typed generated/factory vars. +- Runtime coverage confirms static object, static array, and async function exports. + +The redirect security warning and meta-refresh SEO guidance remain accurate. ### Objective assessment @@ -139,8 +145,8 @@ PR #253 says it closes issue #237. The generalized page-factory mechanism satisf - [x] Preserve output-conflict codes, metadata, and pages-file context. - [x] Define conflict detection as generated-to-regular and generated-to-generated page checks; track whole-build conflicts in issue #288. - [x] Define returned `siteData.pages` as source-backed discovery data. -- [ ] Finalize generated-pages type names and generics. -- [ ] Complete the README API and type documentation. +- [x] Finalize generated-pages type names and generics. +- [x] Complete the README API and type documentation. - [ ] Decide whether this generalized feature fully closes issue #237. ### Validation performed during review @@ -425,10 +431,9 @@ This avoids the current broad special case of “if any pages files exist, layou ## Public types -Export from `index.js`: +Export from the dedicated `types.js` type entry: -- `PagesFunction` -- `AsyncPagesFunction` +- `PagesFunction` for normal, async, and async-generator factories - `PagesFunctionParams` - `GeneratedPageDefinition` - `PagesFileInfo` diff --git a/test-cases/generated-pages/index.test.js b/test-cases/generated-pages/index.test.js index bf8e7d7e..226ba19d 100644 --- a/test-cases/generated-pages/index.test.js +++ b/test-cases/generated-pages/index.test.js @@ -141,6 +141,35 @@ test.describe('generated pages', () => { assert.equal(summary.generatedPagesInTemplate, 6, 'template pages include generated pages') }) + test('supports static object, static array, and async function exports', async () => { + await withTempFixture({ + 'root.layout.js': minimalRootLayout, + 'global.vars.js': minimalGlobalVars, + 'single.pages.js': `export default { + outputName: 'single/index.html', + children: '

single static page

', +} +`, + 'multiple.pages.js': `export default [ + { outputName: 'multiple/one.html', children: '

first static page

' }, + { outputName: 'multiple/two.html', children: '

second static page

' }, +] +`, + 'async.pages.js': `export default async function () { + return { outputName: 'async/index.html', children: '

async function page

' } +} +`, + }, async ({ src, dest }) => { + const domstack = new DomStack(src, dest) + await domstack.build() + + assert.match(await readFile(join(dest, 'single/index.html'), 'utf8'), /single static page/) + assert.match(await readFile(join(dest, 'multiple/one.html'), 'utf8'), /first static page/) + assert.match(await readFile(join(dest, 'multiple/two.html'), 'utf8'), /second static page/) + assert.match(await readFile(join(dest, 'async/index.html'), 'utf8'), /async function page/) + }) + }) + test('returns copyable generated vars and keeps PageData values inside the worker', async () => { await withTempFixture({ 'root.layout.js': minimalRootLayout, diff --git a/test-cases/generated-pages/src/async.pages.js b/test-cases/generated-pages/src/async.pages.js index bcccc182..28b14087 100644 --- a/test-cases/generated-pages/src/async.pages.js +++ b/test-cases/generated-pages/src/async.pages.js @@ -1,3 +1,6 @@ +/** @import { PagesFunction } from '#types' */ + +/** @type {PagesFunction} */ export default async function * asyncPages () { yield { outputName: 'async-generated/index.html', diff --git a/test-cases/generated-pages/src/indexes.pages.js b/test-cases/generated-pages/src/indexes.pages.js index 6b4c7711..e56a9882 100644 --- a/test-cases/generated-pages/src/indexes.pages.js +++ b/test-cases/generated-pages/src/indexes.pages.js @@ -5,21 +5,33 @@ import { html } from 'fragtml' -/** @type {PageFunction<{ title: string, postCount: number }, HtmlResult>} */ +/** + * @typedef {object} IndexVars + * @property {string} layout + * @property {string} title + * @property {number} postCount + */ + +/** + * @typedef {object} SiteVars + * @property {string} siteName + */ + +/** @type {PageFunction} */ const renderIndexPage = ({ vars }) => html`

${vars.title}

${vars.postCount}

` -/** @type {PagesFunction} */ -export default function indexesPages ({ pages }) { +/** @type {PagesFunction} */ +export default function indexesPages ({ pages, vars }) { 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', + title: `${vars.siteName}: 2024 posts`, postCount: posts.length, }, children: renderIndexPage, diff --git a/types.ts b/types.ts index 56b0700a..c8985a66 100644 --- a/types.ts +++ b/types.ts @@ -7,7 +7,6 @@ export type { BuildOptions } from 'esbuild' export type { DomStackOpts, Results, SiteData } from './lib/builder.js' export type { AsyncGlobalDataFunction, - AsyncPagesFunction, GeneratedPageDefinition, GlobalDataFunction, GlobalDataFunctionParams,