diff --git a/README.md b/README.md index 1c432793..6df41d9e 100644 --- a/README.md +++ b/README.md @@ -1094,6 +1094,198 @@ await pMap(allPosts, async (page) => { const html = renderCache.get(page.pageInfo.path) ?? '' ``` +## Generated Pages + +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. + +Supported filenames are: + +- `*.pages.js`, `*.pages.mjs`, and `*.pages.cjs` +- `*.pages.ts`, `*.pages.mts`, and `*.pages.cts` when the current Node.js runtime supports TypeScript loading + +### 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>[] +} + +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: `${vars.siteName} blog`, + posts, + }, + children: ({ vars }) => html`

${vars.title}

${vars.posts.length} posts

`, + } +} + +export default blogIndexes +``` + +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 + +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] @@ -1294,6 +1486,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 @@ -1613,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 { @@ -1872,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' @@ -1900,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) @@ -1913,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/index.js b/index.js index d9345137..69b1d0f3 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' @@ -25,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' @@ -35,6 +35,7 @@ import { layoutSuffixs, layoutStyleSuffix, templateSuffixs, + pagesSuffixs, globalVarsNames, globalDataNames, esbuildSettingsNames, @@ -99,8 +100,12 @@ 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() + /** @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} */ @@ -199,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) { @@ -382,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 @@ -441,6 +452,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, @@ -452,6 +464,41 @@ ${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 + * 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 +525,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 +609,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 +646,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 +703,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 +724,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 +745,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 +771,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 +801,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.`) } @@ -736,6 +830,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. * @@ -839,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/build-pages/index.js b/lib/build-pages/index.js index c962a513..93f6c8ec 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) @@ -53,12 +56,13 @@ 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. - * @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. */ /** @@ -67,6 +71,41 @@ const __dirname = import.meta.dirname * @typedef {DomStackOpts & BuildPagesFilterOptions} BuildPagesOptions */ +/** + * 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 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 - Discovery data from identifyPages(); siteData.pages is source-backed only. + */ + +/** + * Definition for one page produced by a *.pages.* file. + * + * @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. + * @property {U | PageFunction} [children] - Static child content or inline render function. + * @property {boolean} [draft] - When true, only build if buildDrafts is enabled. + */ + +/** + * A generated-pages factory. The same type covers normal functions, async + * functions, and async generators. + * + * @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 + * @returns {GeneratedPageDefinition | GeneratedPageDefinition[] | AsyncIterable> | Promise | GeneratedPageDefinition[] | AsyncIterable>>} + */ + /** * @typedef {BuildStep< * 'page', @@ -82,8 +121,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 {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. */ /** @@ -92,9 +134,24 @@ 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', path: string } | null} + * @returns {{ type: 'page' | 'template' | 'pages file', path: string } | null} */ function getWorkerErrorContext (errorData) { if (errorData.page) { @@ -107,6 +164,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 +193,176 @@ 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.pageFile.relname, + }) + } + + for (const pagesFile of siteData.pagesFiles ?? []) { + 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) + } + } 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 + } + } + + return generatedPageInfos +} + /** * Page builder glue. Most of the magic happens in the builders. * @@ -143,8 +374,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 } : {}), + pageFilterPaths: opts?.pageFilterPaths, + templateFilterPaths: opts?.templateFilterPaths, + buildDrafts: opts?.buildDrafts, } return new Promise((resolve, reject) => { @@ -248,8 +480,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, @@ -266,12 +500,47 @@ 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 - }, { concurrency: MAX_CONCURRENCY }) + } + + // 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 } }) + 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 - // Run global.data.js after all pages are initialized — receives fully resolved PageData[] + 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 +554,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 @@ -316,9 +583,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 8696aec3..53c7f62a 100644 --- a/lib/build-pages/page-builders/js/index.js +++ b/lib/build-pages/page-builders/js/index.js @@ -1,18 +1,31 @@ /** - * @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') + 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/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/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/builder.js b/lib/builder.js index 71c94d13..a60be66d 100644 --- a/lib/builder.js +++ b/lib/builder.js @@ -72,8 +72,13 @@ import { */ /** - * The data generated about the site generate dby identifyPages - * @typedef {Awaited>} SiteData + * 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/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/lib/helpers/domstack-error.js b/lib/helpers/domstack-error.js index 65765252..a8086f22 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 {'DOM_STACK_ERROR_OUTPUT_CONFLICT'} + */ + 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..420ecb65 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,8 @@ export async function identifyPages (src, opts = {}) { defaultClient: null, 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 new file mode 100644 index 00000000..7e430922 --- /dev/null +++ b/plans/generated-pages.md @@ -0,0 +1,557 @@ +# Generated Pages Files + +## 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. Resolved: watch mode removes obsolete regular and generated page outputs + +One-shot builds assume an empty destination. Watch mode previously rebuilt the pages that currently existed but did not remove files written by pages that had disappeared. This affected regular pages too, although generated pages made it easier to encounter because changing one `*.pages.*` file can rename or remove many outputs. + +Implemented resolution: + +- 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. Resolved: conflict detection is intentionally limited to page output paths + +The generated-pages design requires generated pages not to replace regular pages or other generated pages. The implementation meets that scope by checking concrete and generated page output paths before rendering. + +Templates can still target the same path as a regular or generated page. This is an existing whole-build limitation rather than behavior introduced by generated pages: regular pages and templates could already overwrite one another. Template output paths may also be chosen only after a template runs, while esbuild, static, and copied outputs are written by separate build steps. Manifest reconciliation cannot prevent these conflicts because it runs after files have been written. + +Resolved for this PR by: + +- 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). + +A duplicate-record check after the build would be too late to prevent an overwrite, so this PR does not add a partial post-write check. + +#### 4. Resolved: layout asset additions rebuild generated HTML in watch mode + +For layout CSS or client add events, the watch handler built a filter from `#layoutPageMap`. That map contains only concrete `siteData.pages`, so generated pages were omitted when a concrete and generated page shared the affected layout. The new asset was built, but generated HTML did not gain its `` or `').join('') + + '' + children + '' +} +` + +/** + * @param {unknown} error + * @returns {Error & { + * code?: string, + * conflict?: { + * outputPath: string, + * a: { type: string, path: string }, + * b: { type: string, path: string } + * }, + * pagesFile?: { pagesFile: { relname: string } } + * }} + */ +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', () => { + 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') + 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: '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('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, + '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('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, + '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') + 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') + 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('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, + '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 => { + 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 + } + ) + }) + }) + + 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 => { + 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 + } + ) + }) + }) + + 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 => { + 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 + } + ) + }) + }) + + 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('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, + '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, + '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..28b14087 --- /dev/null +++ b/test-cases/generated-pages/src/async.pages.js @@ -0,0 +1,13 @@ +/** @import { PagesFunction } from '#types' */ + +/** @type {PagesFunction} */ +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..e56a9882 --- /dev/null +++ b/test-cases/generated-pages/src/indexes.pages.js @@ -0,0 +1,39 @@ +/** + * @import { PageFunction, PagesFunction } from '#types' + * @import { HtmlResult } from 'fragtml/types.js' + */ + +import { html } from 'fragtml' + +/** + * @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, vars }) { + const posts = pages.filter(page => page.vars.publishDate && page.pageInfo.path.startsWith('blog/')) + + return { + outputName: 'blog/2024/index.html', + vars: { + layout: 'root', + title: `${vars.siteName}: 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..c8985a66 100644 --- a/types.ts +++ b/types.ts @@ -7,8 +7,11 @@ export type { BuildOptions } from 'esbuild' export type { DomStackOpts, Results, SiteData } from './lib/builder.js' export type { AsyncGlobalDataFunction, + GeneratedPageDefinition, GlobalDataFunction, GlobalDataFunctionParams, + PagesFunction, + PagesFunctionParams, } from './lib/build-pages/index.js' export type { AsyncLayoutFunction, @@ -30,7 +33,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,