From c9bb5e07f57d4d183f3fe8edc996cc0eb5b2833f Mon Sep 17 00:00:00 2001 From: Bret Comnes Date: Fri, 26 Jun 2026 21:18:03 -0700 Subject: [PATCH] Implement generated pages files --- README.md | 100 +++++ index.js | 80 +++- lib/build-pages/index.js | 255 +++++++++++- .../page-builders/generated/index.js | 27 ++ lib/build-pages/page-builders/index.js | 2 + lib/helpers/domstack-error.js | 34 +- lib/identify-pages.js | 30 +- plans/generated-pages.md | 390 ++++++++++++++++++ test-cases/generated-pages/index.test.js | 144 +++++++ test-cases/generated-pages/src/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 | 14 + .../generated-pages/src/global.client.js | 1 + test-cases/generated-pages/src/global.css | 1 + test-cases/generated-pages/src/global.data.js | 7 + test-cases/generated-pages/src/global.vars.js | 4 + .../generated-pages/src/indexes.pages.js | 15 + .../generated-pages/src/redirect.layout.js | 16 + .../generated-pages/src/redirects.pages.js | 15 + .../generated-pages/src/root.layout.client.js | 1 + .../generated-pages/src/root.layout.css | 1 + test-cases/generated-pages/src/root.layout.js | 17 + .../generated-pages/src/summary.template.js | 11 + 24 files changed, 1173 insertions(+), 17 deletions(-) create mode 100644 lib/build-pages/page-builders/generated/index.js 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/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/indexes.pages.js 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 5e111a0..5defe01 100644 --- a/README.md +++ b/README.md @@ -1043,6 +1043,106 @@ await pMap(allPosts, async (page) => { const html = renderCache.get(page.pageInfo.path) ?? '' ``` +### Generated Pages + +Files named `*.pages.js` (or `*.pages.ts` when TypeScript loading is available) generate real DomStack pages from one central file. They are similar to templates, but layout-driven: return one or more objects with `outputName`, optional `vars`, and optional `children`, and DomStack renders each output through the normal page/layout pipeline. + +Generated pages receive initialized concrete pages in their `pages` parameter. They do not receive pages generated by other `*.pages.*` files, so each pages file has a stable source-backed view of the site. After generated pages are created, they are included in the full `pages` array for `global.data.*`, templates, pages, and layouts. + +```js +// src/blog-indexes.pages.js +export default function ({ pages }) { + const posts = pages.filter(page => page.vars.publishDate && page.pageInfo.path.startsWith('blog/')) + + return { + outputName: 'blog/index.html', + vars: { + layout: 'blog-index', + title: 'Blog index', + posts, + }, + children: ({ vars }) => `

${vars.title}

${vars.posts.length} posts

`, + } +} +``` + +`outputName` is resolved relative to the `*.pages.*` file's directory and must stay relative: no leading `/` and no `..` path segments. If omitted, it defaults to `/index.html`. Generated pages use global and layout assets only; they do not have page-local `style.css`, `client.js`, or workers. + +### Redirect Pages + +Sites migrating from another platform often need redirect pages for old URLs that no longer exist. A `*.pages.*` file can centrally generate those pages while keeping the redirect HTML in a reusable layout. + +```js +// src/redirects.pages.js +// Generates one index.html per redirect entry using the redirect layout. + +const redirects = [ + { from: '2020/old-slug', to: '/2020/new-slug/' }, + { from: '2021/another-old', to: '/2021/another-new/' }, +] + +export default function redirectsPages () { + return redirects.map(({ from, to }) => ({ + outputName: `${from}/index.html`, + vars: { + layout: 'redirect', + title: 'Redirecting...', + redirectTo: to, + }, + })) +} +``` + +```js +// src/redirect.layout.js + +export default function redirectLayout ({ vars }) { + return ` + + + + + + ${escapeXml(vars.title)} + + +

Redirecting to ${escapeXml(vars.redirectTo)}

+ +` +} + +function escapeXml (str) { + return str + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, ''') +} +``` + +The `outputName` field controls the output path. Using `${from}/index.html` creates a directory-style URL at the old path. The `escapeXml` helper prevents HTML injection in attribute values. Note that `escapeXml` alone does not block dangerous URL schemes like `javascript:` — keep redirect targets to known-safe URL patterns (relative paths or verified external URLs). Be careful with `from` values used in `outputName`: generated page output names must be relative and cannot contain `..` segments. + +**SEO note:** Meta-refresh is a client-side redirect. Search engines may not treat it as a permanent 301 redirect. For static hosting platforms that support server-side redirects, you can instead generate a `_redirects` file (Netlify, Cloudflare Pages) or `vercel.json` (Vercel) using the object template type: + +```js +// src/redirects-netlify.txt.template.js +// Generates a _redirects file for Netlify / Cloudflare Pages. + +const redirects = [ + { from: '/2020/old-slug/', to: '/2020/new-slug/' }, +] + +export default function () { + return { + outputName: '_redirects', + content: redirects.map(({ from, to }) => `${from} ${to} 301`).join('\n'), + } +} +``` + +Both approaches can coexist. Copying a directory that contains a hand-crafted `_redirects` file via `--copy` is also an option when you prefer to manage redirects outside the build. + ## Global Assets There are a few important (and optional) global assets that live anywhere in the `src` directory. If duplicate named files that match the global asset file name pattern are found, a build error will occur until the duplicate file error is resolved. diff --git a/index.js b/index.js index bf7d47b..220bb70 100644 --- a/index.js +++ b/index.js @@ -8,9 +8,9 @@ * @import { TemplateAsyncIterator } from './lib/build-pages/page-builders/template-builder.js' * @import { TemplateOutputOverride } from './lib/build-pages/page-builders/template-builder.js' * @import { TemplateFunctionParams } from './lib/build-pages/page-builders/template-builder.js' - * @import { GlobalDataFunction, AsyncGlobalDataFunction, WorkerBuildStepResult, GlobalDataFunctionParams } from './lib/build-pages/index.js' + * @import { GlobalDataFunction, AsyncGlobalDataFunction, WorkerBuildStepResult, GlobalDataFunctionParams, PagesFunction, AsyncPagesFunction, PagesFunctionParams, GeneratedPageDefinition } from './lib/build-pages/index.js' * @import { BuildOptions, BuildContext } from 'esbuild' - * @import { PageInfo, TemplateInfo } from './lib/identify-pages.js' + * @import { PageInfo, TemplateInfo, PagesFileInfo } from './lib/identify-pages.js' */ import { once } from 'events' import assert from 'node:assert' @@ -36,6 +36,7 @@ import { layoutSuffixs, layoutStyleSuffix, templateSuffixs, + pagesSuffixs, globalVarsNames, globalDataNames, esbuildSettingsNames, @@ -115,6 +116,10 @@ export { PageData } from './lib/build-pages/page-data.js' * @typedef {TemplateInfo} TemplateInfo */ +/** + * @typedef {PagesFileInfo} PagesFileInfo + */ + /** * @template {Record} Vars - The type of variables passed to the layout function * @template [PageReturn=any] PageReturn - The return type of the page function @@ -126,6 +131,10 @@ export { PageData } from './lib/build-pages/page-data.js' * @typedef {GlobalDataFunctionParams} GlobalDataFunctionParams */ +/** + * @typedef {PagesFunctionParams} PagesFunctionParams + */ + /** * @template {Record} Vars - The type of variables passed to the page function * @template [PageReturn=any] PageReturn - The return type of the page function @@ -137,6 +146,24 @@ export { PageData } from './lib/build-pages/page-data.js' * @typedef {TemplateFunctionParams} TemplateFunctionParams */ +/** + * @template {Record} [Vars=Record] - The type of variables for the generated pages function + * @template [Children=any] + * @typedef {PagesFunction} PagesFunction + */ + +/** + * @template {Record} [Vars=Record] - The type of variables for the async generated pages function + * @template [Children=any] + * @typedef {AsyncPagesFunction} AsyncPagesFunction + */ + +/** + * @template {Record} [Vars=Record] - The type of variables for a generated page + * @template [Children=any] + * @typedef {GeneratedPageDefinition} GeneratedPageDefinition + */ + /** * @typedef TestBuildResult * @property {string} dest - Temporary destination directory used for the build. @@ -181,6 +208,8 @@ export class DomStack { #pageDepMap = new Map() /** @type {Map>} depFilepath → Set */ #templateDepMap = new Map() + /** @type {Map>} depFilepath → Set */ + #pagesFileDepMap = new Map() /** @type {Set} absolute filepaths of esbuild entry points */ #esbuildEntryPoints = new Set() @@ -533,6 +562,7 @@ export class DomStack { const layoutFileMap = /** @type {Map} */ (new Map()) const pageDepMap = /** @type {Map>} */ (new Map()) const templateDepMap = /** @type {Map>} */ (new Map()) + const pagesFileDepMap = /** @type {Map>} */ (new Map()) // layoutFileMap: layout filepath → layoutName for (const layout of Object.values(siteData.layouts)) { @@ -616,6 +646,20 @@ export class DomStack { } } + // pagesFileDepMap: dep filepath → Set + for (const pagesFileInfo of siteData.pagesFiles ?? []) { + try { + const deps = await find(pagesFileInfo.pagesFile.filepath) + for (const dep of deps) { + const absPath = resolve(dep) + if (!pagesFileDepMap.has(absPath)) pagesFileDepMap.set(absPath, new Set()) + pagesFileDepMap.get(absPath)?.add(pagesFileInfo) + } + } catch { + // best-effort + } + } + // esbuildEntryPoints: absolute filepaths of all esbuild entry points const esbuildEntryPoints = /** @type {Set} */ (new Set()) if (siteData.globalClient) esbuildEntryPoints.add(resolve(siteData.globalClient.filepath)) @@ -638,6 +682,7 @@ export class DomStack { this.#layoutFileMap = layoutFileMap this.#pageDepMap = pageDepMap this.#templateDepMap = templateDepMap + this.#pagesFileDepMap = pagesFileDepMap this.#esbuildEntryPoints = esbuildEntryPoints } @@ -687,6 +732,11 @@ export class DomStack { // 7. Layout file itself → rebuild pages using that layout if (layoutSuffixs.some(s => changedBasename.endsWith(s))) { + if ((siteData.pagesFiles?.length ?? 0) > 0) { + console.log(`"${changedBasename}" changed, rebuilding all pages...`) + return this.#runPageBuild(siteData) + } + const layoutName = this.#layoutFileMap.get(changedPath) if (layoutName) { const affectedPages = this.#layoutPageMap.get(layoutName) @@ -703,6 +753,10 @@ export class DomStack { // 8. Dep of a layout if (this.#layoutDepMap.has(changedPath)) { + if ((siteData.pagesFiles?.length ?? 0) > 0) { + console.log(`"${changedBasename}" changed, rebuilding all pages...`) + return this.#runPageBuild(siteData) + } const affectedLayoutNames = this.#layoutDepMap.get(changedPath) ?? new Set() const affectedPages = new Set(/** @type {PageInfo[]} */ ([])) for (const layoutName of affectedLayoutNames) { @@ -725,7 +779,15 @@ export class DomStack { } } - // 10. Template file itself + // 10. Pages file itself → full page rebuild + if (pagesSuffixs.some(s => changedBasename.endsWith(s))) { + if (siteData.pagesFiles?.some(p => p.pagesFile.filepath === changedPath)) { + console.log(`"${changedBasename}" changed, rebuilding all pages...`) + return this.#runPageBuild(siteData) + } + } + + // 11. Template file itself if (templateSuffixs.some(s => changedBasename.endsWith(s))) { const templateInfo = siteData.templates.find(t => t.templateFile.filepath === changedPath) if (templateInfo) { @@ -734,7 +796,7 @@ export class DomStack { } } - // 11. Dep of a page.js or page.vars + // 12. Dep of a page.js or page.vars if (this.#pageDepMap.has(changedPath)) { const affectedPages = this.#pageDepMap.get(changedPath) ?? new Set() if (affectedPages.size > 0) { @@ -744,7 +806,13 @@ export class DomStack { } } - // 12. Dep of a template file + // 13. Dep of a pages file → full page rebuild + if (this.#pagesFileDepMap.has(changedPath)) { + console.log(`"${changedBasename}" changed, rebuilding all pages...`) + return this.#runPageBuild(siteData) + } + + // 14. Dep of a template file if (this.#templateDepMap.has(changedPath)) { const affectedTemplates = this.#templateDepMap.get(changedPath) ?? new Set() if (affectedTemplates.size > 0) { @@ -754,7 +822,7 @@ export class DomStack { } } - // 13. No matching rule — skip. + // 15. No matching rule — skip. console.log(`"${changedBasename}" changed but did not match any rebuild rule, skipping.`) } diff --git a/lib/build-pages/index.js b/lib/build-pages/index.js index 1549810..4023c6a 100644 --- a/lib/build-pages/index.js +++ b/lib/build-pages/index.js @@ -1,12 +1,12 @@ /** * @import { BuilderOptions } from './page-builders/page-writer.js' * @import { BuildStep, SiteData, DomStackOpts } from '../builder.js' - * @import { PageInfo, TemplateInfo } from '../identify-pages.js' + * @import { PageInfo, TemplateInfo, PagesFileInfo } from '../identify-pages.js' * @import { ResolvedLayout } from './page-data.js' */ import { Worker } from 'worker_threads' -import { join } from 'path' +import { basename, dirname, isAbsolute, join, normalize, resolve } from 'path' import pMap from 'p-map' import { cpus } from 'os' import { keyBy } from '../helpers/key-by.js' @@ -14,6 +14,8 @@ import { resolveVars, resolveGlobalData } from './resolve-vars.js' import { pageBuilders, templateBuilder } from './page-builders/index.js' import { PageData, resolveLayout } from './page-data.js' import { pageWriter } from './page-builders/page-writer.js' +import { computePageUrl } from './compute-page-url.js' +import { DomStackOutputConflictError } from '../helpers/domstack-error.js' const MAX_CONCURRENCY = Math.min(cpus().length, 24) @@ -52,6 +54,48 @@ const __dirname = import.meta.dirname * @returns {Promise} */ +/** + * Parameters passed to a *.pages.* default export function. + * + * @typedef {object} PagesFunctionParams + * @property {PageData[]} pages - Initialized concrete/source-backed pages only. + * @property {Record} vars - Default and global vars, before global.data.* output. + * @property {PagesFileInfo} pagesFile - Info about the current *.pages.* file. + * @property {SiteData} siteData - Site data from identifyPages(). + */ + +/** + * Definition for one page produced by a *.pages.* file. + * + * @template {Record} [T=Record] + * @template [U=any] + * @typedef {object} GeneratedPageDefinition + * @property {string} [outputName] - Relative output filename, defaulting to the pages file name. + * @property {T} [vars] - Page vars to merge through the normal page/layout pipeline. + * @property {U | import('./page-builders/page-writer.js').PageFunction} [children] - Static child content or inline render function. + * @property {boolean} [draft] - When true, only build if buildDrafts is enabled. + */ + +/** + * Synchronous generated-pages function. + * + * @template {Record} [T=Record] + * @template [U=any] + * @callback PagesFunction + * @param {PagesFunctionParams} params + * @returns {GeneratedPageDefinition | GeneratedPageDefinition[] | AsyncIterable> | Promise | GeneratedPageDefinition[] | AsyncIterable>>} + */ + +/** + * Asynchronous generated-pages function. + * + * @template {Record} [T=Record] + * @template [U=any] + * @callback AsyncPagesFunction + * @param {PagesFunctionParams} params + * @returns {Promise | GeneratedPageDefinition[] | AsyncIterable>>} + */ + /** * @typedef {BuildStep< * 'page', @@ -68,6 +112,7 @@ const __dirname = import.meta.dirname * @typedef {object} WorkerErrorData * @property {PageInfo} [page] - Page context for page var/rendering errors. * @property {TemplateInfo} [template] - Template context for template rendering errors. + * @property {PagesFileInfo} [pagesFile] - Pages-file context for generated page resolution errors. */ /** @@ -87,7 +132,7 @@ export { pageBuilders } /** * @param {WorkerErrorData} errorData - * @returns {{ type: 'page' | 'template', path: string } | null} + * @returns {{ type: 'page' | 'template' | 'pages file', path: string } | null} */ function getWorkerErrorContext (errorData) { if (errorData.page) { @@ -100,6 +145,10 @@ function getWorkerErrorContext (errorData) { return { type: 'template', path: templatePath } } + if (errorData.pagesFile) { + return { type: 'pages file', path: errorData.pagesFile.pagesFile.relname } + } + return null } @@ -125,6 +174,168 @@ function restoreWorkerError (error, errorData) { return restoredError } +/** + * @param {unknown} value + * @returns {value is AsyncIterable} + */ +function isAsyncIterable (value) { + return typeof value === 'object' && value !== null && Symbol.asyncIterator in value && typeof value[Symbol.asyncIterator] === 'function' +} + +/** + * @param {unknown} value + * @returns {value is GeneratedPageDefinition} + */ +function isGeneratedPageDefinition (value) { + return typeof value === 'object' && value !== null +} + +/** + * @param {unknown} value + * @returns {Promise} + */ +async function collectGeneratedPageDefinitions (value) { + if (value == null) return [] + + if (Array.isArray(value)) { + return /** @type {GeneratedPageDefinition[]} */ (value) + } + + if (isAsyncIterable(value)) { + /** @type {GeneratedPageDefinition[]} */ + const definitions = [] + for await (const definition of value) { + definitions.push(/** @type {GeneratedPageDefinition} */ (definition)) + } + return definitions + } + + if (isGeneratedPageDefinition(value)) { + return [value] + } + + throw new Error(`Pages file returned unknown return type: ${typeof value}`) +} + +/** + * @param {string} value + * @param {object} opts + * @param {string} opts.field + * @param {boolean} [opts.allowEmpty] + * @returns {string} + */ +function normalizeGeneratedOutputPart (value, { field, allowEmpty = false }) { + if (typeof value !== 'string') throw new TypeError(`Generated page ${field} must be a string`) + if (!allowEmpty && value.length === 0) throw new Error(`Generated page ${field} must not be empty`) + if (isAbsolute(value) || /^[A-Za-z]:[\\/]/.test(value)) throw new Error(`Generated page ${field} must be relative: ${value}`) + if (value.split(/[\\/]+/).includes('..')) throw new Error(`Generated page ${field} must not contain ".." segments: ${value}`) + + const normalized = normalize(value) + return normalized === '.' ? '' : normalized +} + +/** + * @param {object} params + * @param {GeneratedPageDefinition} params.definition + * @param {PagesFileInfo} params.pagesFile + * @param {number} params.index + * @returns {PageInfo} + */ +function generatedDefinitionToPageInfo ({ definition, pagesFile, index }) { + const relativeOutputName = normalizeGeneratedOutputPart(definition.outputName ?? `${pagesFile.name}/index.html`, { field: 'outputName' }) + const outputRelname = join(pagesFile.path, relativeOutputName) + const generatedPath = dirname(outputRelname) === '.' ? '' : dirname(outputRelname) + const outputName = basename(outputRelname) + + return { + pageFile: { + ...pagesFile.pagesFile, + basename: `${pagesFile.pagesFile.basename}#${index}`, + relname: `${pagesFile.pagesFile.relname}#${index}`, + type: 'generated', + }, + type: 'generated', + path: generatedPath, + url: computePageUrl({ path: generatedPath, outputName }), + outputName, + outputRelname, + draft: Boolean(definition.draft), + generated: { + pagesFile, + vars: definition.vars ?? {}, + children: definition.children, + }, + } +} + +/** + * @param {object} params + * @param {SiteData} params.siteData + * @param {PageData[]} params.concretePages + * @param {Record} params.globalVars + * @param {boolean | undefined} params.buildDrafts + * @returns {Promise} + */ +async function resolveGeneratedPageInfos ({ siteData, concretePages, globalVars, buildDrafts }) { + /** @type {PageInfo[]} */ + const generatedPageInfos = [] + /** @type {Map} */ + const pageOutputClaims = new Map() + + for (const pageInfo of siteData.pages) { + pageOutputClaims.set(resolve(pageInfo.outputRelname), { + type: 'page', + path: pageInfo.outputRelname, + }) + } + + for (const pagesFile of siteData.pagesFiles ?? []) { + const importResults = await import(pagesFile.pagesFile.filepath) + if (!('default' in importResults)) throw new Error(`Missing default export from pages file: ${pagesFile.pagesFile.relname}`) + + const pagesExport = importResults.default + const pagesResults = typeof pagesExport === 'function' + ? await pagesExport({ + pages: concretePages, + vars: globalVars, + pagesFile, + siteData, + }) + : pagesExport + + const definitions = await collectGeneratedPageDefinitions(pagesResults) + + for (const [index, definition] of definitions.entries()) { + const generatedPageInfo = generatedDefinitionToPageInfo({ definition, pagesFile, index }) + if (generatedPageInfo.draft && !buildDrafts) continue + + const outputKey = resolve(generatedPageInfo.outputRelname) + const existingClaim = pageOutputClaims.get(outputKey) + if (existingClaim) { + throw new DomStackOutputConflictError( + `Output path conflict: ${generatedPageInfo.outputRelname} is produced by both ${existingClaim.path} and ${pagesFile.pagesFile.relname}.`, + { + outputPath: generatedPageInfo.outputRelname, + a: existingClaim, + b: { + type: 'page', + path: pagesFile.pagesFile.relname, + }, + } + ) + } + + pageOutputClaims.set(outputKey, { + type: 'page', + path: generatedPageInfo.outputRelname, + }) + generatedPageInfos.push(generatedPageInfo) + } + } + + return generatedPageInfos +} + /** * Page builder glue. Most of the magic happens in the builders. * @@ -233,8 +444,10 @@ export async function buildPagesDirect (src, dest, siteData, _opts) { markdownItSettingsPath: siteData.markdownItSettings?.filepath || null } - // Mix in resolveVars, renderInnerPage and renderFullPage methods - const pages = await pMap(siteData.pages, async (pageInfo) => { + /** + * @param {PageInfo} pageInfo + */ + const initPageData = async (pageInfo) => { const pageData = new PageData({ pageInfo, globalVars, @@ -254,9 +467,35 @@ export async function buildPagesDirect (src, dest, siteData, _opts) { result.errors.push({ error: variableResolveError, errorData: { page: pageInfo } }) } return pageData - }, { concurrency: MAX_CONCURRENCY }) + } - // Run global.data.js after all pages are initialized — receives fully resolved PageData[] + // Mix in resolveVars, renderInnerPage and renderFullPage methods for concrete pages. + const concretePages = await pMap(siteData.pages, initPageData, { concurrency: MAX_CONCURRENCY }) + + if (result.errors.length > 0) return result + + let generatedPageInfos = /** @type {PageInfo[]} */ ([]) + try { + generatedPageInfos = await resolveGeneratedPageInfos({ + siteData, + concretePages, + globalVars, + buildDrafts: _opts?.buildDrafts, + }) + } catch (err) { + if (!(err instanceof Error)) throw new Error('Non-error thrown while resolving generated pages', { cause: err }) + const generatedPagesError = new Error(`Error resolving generated pages: ${err.message}`, { cause: { message: err.message, stack: err.stack } }) + result.errors.push({ error: generatedPagesError }) + } + + if (result.errors.length > 0) return result + + const generatedPages = await pMap(generatedPageInfos, initPageData, { concurrency: MAX_CONCURRENCY }) + const pages = [...concretePages, ...generatedPages] + + if (result.errors.length > 0) return result + + // Run global.data.js after concrete and generated pages are initialized — receives fully resolved PageData[] // so it can filter/sort by page.vars.layout, page.vars.publishDate, etc. const globalDataVars = await resolveGlobalData({ globalDataPath: siteData.globalData?.filepath, @@ -270,8 +509,6 @@ export async function buildPagesDirect (src, dest, siteData, _opts) { } } - if (result.errors.length > 0) return result - /** @type {[number, number]} Divided concurrency valus */ const dividedConcurrency = MAX_CONCURRENCY % 2 ? [((MAX_CONCURRENCY - 1) / 2) + 1, (MAX_CONCURRENCY - 1) / 2] // odd diff --git a/lib/build-pages/page-builders/generated/index.js b/lib/build-pages/page-builders/generated/index.js new file mode 100644 index 0000000..40bd5a4 --- /dev/null +++ b/lib/build-pages/page-builders/generated/index.js @@ -0,0 +1,27 @@ +/** + * @import { PageBuilderType } from '../page-writer.js' + */ + +import assert from 'node:assert' + +/** + * Build a generated page from data returned by a *.pages.* file. + * Generated pages use global/layout assets only; they do not have page-local assets. + * + * @template {Record} T - The type of variables for the page + * @template [U=any] U - The return type of the pageLayout function + * @type {PageBuilderType} + */ +export async function generatedBuilder ({ pageInfo }) { + assert(pageInfo.type === 'generated', 'generated builder requires a "generated" page type') + assert(pageInfo.generated, 'generated page requires generated metadata') + + const generated = pageInfo.generated + + return { + vars: generated.vars ?? {}, + pageLayout: typeof generated.children === 'function' + ? generated.children + : () => generated.children ?? '', + } +} diff --git a/lib/build-pages/page-builders/index.js b/lib/build-pages/page-builders/index.js index 837be34..fca8e9d 100644 --- a/lib/build-pages/page-builders/index.js +++ b/lib/build-pages/page-builders/index.js @@ -1,10 +1,12 @@ import { mdBuilder } from './md/index.js' import { jsBuilder } from './js/index.js' import { htmlBuilder } from './html/index.js' +import { generatedBuilder } from './generated/index.js' export { templateBuilder } from './template-builder.js' export const pageBuilders = { md: mdBuilder, js: jsBuilder, html: htmlBuilder, + generated: generatedBuilder, } diff --git a/lib/helpers/domstack-error.js b/lib/helpers/domstack-error.js index bb8a6d3..8ec480f 100644 --- a/lib/helpers/domstack-error.js +++ b/lib/helpers/domstack-error.js @@ -1,4 +1,10 @@ -/** @typedef { 'DOM_STACK_ERROR_DUPLICATE_PAGE' } DomStackErrorCode */ +/** @typedef { 'DOM_STACK_ERROR_DUPLICATE_PAGE' | 'DOM_STACK_ERROR_OUTPUT_CONFLICT' } DomStackErrorCode */ + +/** + * @typedef DomStackOutputConflictErrorClaim + * @property {'page'} type - The kind of output producer. + * @property {string} path - Human-readable source or output path for the producer. + */ /** * Domstack Duplicate Page Error @@ -31,6 +37,32 @@ export class DomStackDuplicatePageError extends Error { } } +/** + * DomStack Output Conflict Error + * @extends {Error} + */ +export class DomStackOutputConflictError extends Error { + /** @type {{ outputPath: string, a: DomStackOutputConflictErrorClaim, b: DomStackOutputConflictErrorClaim }} */ + conflict + + /** + * @param {string} message - The error message + * @param {{ outputPath: string, a: DomStackOutputConflictErrorClaim, b: DomStackOutputConflictErrorClaim }} conflict - Conflict metadata + * @param {ErrorOptions} [opts] - The opts object from the Error class + */ + constructor (message, conflict, opts) { + super(message, opts) + this.conflict = conflict + } + + /** + * @returns {DomStackErrorCode} + */ + get code () { + return 'DOM_STACK_ERROR_OUTPUT_CONFLICT' + } +} + /** @typedef { 'DOM_STACK_WARNING_DUPLICATE_LAYOUT' } DomStackWarningCode */ /** diff --git a/lib/identify-pages.js b/lib/identify-pages.js index cec555a..06662ea 100644 --- a/lib/identify-pages.js +++ b/lib/identify-pages.js @@ -64,6 +64,10 @@ export const templateSuffixs = nodeHasTS ? ['.template.ts', '.template.mts', '.template.cts', '.template.js', '.template.mjs', '.template.cjs'] : ['.template.js', '.template.mjs', '.template.cjs'] +export const pagesSuffixs = nodeHasTS + ? ['.pages.ts', '.pages.mts', '.pages.cts', '.pages.js', '.pages.mjs', '.pages.cjs'] + : ['.pages.js', '.pages.mjs', '.pages.cjs'] + export const globalStyleNames = ['global.css', 'global.style.css'] export const pageStyleName = 'style.css' @@ -140,7 +144,7 @@ const shaper = ({ */ /** - * @typedef {'js' | 'md' | 'html'} PageTypes + * @typedef {'js' | 'md' | 'html' | 'generated'} PageTypes */ /** @@ -162,6 +166,7 @@ const shaper = ({ * @property {string} outputName - The name of the output file. * @property {string} outputRelname - The relative name/path for the output file. * @property {boolean} draft - If the page is marked as a draft or not. Draft pages are only included when buildDrafts is passed. + * @property {{ pagesFile: PagesFileInfo, vars?: Record, children?: any } | undefined} [generated] - Generated page metadata for pages produced by *.pages.* files. */ /** @@ -171,6 +176,13 @@ const shaper = ({ * @property {string} outputName - The derived output name of the template file. Might be overridden. */ +/** + * @typedef PagesFileInfo + * @property {WalkerFile} pagesFile - The generated-pages file info. + * @property {string} path - The path of the parent dir of the pages file. + * @property {string} name - The derived name of the pages file. + */ + /** * Identifies the pages, layouts, templates, and other relevant data from a given source directory. * @@ -216,6 +228,9 @@ export async function identifyPages (src, opts = {}) { /** @type {TemplateInfo[]} The array of discovered template files */ const templates = [] + /** @type {PagesFileInfo[]} The array of discovered generated-pages files */ + const pagesFiles = [] + /** @type {PageFileAsset | undefined } */ let globalStyle @@ -427,6 +442,18 @@ export async function identifyPages (src, opts = {}) { }) } + if (pagesSuffixs.some(suffix => fileName.endsWith(suffix))) { + const suffix = pagesSuffixs.find(suffix => fileName.endsWith(suffix)) + if (!suffix) throw new Error('pages suffix not found') + const pagesFileName = fileName.slice(0, -suffix.length) + + pagesFiles.push({ + pagesFile: fileInfo, + path: dir, + name: pagesFileName, + }) + } + if (globalStyleNames.some(name => basename(fileName) === name)) { if (globalStyle) { warnings.push({ @@ -544,6 +571,7 @@ export async function identifyPages (src, opts = {}) { defaultClient: null, layouts, templates, + pagesFiles, pages, warnings, errors, diff --git a/plans/generated-pages.md b/plans/generated-pages.md new file mode 100644 index 0000000..0ebc3ac --- /dev/null +++ b/plans/generated-pages.md @@ -0,0 +1,390 @@ +# Generated Pages Files + +## Status: Proposed refinement + +Plan for adding first-class generated page support in response to the redirect-page discussion in PR #253. + +--- + +## Problem + +Templates can already write arbitrary files, including redirect HTML files, `_redirects`, feeds, and other generated assets. They do not, however, create real DomStack pages: + +- Template outputs bypass page vars, layouts, default/global assets, and page render helpers. +- Template outputs are not represented in `pages`, so `global.data.*`, feeds, indexes, and other introspective code cannot see them. +- Redirect pages are conceptually pages: they should use a redirect layout, inherit vars, and appear at page URLs. +- Some generated-page use cases need central control: redirect lists, yearly/monthly blog indexes, tag indexes, pagination, archive pages, etc. + +The final PR comments point toward a dedicated `*.pages.ts` feature rather than more redirect docs or more template escape hatches. + +## Refined recommendation + +Add a generated-pages file type, discovered as `*.pages.*`, but do **not** treat its outputs as a separate output class. + +Instead: + +> `*.pages.*` files are page factories. Their returned definitions expand into normal `PageInfo` entries and are appended to the page set before `global.data.*`, templates, and final page rendering run. + +This preserves the useful authoring model from templates — one file can return one output, many outputs, or an async stream of outputs — while keeping generated results inside the normal page pipeline. + +| Feature | Purpose | Output semantics | +|---|---|---| +| `*.template.*` | Generate arbitrary files | Caller provides final file content | +| `*.pages.*` | Generate real pages | Caller provides output name, vars, and children; DomStack renders through layout/page pipeline | + +## File naming + +Discover the same JS/TS module families as templates: + +```txt +*.pages.ts / *.pages.mts / *.pages.cts +*.pages.js / *.pages.mjs / *.pages.cjs +``` + +Use `nodeHasTS` just like `templateSuffixs` in `lib/identify-pages.js`. + +Examples: + +```txt +src/redirects.pages.js +src/blog/indexes.pages.ts +src/tags.pages.mjs +``` + +## Proposed API + +A pages file exports a default function, async function, array, object, or async iterable that yields generated page definitions. + +```ts +import type { PagesFunction } from '@domstack/static' + +export default (async function redirectsPages ({ pages }) { + return [ + { + outputName: '2020/old-slug/index.html', + vars: { + layout: 'redirect', + title: 'Redirecting...', + redirectTo: '/2020/new-slug/', + }, + children: '', + }, + ] +}) satisfies PagesFunction +``` + +The generated page definition is template-like, but layout-driven: `outputName` chooses where to write the page, `children` supplies the layout child content, and `vars` controls page/layout variables. + +```ts +type GeneratedPageDefinition, Children = any> = { + outputName?: string // default: '/index.html' + vars?: Vars + children?: Children | ((params: PageFunctionParams) => Children | Promise) + draft?: boolean +} +``` + +Rules: + +- `outputName` is a relative output path, resolved from the `*.pages.*` file's directory, with no leading `/` and no `..` segments. +- `outputName` defaults to `/index.html`. +- `vars.layout` participates in normal layout resolution. If omitted, the usual default/global layout value applies. +- `children` can be static content or an inline page-like render function. +- Generated pages must not reference another page file as their render template. +- Generated pages intentionally do not get page-local assets (`style.css`, `client.js`, workers). They only participate in global and layout assets. + +## Pages file parameters + +Pass enough context for reflection while avoiding circular or ordering-dependent generation: + +```ts +type PagesFunctionParams = { + pages: PageData[] + vars: Record + pagesFile: PagesFileInfo + siteData: SiteData +} +``` + +`pages` contains only concrete/source-backed pages discovered directly from the source tree, initialized with default/global/page/builder vars, but before `global.data.*` runs. It does not include generated pages from any `*.pages.*` file, including pages produced by earlier files in the same build. + +This gives every pages file the same stable introspection set. + +## Build pipeline + +Do not run `*.pages.*` files inside `identifyPages()`. They need initialized concrete page data (`page.vars`, builder vars, pageInfo, render helpers), and `identifyPages()` should remain a file-discovery phase. + +Instead, add an explicit page-expansion phase early in `buildPagesDirect()`. + +Current pipeline: + +```txt +identifyPages() + discover concrete pages + discover layouts/templates/global assets + +buildPagesDirect() + resolve default/global vars + resolve layouts + initialize concrete PageData[] + resolve global.data.* with concrete pages + stamp globalDataVars + render pages and templates +``` + +Proposed pipeline: + +```txt +identifyPages() + discover concrete pages + discover layouts/templates/global assets + discover pagesFiles (*.pages.*) + +buildPagesDirect() + resolve default/global vars + resolve layouts + + concretePageInfos = siteData.pages + concretePageData = initialize concrete PageData[] + + run pagesFiles with concretePageData + global vars + siteData + validate generated page definitions + convert definitions into generated PageInfo objects + detect output conflicts against concrete pages and earlier generated pages + + expandedSiteData = { + ...siteData, + concretePages: concretePageInfos, + pages: [...concretePageInfos, ...generatedPageInfos], + } + + generatedPageData = initialize generated PageData[] + allPages = [...concretePageData, ...generatedPageData] + + resolve global.data.* with allPages + stamp globalDataVars onto allPages + render pages/templates using expandedSiteData + allPages +``` + +The important framing is that generated outputs become ordinary pages as soon as they have been expanded into `GeneratedPageInfo` objects. From that point forward, rendering, global data, templates, reports, and watch maps should operate on the expanded page list. + +## Data model changes + +### `identify-pages.js` + +Add: + +```js +export const pagesSuffixs = nodeHasTS + ? ['.pages.ts', '.pages.mts', '.pages.cts', '.pages.js', '.pages.mjs', '.pages.cjs'] + : ['.pages.js', '.pages.mjs', '.pages.cjs'] +``` + +Add `PagesFileInfo` and `siteData.pagesFiles` alongside `siteData.templates`. + +Optionally distinguish the raw concrete pages from expanded pages once expansion has run: + +```ts +type SiteData = { + pages: PageInfo[] // expanded pages after generated-page expansion + concretePages?: PageInfo[] // source-backed pages discovered by identifyPages() + pagesFiles: PagesFileInfo[] +} +``` + +`identifyPages()` can initially return `pages` and `concretePages` as the same list. The expansion phase can then produce an `expandedSiteData` object rather than mutating the original `siteData` in place. + +### Generated page info + +Represent generated pages as regular `PageInfo` entries with an additional marker: + +```ts +type GeneratedPageInfo = PageInfo & { + type: 'generated' + generated: { + pagesFile: PagesFileInfo + vars: Record + children: unknown | PageFunction + } +} +``` + +Add a generated page builder to `pageBuilders`: + +```js +pageBuilders.generated = async ({ pageInfo }) => ({ + vars: pageInfo.generated.vars, + pageLayout: typeof pageInfo.generated.children === 'function' + ? pageInfo.generated.children + : () => pageInfo.generated.children ?? '', +}) +``` + +`PageData.init()` can then continue to resolve layout and assets through the existing builder contract. Generated pages are special only at the point where their `PageInfo` is created. + +## Conflict detection + +Generated pages must not silently overwrite concrete pages, loose markdown outputs, or other generated pages. Any duplicate generated/concrete page output path must throw a conflict error. + +Minimum v1 conflict checks: + +1. Validate `outputName` is relative and cannot escape the pages file's directory. +2. Compute: + - `outputRelname = join(pagesFile.path, outputName)` + - `path = dirname(outputRelname)` + - `outputName = basename(outputRelname)` + - `url = computePageUrl({ path, outputName })` +3. Reject duplicates within: + - existing concrete `siteData.pages[*].outputRelname` + - generated definitions from all pages files + +Prefer hard errors for duplicate page output paths, matching the existing duplicate page-source behavior. + +## Watch mode integration + +Generated pages should eventually make watch mode cleaner, not more special, if watch maps are rebuilt from expanded page data. + +### Conservative v1 + +Treat `*.pages.*` as structural page inputs: + +- Add/change/unlink of a `*.pages.*` file → full page rebuild and rebuild maps. +- Dependency of a `*.pages.*` file → full page rebuild. +- Layout changes may need a full page rebuild until generated pages are included in layout watch maps. + +### Better follow-up + +Once the build has an `expandedSiteData` concept, rebuild watch maps from expanded pages: + +- `#layoutPageMap` should include generated pages by resolving their final `vars.layout`. +- A layout change can then target both concrete and generated pages using that layout. +- `#pageFileMap` can include generated page pseudo-file paths only if targeted rebuilds need them; otherwise pages-file changes remain structural. +- `#pagesFileDepMap` tracks dependencies imported by pages files and can conservatively trigger full page rebuilds. + +This avoids the current broad special case of “if any pages files exist, layout changed means rebuild all pages.” + +## Public types + +Export from `index.js`: + +- `PagesFunction` +- `AsyncPagesFunction` +- `PagesFunctionParams` +- `GeneratedPageDefinition` +- `PagesFileInfo` + +Add JSDoc typedefs first, then declaration generation will expose them through the existing `tsc -p declaration.tsconfig.json` flow. + +## Documentation examples + +### Redirects + +```js +// src/redirects.pages.js +const redirects = [ + { from: '2020/old-slug', to: '/2020/new-slug/' }, +] + +export default function () { + return redirects.map(({ from, to }) => ({ + outputName: `${from}/index.html`, + vars: { + layout: 'redirect', + title: 'Redirecting...', + redirectTo: to, + }, + })) +} +``` + +```js +// src/redirect.layout.js +export default function redirectLayout ({ vars }) { + return ` + + + + + + ${vars.title} + + +

Redirecting to ${vars.redirectTo}

+ +` +} +``` + +Docs should still mention validating redirect targets, but that security note belongs in the redirect-layout example rather than in the generated-pages core API. + +### Blog indexes + +```js +// src/blog-indexes.pages.js +export default function ({ pages }) { + const years = new Map() + + for (const page of pages) { + const date = page.vars.publishDate + if (!date || !page.pageInfo.path.startsWith('blog/')) continue + const year = new Date(date).getFullYear().toString() + years.set(year, [...(years.get(year) ?? []), page]) + } + + return [...years].map(([year, posts]) => ({ + outputName: `blog/${year}/index.html`, + vars: { layout: 'blog-index', title: `${year} posts`, posts }, + })) +} +``` + +## Tests + +Add a focused generated-pages fixture, likely `test-cases/generated-pages/`: + +1. Discovers `*.pages.js` and exposes it on `siteData.pagesFiles`. +2. Generates redirect pages that render through a `redirect.layout.js`. +3. Generated pages appear in `global.data.js` and in template `pages` introspection. +4. Generated blog/year indexes can inspect concrete pages. +5. Multiple `*.pages.*` files each receive only concrete pages, not generated pages from other pages files. +6. Duplicate generated/concrete output paths throw an aggregate build error. +7. Invalid generated output paths (`/absolute`, `../escape`, `nested/../../escape`) throw a clear error. +8. Async iterable pages files work for large output sets. +9. Watch mode: changing a `*.pages.js` file triggers a full page rebuild. +10. Follow-up watch test: once expanded watch maps exist, a layout change rebuilds generated pages using that layout. + +Run at minimum: + +```sh +npm run test:node-test -- test-cases/generated-pages/index.test.js +npm run test:neostandard +npm run test:tsc +``` + +Then run full `npm test` before merging. + +## Design decisions + +1. `*.pages.*` files are page factories, not a separate output system. + - Their outputs become regular `PageInfo` entries in the expanded page list. + - Downstream systems should consume the expanded page list wherever possible. +2. Generated pages are distinct from concrete/source-backed pages only while pages files are running. + - The `pages` argument passed to `*.pages.*` files contains only concrete pages discovered directly from the source tree. + - Generated pages are not passed to other pages files in the same build. + - This avoids ordering-dependent generation. +3. Generated pages do not support page-level `style.css`, `client.js`, or workers. + - They participate only in global assets and layout assets. + - This keeps generated pages focused on central page creation while concrete pages remain the place for page-local asset bundles. +4. Generated pages pass child content directly; they do not pull in existing page files as render templates. + - `children` may be static content or an inline render function. + - Reusable presentation belongs in layouts or userland helper functions imported by the pages file. + +## Milestones + +1. Discovery and types: `pagesSuffixs`, `PagesFileInfo`, `siteData.pagesFiles`, exported JSDoc typedefs. +2. Runtime: `resolvePagesFiles()`, generated page validation, generated `PageInfo`, `pageBuilders.generated`. +3. Expansion: create `expandedSiteData` where `pages` contains concrete + generated pages. +4. Pipeline: run `global.data.*`, templates, and page rendering against expanded pages. +5. Errors: duplicate generated/concrete page output conflicts and invalid generated output path errors with useful file context. +6. Tests and docs: generated-pages fixture, README section, redirect and blog-index examples. +7. Watch follow-up: rebuild maps from expanded page data so generated pages participate in layout-targeted rebuilds. diff --git a/test-cases/generated-pages/index.test.js b/test-cases/generated-pages/index.test.js new file mode 100644 index 0000000..a72f6fc --- /dev/null +++ b/test-cases/generated-pages/index.test.js @@ -0,0 +1,144 @@ +import { test } from 'node:test' +import assert from 'node:assert' +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' +import { dirname, join } from 'node:path' +import { tmpdir } from 'node:os' +import * as cheerio from 'cheerio' +import { DomStack, testBuild } from '../../index.js' + +const __dirname = import.meta.dirname + +/** + * @param {string} src + * @param {string} relname + * @param {string} content + */ +async function writeFixtureFile (src, relname, content) { + const filepath = join(src, relname) + await mkdir(dirname(filepath), { recursive: true }) + await writeFile(filepath, content) +} + +/** + * @param {Record} files + * @param {(paths: { src: string, dest: string }) => Promise} run + */ +async function withTempFixture (files, run) { + const root = await mkdtemp(join(tmpdir(), 'domstack-generated-pages-')) + const src = join(root, 'src') + const dest = join(root, 'dist') + await mkdir(src, { recursive: true }) + + for (const [relname, content] of Object.entries(files)) { + await writeFixtureFile(src, relname, content) + } + + try { + await run({ src, dest }) + } finally { + await rm(root, { recursive: true, force: true }) + } +} + +const minimalRootLayout = `export default function rootLayout ({ vars, children }) { + return '' + vars.title + '
' + children + '
' +} +` + +const minimalGlobalVars = `export default { layout: 'root', title: 'Test' } +` + +/** + * @param {unknown} error + * @returns {string} + */ +function aggregateErrorMessage (error) { + if (!(error instanceof Error)) return String(error) + const aggregate = /** @type {Error & { errors?: Error[] }} */ (error) + return String(aggregate.errors?.[0]?.message ?? aggregate.message) +} + +test.describe('generated pages', () => { + test('builds generated pages through layouts and exposes them to global data and templates', async (t) => { + const src = join(__dirname, './src') + const build = await testBuild(src) + const { results, readOutput } = build + + t.after(async () => { + await build.cleanup() + }) + + assert.equal(results.siteData.pagesFiles.length, 4, 'four pages files are discovered') + + const redirectHtml = await readOutput('old-url/index.html') + assert.match(redirectHtml, //, 'redirect page renders through redirect layout') + assert.match(redirectHtml, /\/new-url\/<\/a>/, 'redirect target is rendered') + + const blogIndexHtml = await readOutput('blog/2024/index.html') + const blogIndexDoc = cheerio.load(blogIndexHtml) + assert.equal(blogIndexDoc('#post-count').text(), '1', 'generated blog index can inspect concrete blog pages') + + const introspectionHtml = await readOutput('generated-introspection/index.html') + const introspectionDoc = cheerio.load(introspectionHtml) + assert.equal(introspectionDoc('#saw-generated').text(), 'false', 'pages files receive concrete pages only') + assert.equal(introspectionDoc('meta[name="generated-page-count"]').attr('content'), '4', 'global.data sees generated pages after pages files run') + + const stylesheetHrefs = Array.from(introspectionDoc('link[rel="stylesheet"]')).map(link => introspectionDoc(link).attr('href') ?? '') + assert.ok(stylesheetHrefs.some(href => href.startsWith('/global-') && href.endsWith('.css')), 'generated page includes global stylesheet') + assert.ok(stylesheetHrefs.some(href => href.startsWith('/root.layout-') && href.endsWith('.css')), 'generated page includes layout stylesheet') + assert.ok(!stylesheetHrefs.some(href => href.startsWith('./style-')), 'generated page does not include page-local stylesheet') + + const scriptSrcs = Array.from(introspectionDoc('script[type="module"]')).map(script => introspectionDoc(script).attr('src') ?? '') + assert.ok(scriptSrcs.some(src => src.startsWith('/global.client-') && src.endsWith('.js')), 'generated page includes global client') + assert.ok(scriptSrcs.some(src => src.startsWith('/root.layout.client-') && src.endsWith('.js')), 'generated page includes layout client') + assert.ok(!scriptSrcs.some(src => src.startsWith('./client-')), 'generated page does not include page-local client') + + const asyncHtml = await readOutput('async-generated/index.html') + assert.match(asyncHtml, /async generated page/, 'async iterable pages files are supported') + + const summary = JSON.parse(await readOutput('summary.json')) + assert.equal(summary.generatedPageCount, 4, 'template vars include global.data generated page count') + assert.equal(summary.generatedPagesInTemplate, 4, 'template pages include generated pages') + }) + + test('throws a conflict error for generated pages that collide with concrete pages', async () => { + await withTempFixture({ + 'root.layout.js': minimalRootLayout, + 'global.vars.js': minimalGlobalVars, + 'README.md': '# Concrete root page\n', + 'conflict.pages.js': `export default function () { + return { outputName: 'index.html', vars: { title: 'Generated root' }, children: 'generated' } +} +`, + }, async ({ src, dest }) => { + const domstack = new DomStack(src, dest) + await assert.rejects( + () => domstack.build(), + error => { + assert.match(aggregateErrorMessage(error), /Output path conflict/) + return true + } + ) + }) + }) + + test('throws a clear error for invalid generated page paths', async () => { + await withTempFixture({ + 'root.layout.js': minimalRootLayout, + 'global.vars.js': minimalGlobalVars, + 'invalid.pages.js': `export default function () { + return { outputName: '../outside/index.html', vars: { title: 'Invalid' }, children: 'invalid' } +} +`, + }, async ({ src, dest }) => { + const domstack = new DomStack(src, dest) + await assert.rejects( + () => domstack.build(), + error => { + assert.match(aggregateErrorMessage(error), /must not contain "\.\." segments/) + return true + } + ) + }) + }) +}) diff --git a/test-cases/generated-pages/src/README.md b/test-cases/generated-pages/src/README.md new file mode 100644 index 0000000..dd2f75c --- /dev/null +++ b/test-cases/generated-pages/src/README.md @@ -0,0 +1,7 @@ +--- +title: Home +--- + +# Home + +Concrete home page. diff --git a/test-cases/generated-pages/src/async.pages.js b/test-cases/generated-pages/src/async.pages.js new file mode 100644 index 0000000..bcccc18 --- /dev/null +++ b/test-cases/generated-pages/src/async.pages.js @@ -0,0 +1,10 @@ +export default async function * asyncPages () { + yield { + outputName: 'async-generated/index.html', + vars: { + layout: 'root', + title: 'Async generated', + }, + children: '

async generated page

', + } +} diff --git a/test-cases/generated-pages/src/blog/post-one/page.md b/test-cases/generated-pages/src/blog/post-one/page.md new file mode 100644 index 0000000..2c853f8 --- /dev/null +++ b/test-cases/generated-pages/src/blog/post-one/page.md @@ -0,0 +1,8 @@ +--- +title: Post One +publishDate: 2024-01-02 +--- + +# Post One + +A concrete blog post. diff --git a/test-cases/generated-pages/src/concrete-only.pages.js b/test-cases/generated-pages/src/concrete-only.pages.js new file mode 100644 index 0000000..eb532fb --- /dev/null +++ b/test-cases/generated-pages/src/concrete-only.pages.js @@ -0,0 +1,14 @@ +// @ts-nocheck + +export default function concreteOnlyPages ({ pages }) { + return { + outputName: 'generated-introspection/index.html', + vars: { + layout: 'root', + title: 'Generated introspection', + sawGenerated: pages.some(page => page.pageInfo.type === 'generated'), + concreteCount: pages.length, + }, + children: ({ vars }) => `

${vars.sawGenerated}

${vars.concreteCount}

`, + } +} diff --git a/test-cases/generated-pages/src/global.client.js b/test-cases/generated-pages/src/global.client.js new file mode 100644 index 0000000..8e4ffaa --- /dev/null +++ b/test-cases/generated-pages/src/global.client.js @@ -0,0 +1 @@ +globalThis.generatedPagesGlobalClient = true diff --git a/test-cases/generated-pages/src/global.css b/test-cases/generated-pages/src/global.css new file mode 100644 index 0000000..0be0f75 --- /dev/null +++ b/test-cases/generated-pages/src/global.css @@ -0,0 +1 @@ +body { font-family: system-ui, sans-serif; } diff --git a/test-cases/generated-pages/src/global.data.js b/test-cases/generated-pages/src/global.data.js new file mode 100644 index 0000000..482ae9d --- /dev/null +++ b/test-cases/generated-pages/src/global.data.js @@ -0,0 +1,7 @@ +// @ts-nocheck + +export default function globalData ({ pages }) { + return { + generatedPageCount: pages.filter(page => page.pageInfo.type === 'generated').length, + } +} diff --git a/test-cases/generated-pages/src/global.vars.js b/test-cases/generated-pages/src/global.vars.js new file mode 100644 index 0000000..a62f359 --- /dev/null +++ b/test-cases/generated-pages/src/global.vars.js @@ -0,0 +1,4 @@ +export default { + layout: 'root', + siteName: 'Generated Pages Test', +} diff --git a/test-cases/generated-pages/src/indexes.pages.js b/test-cases/generated-pages/src/indexes.pages.js new file mode 100644 index 0000000..9df5a2f --- /dev/null +++ b/test-cases/generated-pages/src/indexes.pages.js @@ -0,0 +1,15 @@ +// @ts-nocheck + +export default function indexesPages ({ pages }) { + const posts = pages.filter(page => page.vars.publishDate && page.pageInfo.path.startsWith('blog/')) + + return { + outputName: 'blog/2024/index.html', + vars: { + layout: 'root', + title: '2024 posts', + postCount: posts.length, + }, + children: ({ vars }) => `

${vars.title}

${vars.postCount}

`, + } +} diff --git a/test-cases/generated-pages/src/redirect.layout.js b/test-cases/generated-pages/src/redirect.layout.js new file mode 100644 index 0000000..0610b73 --- /dev/null +++ b/test-cases/generated-pages/src/redirect.layout.js @@ -0,0 +1,16 @@ +// @ts-nocheck + +export default function redirectLayout ({ vars }) { + return ` + + + + + + ${vars.title} + + +

Redirecting to ${vars.redirectTo}

+ +` +} diff --git a/test-cases/generated-pages/src/redirects.pages.js b/test-cases/generated-pages/src/redirects.pages.js new file mode 100644 index 0000000..fb30f19 --- /dev/null +++ b/test-cases/generated-pages/src/redirects.pages.js @@ -0,0 +1,15 @@ +const redirects = [ + { from: 'old-url', to: '/new-url/' }, +] + +export default function redirectsPages () { + return redirects.map(({ from, to }) => ({ + outputName: `${from}/index.html`, + vars: { + layout: 'redirect', + title: 'Redirecting...', + redirectTo: to, + }, + children: '', + })) +} diff --git a/test-cases/generated-pages/src/root.layout.client.js b/test-cases/generated-pages/src/root.layout.client.js new file mode 100644 index 0000000..dc4b275 --- /dev/null +++ b/test-cases/generated-pages/src/root.layout.client.js @@ -0,0 +1 @@ +globalThis.generatedPagesRootLayoutClient = true diff --git a/test-cases/generated-pages/src/root.layout.css b/test-cases/generated-pages/src/root.layout.css new file mode 100644 index 0000000..edaa3af --- /dev/null +++ b/test-cases/generated-pages/src/root.layout.css @@ -0,0 +1 @@ +main { display: block; } diff --git a/test-cases/generated-pages/src/root.layout.js b/test-cases/generated-pages/src/root.layout.js new file mode 100644 index 0000000..b4a31bb --- /dev/null +++ b/test-cases/generated-pages/src/root.layout.js @@ -0,0 +1,17 @@ +// @ts-nocheck + +export default function rootLayout ({ vars, styles = [], scripts = [], children }) { + return ` + + + + ${vars.title} + ${styles.map(href => ``).join('\n ')} + ${scripts.map(src => ``).join('\n ')} + + + +
${children}
+ +` +} diff --git a/test-cases/generated-pages/src/summary.template.js b/test-cases/generated-pages/src/summary.template.js new file mode 100644 index 0000000..da6d473 --- /dev/null +++ b/test-cases/generated-pages/src/summary.template.js @@ -0,0 +1,11 @@ +// @ts-nocheck + +export default function summaryTemplate ({ pages, vars }) { + return { + outputName: 'summary.json', + content: JSON.stringify({ + generatedPageCount: vars.generatedPageCount, + generatedPagesInTemplate: pages.filter(page => page.pageInfo.type === 'generated').length, + }, null, 2), + } +}