Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
100 changes: 100 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 }) => `<h1>${vars.title}</h1><p>${vars.posts.length} posts</p>`,
}
}
```

`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 `<pages-file-name>/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 `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta http-equiv="refresh" content="0;url=${escapeXml(vars.redirectTo)}" />
<link rel="canonical" href="${escapeXml(vars.redirectTo)}" />
<title>${escapeXml(vars.title)}</title>
</head>
<body>
<p>Redirecting to <a href="${escapeXml(vars.redirectTo)}">${escapeXml(vars.redirectTo)}</a></p>
</body>
</html>`
}

function escapeXml (str) {
return str
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;')
}
```

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.
Expand Down
80 changes: 74 additions & 6 deletions index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -36,6 +36,7 @@ import {
layoutSuffixs,
layoutStyleSuffix,
templateSuffixs,
pagesSuffixs,
globalVarsNames,
globalDataNames,
esbuildSettingsNames,
Expand Down Expand Up @@ -115,6 +116,10 @@ export { PageData } from './lib/build-pages/page-data.js'
* @typedef {TemplateInfo} TemplateInfo
*/

/**
* @typedef {PagesFileInfo} PagesFileInfo
*/

/**
* @template {Record<string, any>} Vars - The type of variables passed to the layout function
* @template [PageReturn=any] PageReturn - The return type of the page function
Expand All @@ -126,6 +131,10 @@ export { PageData } from './lib/build-pages/page-data.js'
* @typedef {GlobalDataFunctionParams} GlobalDataFunctionParams
*/

/**
* @typedef {PagesFunctionParams} PagesFunctionParams
*/

/**
* @template {Record<string, any>} Vars - The type of variables passed to the page function
* @template [PageReturn=any] PageReturn - The return type of the page function
Expand All @@ -137,6 +146,24 @@ export { PageData } from './lib/build-pages/page-data.js'
* @typedef {TemplateFunctionParams<Vars>} TemplateFunctionParams
*/

/**
* @template {Record<string, any>} [Vars=Record<string, any>] - The type of variables for the generated pages function
* @template [Children=any]
* @typedef {PagesFunction<Vars, Children>} PagesFunction
*/

/**
* @template {Record<string, any>} [Vars=Record<string, any>] - The type of variables for the async generated pages function
* @template [Children=any]
* @typedef {AsyncPagesFunction<Vars, Children>} AsyncPagesFunction
*/

/**
* @template {Record<string, any>} [Vars=Record<string, any>] - The type of variables for a generated page
* @template [Children=any]
* @typedef {GeneratedPageDefinition<Vars, Children>} GeneratedPageDefinition
*/

/**
* @typedef TestBuildResult
* @property {string} dest - Temporary destination directory used for the build.
Expand Down Expand Up @@ -181,6 +208,8 @@ export class DomStack {
#pageDepMap = new Map()
/** @type {Map<string, Set<TemplateInfo>>} depFilepath → Set<TemplateInfo> */
#templateDepMap = new Map()
/** @type {Map<string, Set<PagesFileInfo>>} depFilepath → Set<PagesFileInfo> */
#pagesFileDepMap = new Map()
/** @type {Set<string>} absolute filepaths of esbuild entry points */
#esbuildEntryPoints = new Set()

Expand Down Expand Up @@ -533,6 +562,7 @@ export class DomStack {
const layoutFileMap = /** @type {Map<string, string>} */ (new Map())
const pageDepMap = /** @type {Map<string, Set<PageInfo>>} */ (new Map())
const templateDepMap = /** @type {Map<string, Set<TemplateInfo>>} */ (new Map())
const pagesFileDepMap = /** @type {Map<string, Set<PagesFileInfo>>} */ (new Map())

// layoutFileMap: layout filepath → layoutName
for (const layout of Object.values(siteData.layouts)) {
Expand Down Expand Up @@ -616,6 +646,20 @@ export class DomStack {
}
}

// pagesFileDepMap: dep filepath → Set<PagesFileInfo>
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<string>} */ (new Set())
if (siteData.globalClient) esbuildEntryPoints.add(resolve(siteData.globalClient.filepath))
Expand All @@ -638,6 +682,7 @@ export class DomStack {
this.#layoutFileMap = layoutFileMap
this.#pageDepMap = pageDepMap
this.#templateDepMap = templateDepMap
this.#pagesFileDepMap = pagesFileDepMap
this.#esbuildEntryPoints = esbuildEntryPoints
}

Expand Down Expand Up @@ -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)
Expand All @@ -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) {
Expand All @@ -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) {
Expand All @@ -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) {
Expand All @@ -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) {
Expand All @@ -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.`)
}

Expand Down
Loading