diff --git a/README.md b/README.md index 1c432793..d20f6958 100644 --- a/README.md +++ b/README.md @@ -136,7 +136,7 @@ src % tree │ ├── global.client.ts # you can define a global client that loads on every page. │ ├── global.css # you can define a global css file that loads on every page. │ ├── global.vars.ts # site wide variables get defined in global.vars.ts -│ ├── global.data.ts # optional file to derive and aggregate data from all pages before rendering +│ ├── global.data.ts # optional file to derive and aggregate data from source-backed pages │ ├── markdown-it.settings.ts # You can customize the markdown-it instance used to render markdown │ ├── domstack-manifest.settings.ts # You can customize the domstack manifest │ ├── esbuild.settings.ts # You can even customize the build settings passed to esbuild @@ -1076,9 +1076,9 @@ Any `PageData` instance exposes two methods for accessing rendered output: - `await page.renderInnerPage({ pages })` returns the page's inner render output as produced by its builder, without a layout wrapper applied. This is often an HTML string (for example, markdown rendered to HTML), but the type depends on the page builder. - `await page.renderFullPage({ pages })` returns the complete page output with its layout applied. -Both methods are async and require the full `pages` array. They are available inside templates, in `global.data.js`, inside page functions, and inside layouts. +Both methods are async and require the `pages` array available at that build stage. Templates, page functions, and layouts receive the final source-backed plus generated collection; `global.data.js` receives the source-backed collection before generated pages are created. -These methods also require that the `PageData` instance was successfully initialized first. When iterating the full `pages` array -- especially in `global.data.js` -- some entries may represent pages that failed initialization before the build aborts, and calling `renderInnerPage()` or `renderFullPage()` on those pages will throw. +These methods also require that the `PageData` instance was successfully initialized first. When iterating a `pages` array, some entries may represent pages that failed initialization before the build aborts, and calling `renderInnerPage()` or `renderFullPage()` on those pages will throw. For templates that render many pages, pre-render in parallel and cache results to avoid doing the same work twice when producing several output files from one template: @@ -1094,6 +1094,265 @@ 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 or derived data, 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/derived vars received by the factory: + +Prepare reusable collections in `global.data.*`, then keep the pages factory focused on turning those records into page definitions. Layouts remain responsible for rendering the HTML: + +```ts +// src/blog-indexes.pages.ts +import type { PagesFunction } from '@domstack/static/types.js' + +type BlogPost = { + title: string + url: string + publishDate: string +} + +type BlogIndex = { + year: number + posts: BlogPost[] +} + +type CollectionVars = { + siteName: string + blogIndexes: BlogIndex[] // grouped and sorted by global.data.ts +} + +type BlogIndexVars = { + layout: string + title: string + posts: BlogPost[] +} + +const blogIndexes: PagesFunction = ({ vars }) => { + const pages = [] + + for (const { year, posts } of vars.blogIndexes) { + pages.push({ + outputName: `blog/${year}/index.html`, + vars: { + layout: 'blog-index', + title: `${vars.siteName}: ${year} posts`, + posts, + }, + }) + } + + return pages +} + +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' + +type ArchiveVars = { layout: string, year: number } +type CollectionVars = { blogYears: number[] } + +const archivePages: PagesFunction = async function * ({ vars }) { + for (const year of vars.blogYears) { + 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 plus the values returned by `global.data.*`. | +| `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 and the same derived global data, 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 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 name a file, must not be absolute or contain `..` segments, and cannot end in a path separator. Defaults to `/index.html`. | +| `vars` | Page-level vars merged with the normal default, global, layout, and builder vars. | +| `children` | Optional static child content or inline `PageFunction` rendered before the layout. When omitted or explicitly `undefined`, the page renders empty child content 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. Keep that history on the current page with `redirectFrom` metadata instead of maintaining a separate old/new mapping: + +```md +--- +title: Current Post +redirectFrom: + - /2020/old-slug/ + - /blog/original-title/ +--- + +# Current Post +``` + +Collect the metadata in `global.data.js`. The current page's URL becomes the redirect target automatically: + +```js +// src/global.data.js +function collectRedirects (pages) { + const redirects = [] + const redirectOwners = new Map() + + for (const page of pages) { + const redirectFrom = page.vars.redirectFrom + if (redirectFrom === undefined) continue + + const source = page.pageInfo.pageFile.relname + if (!Array.isArray(redirectFrom)) throw new TypeError(`redirectFrom on "${source}" must be an array`) + + for (const from of redirectFrom) { + if (typeof from !== 'string') throw new TypeError(`redirectFrom entries on "${source}" must be strings`) + if (from.trim() !== from || !from.startsWith('/') || from.startsWith('//')) throw new Error(`Invalid redirectFrom "${from}" on "${source}": expected a same-origin URL path`) + if (from.includes('?') || from.includes('#') || from.includes('\\') || from.split('/').some(part => part === '.' || part === '..')) throw new Error(`Invalid redirectFrom "${from}" on "${source}": unsupported URL path`) + + const existingSource = redirectOwners.get(from) + if (existingSource) throw new Error(`redirectFrom "${from}" is declared by both "${existingSource}" and "${source}"`) + + redirectOwners.set(from, source) + redirects.push({ from, to: page.pageInfo.url }) + } + } + + return redirects +} + +export default function globalData ({ pages }) { + return { redirects: collectRedirects(pages) } +} +``` + +Validation happens while the destination page is still known, so malformed or duplicate metadata reports the page that declared it. The pages factory then consumes the validated collection and renders each old location through a reusable redirect layout: + +```js +// src/redirects.pages.js +function redirectOutputName (from) { + if (!from.startsWith('/') || from.startsWith('//')) throw new Error(`redirectFrom must be a same-origin URL path: ${from}`) + if (from.includes('?') || from.includes('#')) throw new Error(`redirectFrom must not include a query or fragment: ${from}`) + + const relativePath = from.slice(1) + if (relativePath.length === 0) return 'index.html' + return relativePath.endsWith('/') ? `${relativePath}index.html` : relativePath +} + +export default function redirectsPages ({ vars }) { + const pages = [] + + for (const { from, to } of vars.redirects) { + pages.push({ + outputName: redirectOutputName(from), + vars: { + layout: 'redirect', + title: 'Redirecting...', + redirectTo: to, + }, + }) + } + + return pages +} +``` + +```js +// src/redirect.layout.js + +import { html, render } from 'fragtml' + +export default function redirectLayout ({ vars }) { + return render(html` + + + + + + ${vars.title} + + +

Redirecting to ${vars.redirectTo}

+ +`) +} +``` + +`redirectFrom` contains old same-origin public URL paths. `redirectOutputName()` converts directory URLs such as `/2020/old-slug/` to `2020/old-slug/index.html`; DomStack's generated-output validation still rejects escaping paths such as `..`. The redirect target comes from the current page's normalized `pageInfo.url`, so moving the page again only requires retaining its previous URLs in that page's metadata. `fragtml` escapes interpolated values by default, including attribute values and link text. + +**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. + +export default function ({ vars }) { + return { + outputName: '_redirects', + content: vars.redirects.map(({ from, to }) => `${from} ${to} 301`).join('\n'), + } +} +``` + +Both approaches can coexist and consume the same `global.data.js` redirect collection. 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 +1553,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 @@ -1541,9 +1802,9 @@ This is a recommended organization pattern, not a requirement. ### `global.data.js` -The `global.data.js` (or `.ts`, `.mjs`, etc.) file is an optional file that can live anywhere in your `src` tree — like all global assets, the first one found wins and duplicates warn. It runs **once per build**, after all pages are initialized and before rendering begins. +The `global.data.js` (or `.ts`, `.mjs`, etc.) file is an optional file that can live anywhere in your `src` tree — like all global assets, the first one found wins and duplicates warn. It runs **once per build**, after source-backed pages are initialized and before generated-page factories run. -It receives a fully resolved `PageData[]` array and returns an object that is stamped onto every page's vars — making the derived data available to every page, layout, and template. +It receives the fully resolved source-backed `PageData[]` array and returns an object that is passed to generated-page factories and stamped onto every source-backed and generated page's vars. The derived data is therefore available to every page, layout, and template at final render time. ```typescript import type { AsyncGlobalDataFunction } from '@domstack/static/types.js' @@ -1587,7 +1848,7 @@ The returned object is stamped onto every page's vars before rendering, so any p **Key properties of `global.data.js`:** -- Receives fully resolved `PageData[]` — every page has `.vars` (merged global + page + builder vars), `.pageInfo` (path, type, etc.), `.styles`, `.scripts`, and more. +- Receives fully resolved source-backed `PageData[]` — every page has `.vars` (merged global + page + builder vars), `.pageInfo` (path, type, etc.), `.styles`, `.scripts`, and more. Generated pages do not exist yet. - Runs inside the worker process (same as all other dynamic imports) to avoid ESM caching issues. - Skipped entirely if no `global.data.*` file exists — zero overhead. - Changes to `global.data.*` trigger a full page rebuild (same as `global.vars.*`), since the output is stamped onto every page's vars. @@ -1602,7 +1863,7 @@ Use `GlobalDataFunction` or `AsyncGlobalDataFunction` to type the function **Raw markdown source is not exposed as `page.vars.content` by default.** For markdown pages, `page.vars` contains front matter-derived values such as `title`, but does not automatically include the raw markdown body as `content`. If you need the raw markdown body, call `await page.readMarkdownContent()`. For rendered output, see [Accessing rendered page content](#accessing-rendered-page-content). -**`renderInnerPage()` is available.** `global.data.js` runs after page initialization has been attempted, and receives `PageData` instances (some may be uninitialized if they failed to initialize), so you can call `renderInnerPage()` here with the same care described above for `page.vars` and other page-dependent access. For examples and performance guidance, see [Accessing rendered page content](#accessing-rendered-page-content). +**`renderInnerPage()` is available.** `global.data.js` runs after source-backed page initialization has been attempted, and receives source-backed `PageData` instances (some may be uninitialized if they failed to initialize), so you can call `renderInnerPage()` here with the same care described above for `page.vars` and other page-dependent access. For examples and performance guidance, see [Accessing rendered page content](#accessing-rendered-page-content). ### `domstack-manifest.settings.ts` @@ -1613,7 +1874,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 { @@ -1804,13 +2065,13 @@ Pages and Layouts receive an object with the following parameters: Template files receive a similar set of variables: -- `vars`: An object with the variables of `global.vars.ts` +- `vars`: An object with the variables from `global.vars.ts` and `global.data.js` - `pages`: An array of [`PageData`](https://github.com/bcomnes/domstack/blob/master/lib/build-pages/page-data.js) instances for every page in the site build. Use this array to introspect pages to generate feeds and index pages. - `template`: An object of the template file data being rendered. ### Derived global data (Advanced) -For data that aggregates across multiple pages — like blog indexes, sitemaps, or RSS feed content — use [`global.data.js`](#globaldatajs). That file runs once per build, receives the raw page list, and merges its return value into `globalVars` so every page, layout, and template can read it via `vars`. +For data that aggregates across multiple pages — like blog indexes, sitemaps, or RSS feed content — use [`global.data.js`](#globaldatajs). That file runs once per build, receives the source-backed page list, and makes its return value available to generated-page factories and every page, layout, and template via `vars`. See the [`global.data.js`](#globaldatajs) section under [Global Assets](#global-assets) for a full example. @@ -1872,22 +2133,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 +2165,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 +2178,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 @@ -2199,15 +2469,20 @@ The `buildPages()` step processes pages in parallel with a concurrency limit: └──────────────────────┼──────────────────────┘ │ ▼ - ┌───────────────────────┐ - │ global.data.js runs │ - │ (receives PageData[])│ - │ stamps vars on pages │ - └───────────┬───────────┘ - │ - ▼ + ┌─────────────────────────────┐ + │ global.data.js runs │ + │ (receives source PageData[])│ + └──────────────┬──────────────┘ + │ + ▼ + ┌─────────────────────────────┐ + │ *.pages.* generates pages │ + │ using the derived data │ + └──────────────┬──────────────┘ + │ + ▼ ┌───────────────────────────────┐ - │ Parallel Render + Write │ + │ Stamp data, render + write │ │ (Concurrency: min(CPUs, 24)) │ └───────────────────────────────┘ ``` @@ -2215,7 +2490,7 @@ The `buildPages()` step processes pages in parallel with a concurrency limit: Variable Resolution Layers, from lowest to highest precedence: - **Domstack defaults** - Internal defaults such as the default `layout: 'root'`. - **Global vars** - Site-wide variables from `global.vars.js` (resolved once). -- **Global data** - Derived variables from `global.data.js`, stamped onto every page after all pages initialize. +- **Global data** - Derived variables from `global.data.js`, resolved from source-backed pages before generated-page factories run and available to every page at final render time. - **Layout vars** - Optional `export const vars` from the selected layout module. - **Page-specific vars** vary by type: - **MD pages**: `page.vars.js` plus builder vars from frontmatter. diff --git a/docs/v11-migration.md b/docs/v11-migration.md index d3925c47..6a30d0e2 100644 --- a/docs/v11-migration.md +++ b/docs/v11-migration.md @@ -194,7 +194,7 @@ export default async function ({ pages }) { Key differences: - There is only **one** `global.data.js` per project (the first one found wins; duplicates emit a warning) - The function is the **default export**, not a named `postVars` export -- The returned data is stamped onto **every** page's vars (same behavior as `postVars` was) +- The returned data is available to generated-page factories and stamped onto **every** page's vars (same final-render behavior as `postVars` had) - The types `PostVarsFunction` and `AsyncPostVarsFunction` are replaced by `GlobalDataFunction` and `AsyncGlobalDataFunction` --- @@ -205,7 +205,7 @@ Two new filenames are now recognized and processed by domstack. If you have exis ### `global.data.js` (and `.ts`, `.mjs`, `.mts`, `.cjs`, `.cts`) -Now treated as the global data aggregation file. Its default export is called with `{ pages }` after all pages are initialized. See [section 7](#7-postvars-removed--globaldatajs) above. +Now treated as the global data aggregation file. Its default export is called with `{ pages }` after source-backed pages are initialized and before generated-page factories run. See [section 7](#7-postvars-removed--globaldatajs) above. ### `markdown-it.settings.js` (and `.ts`, `.mjs`, `.mts`, `.cjs`, `.cts`) diff --git a/examples/blog/package.json b/examples/blog/package.json index 40e048d2..680d464f 100644 --- a/examples/blog/package.json +++ b/examples/blog/package.json @@ -1,7 +1,7 @@ { "name": "@domstack/blog-example", "version": "0.0.0", - "description": "A blog example for domstack demonstrating global.data.ts, nested layouts, and feeds.", + "description": "A blog example for domstack demonstrating generated archives and redirects, global.data.ts, nested layouts, and feeds.", "type": "module", "scripts": { "start": "npm run watch", diff --git a/examples/blog/src/blog-indexes.pages.ts b/examples/blog/src/blog-indexes.pages.ts new file mode 100644 index 00000000..b5efc1f9 --- /dev/null +++ b/examples/blog/src/blog-indexes.pages.ts @@ -0,0 +1,31 @@ +import type { PagesFunction } from '@domstack/static/types.js' +import type { GlobalData } from './global.data.js' + +type YearIndexPageVars = { + layout: 'year-index' + title: string + posts: GlobalData['blogPosts'] +} + +/** + * Turn the yearly groups prepared by global.data.ts into normal pages. + * The year-index layout renders the posts already assigned to each archive. + */ +const blogIndexes: PagesFunction = ({ vars }) => { + const pages = [] + + for (const { year, posts } of vars.blogIndexes) { + pages.push({ + outputName: `blog/${year}/index.html`, + vars: { + layout: 'year-index' as const, + title: String(year), + posts, + }, + }) + } + + return pages +} + +export default blogIndexes diff --git a/examples/blog/src/blog/2024/README.md b/examples/blog/src/blog/2024/README.md deleted file mode 100644 index 41141f6f..00000000 --- a/examples/blog/src/blog/2024/README.md +++ /dev/null @@ -1,4 +0,0 @@ ---- -title: "2024" -layout: year-index ---- diff --git a/examples/blog/src/blog/2024/hello-world/README.md b/examples/blog/src/blog/2024/hello-world/README.md index 50fb849c..34fa9784 100644 --- a/examples/blog/src/blog/2024/hello-world/README.md +++ b/examples/blog/src/blog/2024/hello-world/README.md @@ -3,6 +3,8 @@ layout: post title: "Hello, World" publishDate: "2024-03-15T12:00:00.000Z" description: "The first post on this blog. An introduction to what this is all about." +redirectFrom: + - /blog/hello-world/ tags: - meta - intro @@ -21,14 +23,24 @@ They're generated at build time by `global.data.ts`: ```ts // src/global.data.ts -const blogPosts = pages - .filter(p => p.vars?.layout === 'post' && p.vars?.publishDate) - .sort((a, b) => new Date(b.publishDate) - new Date(a.publishDate)) +const blogPosts = collectBlogPosts(pages) +const blogIndexes = collectBlogIndexes(blogPosts) + +return { blogPosts, blogIndexes /* ...other site data */ } ``` The returned object is stamped onto every page's `vars`, so any page or layout can read `vars.blogPosts` directly — no postVars, no custom wiring. +The yearly `/blog/2024/` and `/blog/2025/` archives are generated separately by +`src/blog-indexes.pages.ts`. `global.data.ts` groups the posts by year once, the pages file +turns those groups into normal pages, and the `year-index` layout renders each group's +newest-first posts. There are no hand-maintained year index files. + +This page also owns its old `/blog/hello-world/` location through the `redirectFrom` +frontmatter above. `global.data.ts` collects that metadata, and `redirects.pages.ts` +generates the redirect to this page's current URL. + ## This layout This post uses the `post` layout (`src/layouts/post.layout.ts`), which wraps the root layout diff --git a/examples/blog/src/blog/2025/README.md b/examples/blog/src/blog/2025/README.md deleted file mode 100644 index 4ba9420c..00000000 --- a/examples/blog/src/blog/2025/README.md +++ /dev/null @@ -1,4 +0,0 @@ ---- -title: "2025" -layout: year-index ---- diff --git a/examples/blog/src/blog/page.ts b/examples/blog/src/blog/page.ts index 4171ec41..1682d3d2 100644 --- a/examples/blog/src/blog/page.ts +++ b/examples/blog/src/blog/page.ts @@ -1,3 +1,4 @@ +import { basename, dirname } from 'node:path' import { html, render } from 'fragtml' import type { PageFunction } from '@domstack/static/types.js' import type { GlobalData } from '../global.data.js' @@ -10,8 +11,17 @@ type Vars = SiteVars & GlobalData * Post data comes entirely from global.data.ts via vars.blogPosts. * No postVars, no manual wiring needed here. */ -const blogIndex: PageFunction = ({ vars }) => { +const blogIndex: PageFunction = ({ vars, page, pages }) => { const { blogPosts } = vars + const archivePages = [] + + for (const candidate of pages) { + if (dirname(candidate.pageInfo.path) === page.path && candidate.vars['layout'] === 'year-index') { + archivePages.push(candidate) + } + } + + archivePages.sort((a, b) => b.pageInfo.path.localeCompare(a.pageInfo.path)) if (blogPosts.length === 0) { return '

No posts yet.

' @@ -39,6 +49,12 @@ const blogIndex: PageFunction = ({ vars }) => { ` })} +

Archive

+ `) } diff --git a/examples/blog/src/global.data.ts b/examples/blog/src/global.data.ts index 3696d643..0f9973c2 100644 --- a/examples/blog/src/global.data.ts +++ b/examples/blog/src/global.data.ts @@ -1,5 +1,5 @@ import { html, render } from 'fragtml' -import type { AsyncGlobalDataFunction } from '@domstack/static/types.js' +import type { AsyncGlobalDataFunction, GlobalDataFunctionParams } from '@domstack/static/types.js' export interface BlogPost { path: string @@ -9,29 +9,112 @@ export interface BlogPost { tags: string[] } +export interface BlogIndex { + year: number + posts: BlogPost[] +} + +export interface PageRedirect { + /** Old same-origin URL path that should redirect. */ + from: string + /** Current URL of the page that declared the old path. */ + to: string +} + +function collectRedirects (pages: GlobalDataFunctionParams['pages']): PageRedirect[] { + const redirects: PageRedirect[] = [] + const redirectOwners = new Map() + + for (const page of pages) { + const redirectFrom = page.vars.redirectFrom + if (redirectFrom === undefined) continue + + const source = page.pageInfo.pageFile.relname + if (!Array.isArray(redirectFrom)) throw new TypeError(`redirectFrom on "${source}" must be an array of same-origin URL paths`) + + for (const from of redirectFrom) { + if (typeof from !== 'string') throw new TypeError(`redirectFrom entries on "${source}" must be strings`) + if (from.trim() !== from || !from.startsWith('/') || from.startsWith('//')) throw new Error(`Invalid redirectFrom "${from}" on "${source}": expected a same-origin URL path beginning with "/"`) + if (from.includes('?') || from.includes('#')) throw new Error(`Invalid redirectFrom "${from}" on "${source}": queries and fragments are not supported`) + if (from.includes('\\') || from.split('/').some(part => part === '.' || part === '..')) throw new Error(`Invalid redirectFrom "${from}" on "${source}": path must not contain ".", "..", or backslash segments`) + + const existingSource = redirectOwners.get(from) + if (existingSource) { + const detail = existingSource === source + ? `more than once on "${source}"` + : `by both "${existingSource}" and "${source}"` + throw new Error(`redirectFrom "${from}" is declared ${detail}`) + } + + redirectOwners.set(from, source) + redirects.push({ from, to: page.pageInfo.url }) + } + } + + return redirects +} + +function collectBlogPosts (pages: GlobalDataFunctionParams['pages']): BlogPost[] { + const blogPosts: BlogPost[] = [] + + for (const page of pages) { + const publishDateValue = page.vars.publishDate + if (page.vars.layout !== 'post' || (typeof publishDateValue !== 'string' && !(publishDateValue instanceof Date))) continue + + const publishDate = new Date(publishDateValue.valueOf()) + if (Number.isNaN(publishDate.valueOf())) continue + + blogPosts.push({ + path: page.pageInfo.path, + title: String(page.vars.title ?? 'Untitled'), + publishDate: publishDate.toISOString(), + description: String(page.vars.description ?? ''), + tags: Array.isArray(page.vars.tags) ? page.vars.tags as string[] : [], + }) + } + + blogPosts.sort((a, b) => b.publishDate.localeCompare(a.publishDate)) + return blogPosts +} + +function collectBlogIndexes (blogPosts: BlogPost[]): BlogIndex[] { + const postsByYear = new Map() + + for (const post of blogPosts) { + const year = new Date(post.publishDate).getUTCFullYear() + const yearPosts = postsByYear.get(year) ?? [] + yearPosts.push(post) + postsByYear.set(year, yearPosts) + } + + const blogIndexes: BlogIndex[] = [] + for (const [year, posts] of postsByYear) { + blogIndexes.push({ year, posts }) + } + + blogIndexes.sort((a, b) => b.year - a.year) + return blogIndexes +} + export interface GlobalData { /** All blog posts, sorted newest-first. Available to every page and template. */ blogPosts: BlogPost[] + /** Yearly post groups used to generate and render archive pages. */ + blogIndexes: BlogIndex[] /** The 5 most recent posts — used by the home page listing. */ recentPosts: BlogPost[] /** Pre-rendered HTML snippet of recent posts — drop into a page with {{{ vars.recentPostsHtml }}} */ recentPostsHtml: string /** tag → posts index, available for tag archive pages. */ tagIndex: Record + /** Redirects collected from each destination page's redirectFrom metadata. */ + redirects: PageRedirect[] } const buildGlobalData: AsyncGlobalDataFunction = async ({ pages }) => { - const blogPosts: BlogPost[] = pages - .filter(p => p.vars?.layout === 'post' && p.vars?.publishDate) - .map(p => ({ - path: p.pageInfo.path, - title: String(p.vars?.title ?? 'Untitled'), - publishDate: String(p.vars?.publishDate), - description: String(p.vars?.description ?? ''), - tags: Array.isArray(p.vars?.tags) ? (p.vars.tags as string[]) : [], - })) - .sort((a, b) => new Date(b.publishDate).getTime() - new Date(a.publishDate).getTime()) - + const blogPosts = collectBlogPosts(pages) + const blogIndexes = collectBlogIndexes(blogPosts) + const redirects = collectRedirects(pages) const recentPosts = blogPosts.slice(0, 5) // Pre-render an HTML snippet for use on the home page via handlebars {{{ vars.recentPostsHtml }}} @@ -66,7 +149,7 @@ const buildGlobalData: AsyncGlobalDataFunction = async ({ pages }) = } } - return { blogPosts, recentPosts, recentPostsHtml, tagIndex } + return { blogPosts, blogIndexes, recentPosts, recentPostsHtml, tagIndex, redirects } } export default buildGlobalData diff --git a/examples/blog/src/layouts/redirect.layout.ts b/examples/blog/src/layouts/redirect.layout.ts new file mode 100644 index 00000000..0f641cdb --- /dev/null +++ b/examples/blog/src/layouts/redirect.layout.ts @@ -0,0 +1,25 @@ +import { html, render } from 'fragtml' +import type { LayoutFunction } from '@domstack/static/types.js' +import type { RootVars } from './root.layout.js' + +type RedirectVars = RootVars & { + redirectTo: string +} + +const redirectLayout: LayoutFunction = ({ vars }) => { + return render(html` + + + + + + + ${vars.title} + + +

Redirecting to ${vars.redirectTo}

+ +`) +} + +export default redirectLayout diff --git a/examples/blog/src/layouts/year-index.layout.ts b/examples/blog/src/layouts/year-index.layout.ts index 6acc39e9..f57e657a 100644 --- a/examples/blog/src/layouts/year-index.layout.ts +++ b/examples/blog/src/layouts/year-index.layout.ts @@ -1,44 +1,38 @@ import { html, raw, render } from 'fragtml' import type { HtmlResult } from 'fragtml/types.js' -import { dirname } from 'node:path' import type { LayoutFunction } from '@domstack/static/types.js' import rootLayout from './root.layout.ts' import type { RootVars } from './root.layout.ts' -import type { PostVars } from './post.layout.ts' +import type { BlogPost } from '../global.data.ts' -export type YearIndexVars = RootVars & Pick +export type YearIndexVars = RootVars & { + posts?: BlogPost[] +} /** - * Auto-index layout: lists all direct child pages of the current page's - * folder, sorted newest-first by publishDate. Use on year/section index - * pages — just set `layout: year-index` in frontmatter, no page.ts needed. + * Yearly archive layout. `global.data.ts` prepares each newest-first post + * collection and `blog-indexes.pages.ts` assigns it to a generated page. */ const yearIndexLayout: LayoutFunction = (args) => { - const { children, page, pages, ...rest } = args - type DatedPage = (typeof pages)[number] & { vars: YearIndexVars & { publishDate: string } } - - const childPages = pages - .filter((p): p is DatedPage => dirname(p.pageInfo.path) === page.path && typeof p.vars.publishDate === 'string') - .sort((a, b) => new Date(b.vars.publishDate).getTime() - new Date(a.vars.publishDate).getTime()) + const { children, ...rest } = args const wrappedChildren = render(html`

${args.vars.title}

    - ${childPages.map(p => { - const title = p.vars.title ?? 'Untitled' - const date = new Date(p.vars.publishDate) + ${(args.vars.posts ?? []).map(post => { + const date = new Date(post.publishDate) return html`
  • - ${title} + ${post.title}

    - ${p.vars.description ? html`

    ${p.vars.description}

    ` : null} + ${post.description ? html`

    ${post.description}

    ` : null}
  • ` })} @@ -50,7 +44,7 @@ const yearIndexLayout: LayoutFunction `) - return rootLayout({ ...rest, page, pages, children: wrappedChildren }) + return rootLayout({ ...rest, children: wrappedChildren }) } export default yearIndexLayout diff --git a/examples/blog/src/redirects.pages.ts b/examples/blog/src/redirects.pages.ts new file mode 100644 index 00000000..464caae4 --- /dev/null +++ b/examples/blog/src/redirects.pages.ts @@ -0,0 +1,36 @@ +import type { PagesFunction } from '@domstack/static/types.js' +import type { GlobalData } from './global.data.js' + +type RedirectPageVars = { + layout: 'redirect' + title: string + redirectTo: string +} + +function redirectOutputName (from: string): string { + if (!from.startsWith('/') || from.startsWith('//')) throw new Error(`redirectFrom must be a same-origin URL path: ${from}`) + if (from.includes('?') || from.includes('#')) throw new Error(`redirectFrom must not include a query or fragment: ${from}`) + + const relativePath = from.slice(1) + if (relativePath.length === 0) return 'index.html' + return relativePath.endsWith('/') ? `${relativePath}index.html` : relativePath +} + +const redirectPages: PagesFunction = ({ vars }) => { + const pages = [] + + for (const { from, to } of vars.redirects) { + pages.push({ + outputName: redirectOutputName(from), + vars: { + layout: 'redirect' as const, + title: 'Redirecting…', + redirectTo: to, + }, + }) + } + + return pages +} + +export default redirectPages diff --git a/index.js b/index.js index d9345137..253ac837 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,21 @@ ${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 (err) { + const message = err instanceof Error ? err.message : String(err) + this.#logger.debug(`Could not analyze dependencies for pages file "${pagesFileInfo.pagesFile.relname}": ${message}`) + } + } + // 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 +647,7 @@ ${siteData.errors.map(err => ` ${err.message}`).join('\n')}`) this.#layoutFileMap = layoutFileMap this.#pageDepMap = pageDepMap this.#templateDepMap = templateDepMap + this.#pagesFileDepMap = pagesFileDepMap this.#esbuildEntryPoints = esbuildEntryPoints } @@ -616,8 +680,14 @@ ${siteData.errors.map(err => ` ${err.message}`).join('\n')}`) return this.#fullRebuild() } - // 5. markdown-it.settings.* → rebuild all md pages only + // 5. markdown-it.settings.* → rebuild all md pages only, unless generated + // pages may consume their rendered output. if (markdownItSettingsNames.some(n => changedBasename === n)) { + if ((siteData.pagesFiles?.length ?? 0) > 0) { + this.#logger.info(`"${changedBasename}" changed, rebuilding all pages...`) + return this.#runGeneratedPageBuild(siteData) + } + const mdPages = new Set(siteData.pages.filter(p => p.type === 'md')) logRebuildTree(changedBasename, this.#logger, mdPages) return this.#runPageBuild(siteData, Array.from(mdPages).map(p => p.pageFile.filepath), []) @@ -640,6 +710,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 +731,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 +752,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 +778,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 +808,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 +837,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 +950,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..574c0117 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) @@ -29,11 +32,11 @@ const __dirname = import.meta.dirname /** * Parameters passed to a global.data.js default export function. * @typedef {object} GlobalDataFunctionParams - * @property {PageData[]} pages - Fully initialized PageData instances for all pages. + * @property {PageData[]} pages - Fully initialized source-backed pages, before generated pages are created. */ /** - * Synchronous global.data function. Receives initialized PageData[] (with .vars, .pageInfo, etc.) + * Synchronous global.data function. Receives initialized source-backed PageData[] (with .vars, .pageInfo, etc.) * and returns an object stamped onto every page's vars before rendering begins. * * @template {Record} [T=Record] - The shape of the derived vars object returned. @@ -43,7 +46,7 @@ const __dirname = import.meta.dirname */ /** - * Asynchronous global.data function. Receives initialized PageData[] (with .vars, .pageInfo, etc.) + * Asynchronous global.data function. Receives initialized source-backed PageData[] (with .vars, .pageInfo, etc.) * and returns an object stamped onto every page's vars before rendering begins. * * @template {Record} [T=Record] - The shape of the derived vars object returned. @@ -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, global, and global-data vars available to the factory. + * @typedef {object} PagesFunctionParams + * @property {PageData[]} pages - Initialized source-backed pages with global data applied, before generated pages are created. + * @property {T} vars - Default and global vars plus values returned by global.data.*. + * @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 | undefined} [children] - Optional static child content or inline render function. Omitted or undefined children render as empty content. + * @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, global, and global-data 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,178 @@ 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}`) + if (/[\\/]$/.test(value)) throw new Error(`Generated page ${field} must name a file: ${value}`) + + const normalized = normalize(value) + if (!allowEmpty && normalized === '.') throw new Error(`Generated page ${field} must not be empty`) + 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.factoryVars + * @param {boolean | undefined} params.buildDrafts + * @returns {Promise} + */ +async function resolveGeneratedPageInfos ({ siteData, concretePages, factoryVars, 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: factoryVars, + 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 +376,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 +482,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,25 +502,66 @@ 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 }) - // Run global.data.js after all pages are initialized — receives fully resolved PageData[] - // so it can filter/sort by page.vars.layout, page.vars.publishDate, etc. + if (result.errors.length > 0) return result + + // Derive collection data from source-backed pages before generated-page factories run. + // This keeps generated pages downstream while making shared data available to them. const globalDataVars = await resolveGlobalData({ globalDataPath: siteData.globalData?.filepath, - pages, + pages: concretePages, }) - // Stamp globalDataVars onto each page so they appear in page.vars at render time. if (Object.keys(globalDataVars).length > 0) { - for (const page of pages) { + for (const page of concretePages) { page.globalDataVars = globalDataVars } } + const pagesFactoryVars = { ...globalVars, ...globalDataVars } + + let generatedPageInfos = /** @type {PageInfo[]} */ ([]) + try { + generatedPageInfos = await resolveGeneratedPageInfos({ + siteData, + concretePages, + factoryVars: pagesFactoryVars, + 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 + + const generatedPages = await pMap(generatedPageInfos, initPageData, { concurrency: MAX_CONCURRENCY }) + + if (Object.keys(globalDataVars).length > 0) { + for (const page of generatedPages) { + page.globalDataVars = globalDataVars + } + } + + const pages = [...concretePages, ...generatedPages] + if (result.errors.length > 0) return result /** @type {[number, number]} Divided concurrency valus */ @@ -316,9 +593,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..8684456e 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, 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..d8626f36 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. */ @@ -44,14 +44,15 @@ export async function resolveVars ({ */ /** - * Resolve and call a global.data.js file with the initialized PageData array. + * Resolve and call a global.data.js file with initialized source-backed pages. * Receives fully resolved PageData instances (with .vars, .pageInfo, etc.) so * that global.data.js can filter and aggregate by layout, publishDate, title, etc. + * Generated pages are created afterward and receive the returned data. * 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 {PageData[]} params.pages - Initialized PageData array. + * @param {string | undefined} [params.globalDataPath] - Path to the global.data file. + * @param {PageData[]} params.pages - Initialized source-backed PageData array. * @returns {Promise} */ export async function resolveGlobalData ({ globalDataPath, pages }) { @@ -75,7 +76,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..576763f5 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,8 +431,8 @@ 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 {string} [params.manifestRole] - Explicit user-provided role for this output. + * @param {Record} [params.pageVars] - Copyable top-level page variables available to manifest option transforms. + * @param {string | undefined} [params.manifestRole] - Explicit user-provided role for this output. * @param {Record} [params.manifestVars] - Explicit page/app variables to expose on this manifest entry. * @param {DomstackManifestEntryPageMeta} [params.page] - Page metadata to copy onto page entries. 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..8184dc04 --- /dev/null +++ b/plans/generated-pages.md @@ -0,0 +1,604 @@ +# Generated Pages Files + +## Status: Implementation review — ready to land + +Plan for adding first-class generated page support in response to the redirect-page discussion in PR #253. + +## PR #253 implementation review + +Originally reviewed at commit `78d012e` on 2026-08-29. Follow-up fixes were completed during the review. + +### Verdict + +Ready to land. The worker boundary, generated-output lifecycle, watch behavior, error reporting, public data model, types, and documentation findings have been resolved. Generated pages remain close to regular pages while using a stable source-backed input set plus shared derived data from `global.data.*`. + +### 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 +} + +/** + * @param {any[]} pages + */ +function collectRedirects (pages) { + const data = globalData(/** @type {any} */ ({ pages })) + if (data instanceof Promise) throw new TypeError('Expected synchronous global data') + return data.redirects +} + +test.describe('generated pages', () => { + test('validates page-owned redirect metadata with destination context', () => { + /** + * @param {string} relname + * @param {string} url + * @param {unknown} redirectFrom + */ + const page = (relname, url, redirectFrom) => /** @type {any} */ ({ + vars: { redirectFrom }, + pageInfo: { path: relname.replace(/\/README\.md$/, ''), url, pageFile: { relname } }, + }) + + assert.deepEqual(collectRedirects([ + page('current/README.md', '/current/', ['/old/', '/older/']), + ]), [ + { from: '/old/', to: '/current/' }, + { from: '/older/', to: '/current/' }, + ]) + + assert.throws( + () => collectRedirects([page('string/README.md', '/string/', '/old/')]), + /redirectFrom on "string\/README\.md" must be an array/ + ) + assert.throws( + () => collectRedirects([page('number/README.md', '/number/', [42])]), + /redirectFrom entries on "number\/README\.md" must be strings/ + ) + + for (const redirectFrom of ['https://example.com/old/', '//example.com/old/', '/old/?draft=true', '/../escape/']) { + assert.throws( + () => collectRedirects([page('invalid/README.md', '/invalid/', [redirectFrom])]), + error => error instanceof Error && error.message.includes(redirectFrom) && error.message.includes('invalid/README.md') + ) + } + + assert.throws( + () => collectRedirects([ + page('first/README.md', '/first/', ['/shared-old/']), + page('second/README.md', '/second/', ['/shared-old/']), + ]), + /redirectFrom "\/shared-old\/" is declared by both "first\/README\.md" and "second\/README\.md"/ + ) + }) + + test('builds generated pages from global data and exposes the final page set to 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, 7, 'siteData.pages contains the seven 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: 'legacy-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`) + const destinationHtml = await readOutput(destination) + assert.match(destinationHtml, new RegExp(`]*>${heading}`), `${to} is backed by a concrete page`) + assert.match(destinationHtml, //, `${to} receives global data at final render time`) + } + + const blog2024IndexDoc = cheerio.load(await readOutput('blog/2024/index.html')) + const blog2024Links = blog2024IndexDoc('.blog-entry-link').toArray().map(link => ({ + href: blog2024IndexDoc(link).attr('href'), + title: blog2024IndexDoc(link).text().trim(), + })) + const blog2024Dates = blog2024IndexDoc('.blog-entry-date').toArray().map(time => blog2024IndexDoc(time).text().trim()) + assert.deepEqual(blog2024Links, [ + { href: '/blog/2024/post-two/', title: 'Post Two' }, + { href: '/blog/2024/post-one/', title: 'Post One' }, + ], 'generated yearly indexes link concrete posts newest-first') + assert.deepEqual(blog2024Dates, ['2024-06-15', '2024-01-02'], 'generated yearly indexes render publication dates') + + const blog2023IndexDoc = cheerio.load(await readOutput('blog/2023/index.html')) + assert.deepEqual(blog2023IndexDoc('.blog-entry-link').toArray().map(link => ({ + href: blog2023IndexDoc(link).attr('href'), + title: blog2023IndexDoc(link).text().trim(), + })), [ + { href: '/blog/2023/older-post/', title: 'Older Post' }, + ], 'a generated index is created for each year with posts') + + 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="source-page-count"]').attr('content'), '7', 'global.data sees source-backed pages before 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.sourcePageCount, 7, 'template vars include global.data source page count') + assert.equal(summary.blogPostCount, 3, 'template vars include the collection used by pages files') + assert.equal(summary.generatedPagesInTemplate, 8, '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('builds generated drafts when buildDrafts is enabled', async () => { + await withTempFixture({ + 'root.layout.js': minimalRootLayout, + 'global.vars.js': minimalGlobalVars, + 'draft.pages.js': `export default { + outputName: 'draft/index.html', + draft: true, + children: '

    generated draft

    ', +} +`, + }, async ({ src, dest }) => { + const domstack = new DomStack(src, dest, { buildDrafts: true }) + await domstack.build() + + assert.match(await readFile(join(dest, 'draft/index.html'), 'utf8'), /generated draft/) + }) + }) + + test('returns copyable generated vars and keeps global-data PageData values inside the worker', async () => { + await withTempFixture({ + 'root.layout.js': minimalRootLayout, + 'global.vars.js': minimalGlobalVars, + 'global.data.js': `export default function globalData ({ pages }) { + return { posts: pages } +} +`, + 'README.md': '# Concrete page\n', + 'indexes.pages.js': `export default function indexesPages ({ vars }) { + return { + outputName: 'generated-index/index.html', + vars: { + title: 'Generated index', + posts: vars.posts, + }, + 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 the PageData collection from global.data') + 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('rejects generated output names that do not name a file', async () => { + for (const outputName of ['.', './', 'nested/']) { + await withTempFixture({ + 'root.layout.js': minimalRootLayout, + 'global.vars.js': minimalGlobalVars, + 'invalid.pages.js': `export default { outputName: ${JSON.stringify(outputName)}, children: 'invalid' }\n`, + }, 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 be empty|must name a file/) + assert.match(generatedError.message, /pages file: "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 Markdown settings change in watch mode', { timeout: 15_000 }, async () => { + const markdownSettings = (/** @type {string} */ version) => `export default function (md) { + md.renderer.rules.paragraph_open = () => '

    ' + return md +} +` + + await withTempFixture({ + 'root.layout.js': minimalRootLayout, + 'global.vars.js': minimalGlobalVars, + 'post.md': 'Rendered post\n', + 'markdown-it.settings.js': markdownSettings('first'), + 'markdown-summary.pages.js': `export default async function ({ pages }) { + const post = pages.find(page => page.pageInfo.pageFile.relname === 'post.md') + if (!post) throw new Error('Missing Markdown post') + const children = await post.renderInnerPage({ pages }) + return { outputName: 'summary/index.html', children } +} +`, + }, async ({ src, dest }) => { + const domstack = new DomStack(src, dest) + const outputPath = join(dest, 'summary/index.html') + + try { + await domstack.watch({ serve: false }) + assert.match(await readFile(outputPath, 'utf8'), /data-version="first"/) + + await writeFile(join(src, 'markdown-it.settings.js'), markdownSettings('second')) + await new Promise(resolve => setTimeout(resolve, 800)) + await domstack.settled() + + const updatedOutput = await readFile(outputPath, 'utf8') + assert.match(updatedOutput, /data-version="second"/) + assert.doesNotMatch(updatedOutput, /data-version="first"/) + } 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..668185f7 --- /dev/null +++ b/test-cases/generated-pages/src/about/README.md @@ -0,0 +1,9 @@ +--- +title: About +redirectFrom: + - /company/ +--- + +# 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-index.layout.js b/test-cases/generated-pages/src/blog-index.layout.js new file mode 100644 index 00000000..69641ae5 --- /dev/null +++ b/test-cases/generated-pages/src/blog-index.layout.js @@ -0,0 +1,44 @@ +/** + * @import { LayoutFunction } from '#types' + * @import { HtmlResult } from 'fragtml/types.js' + */ + +import { html } from 'fragtml' +import rootLayout from './root.layout.js' + +/** + * @typedef {object} BlogPost + * @property {string} path + * @property {string} url + * @property {string} title + * @property {string} publishDate + */ + +/** + * @typedef {object} IndexVars + * @property {string} title + * @property {BlogPost[] | undefined} [posts] + * @property {number} [sourcePageCount] + */ + +/** @type {LayoutFunction} */ +export default function blogIndexLayout (args) { + const { vars } = args + const children = html` +

    ${vars.title}

    +
      + ${(vars.posts ?? []).map(post => { + const publishDate = new Date(post.publishDate) + + return html` +
    • + ${post.title} + +
    • + ` + })} +
    + ` + + return rootLayout({ ...args, children }) +} diff --git a/test-cases/generated-pages/src/blog/2023/older-post/page.md b/test-cases/generated-pages/src/blog/2023/older-post/page.md new file mode 100644 index 00000000..794a6a58 --- /dev/null +++ b/test-cases/generated-pages/src/blog/2023/older-post/page.md @@ -0,0 +1,8 @@ +--- +title: Older Post +publishDate: 2023-03-04 +--- + +# Older Post + +An older concrete blog post. diff --git a/test-cases/generated-pages/src/blog/2024/post-one/page.md b/test-cases/generated-pages/src/blog/2024/post-one/page.md new file mode 100644 index 00000000..2c853f82 --- /dev/null +++ b/test-cases/generated-pages/src/blog/2024/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/blog/2024/post-two/page.md b/test-cases/generated-pages/src/blog/2024/post-two/page.md new file mode 100644 index 00000000..4e2db03b --- /dev/null +++ b/test-cases/generated-pages/src/blog/2024/post-two/page.md @@ -0,0 +1,8 @@ +--- +title: Post Two +publishDate: 2024-06-15 +--- + +# Post Two + +A newer 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..b52e309c --- /dev/null +++ b/test-cases/generated-pages/src/global.data.js @@ -0,0 +1,133 @@ +/** + * @import { GlobalDataFunction, PageData } from '#types' + */ + +/** + * @typedef {object} BlogPost + * @property {string} path + * @property {string} url + * @property {string} title + * @property {string} publishDate + */ + +/** + * @typedef {object} BlogIndex + * @property {string} year + * @property {BlogPost[]} posts + */ + +/** + * @typedef {object} Redirect + * @property {string} from + * @property {string} to + */ + +/** + * @typedef {object} BlogData + * @property {BlogPost[]} blogPosts + * @property {BlogIndex[]} blogIndexes + * @property {Redirect[]} redirects + * @property {number} sourcePageCount + */ + +/** + * @param {PageData[]} pages + * @returns {Redirect[]} + */ +function collectRedirects (pages) { + /** @type {Redirect[]} */ + const redirects = [] + /** @type {Map} */ + const redirectOwners = new Map() + + for (const page of pages) { + const redirectFrom = page.vars.redirectFrom + if (redirectFrom === undefined) continue + + const source = page.pageInfo.pageFile.relname + if (!Array.isArray(redirectFrom)) throw new TypeError(`redirectFrom on "${source}" must be an array of same-origin URL paths`) + + for (const from of redirectFrom) { + if (typeof from !== 'string') throw new TypeError(`redirectFrom entries on "${source}" must be strings`) + if (from.trim() !== from || !from.startsWith('/') || from.startsWith('//')) throw new Error(`Invalid redirectFrom "${from}" on "${source}": expected a same-origin URL path beginning with "/"`) + if (from.includes('?') || from.includes('#')) throw new Error(`Invalid redirectFrom "${from}" on "${source}": queries and fragments are not supported`) + if (from.includes('\\') || from.split('/').some(part => part === '.' || part === '..')) throw new Error(`Invalid redirectFrom "${from}" on "${source}": path must not contain ".", "..", or backslash segments`) + + const existingSource = redirectOwners.get(from) + if (existingSource) { + const detail = existingSource === source + ? `more than once on "${source}"` + : `by both "${existingSource}" and "${source}"` + throw new Error(`redirectFrom "${from}" is declared ${detail}`) + } + + redirectOwners.set(from, source) + redirects.push({ from, to: page.pageInfo.url }) + } + } + + return redirects +} + +/** + * @param {PageData[]} pages + * @returns {BlogPost[]} + */ +function collectBlogPosts (pages) { + /** @type {BlogPost[]} */ + const blogPosts = [] + + for (const page of pages) { + const publishDateValue = page.vars.publishDate + if (!page.pageInfo.path.startsWith('blog/') || (typeof publishDateValue !== 'string' && !(publishDateValue instanceof Date))) continue + + const publishDate = new Date(publishDateValue.valueOf()) + if (Number.isNaN(publishDate.valueOf())) continue + + blogPosts.push({ + path: page.pageInfo.path, + url: page.pageInfo.url, + title: String(page.vars.title ?? 'Untitled'), + publishDate: publishDate.toISOString(), + }) + } + + blogPosts.sort((a, b) => b.publishDate.localeCompare(a.publishDate)) + return blogPosts +} + +/** + * @param {BlogPost[]} blogPosts + * @returns {BlogIndex[]} + */ +function collectBlogIndexes (blogPosts) { + /** @type {Map} */ + const postsByYear = new Map() + + for (const post of blogPosts) { + const year = String(new Date(post.publishDate).getUTCFullYear()) + const yearPosts = postsByYear.get(year) ?? [] + yearPosts.push(post) + postsByYear.set(year, yearPosts) + } + + const blogIndexes = [] + for (const [year, posts] of postsByYear) { + blogIndexes.push({ year, posts }) + } + + blogIndexes.sort((a, b) => b.year.localeCompare(a.year)) + return blogIndexes +} + +/** @type {GlobalDataFunction} */ +export default function globalData ({ pages }) { + const blogPosts = collectBlogPosts(pages) + + return { + blogPosts, + blogIndexes: collectBlogIndexes(blogPosts), + redirects: collectRedirects(pages), + sourcePageCount: pages.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..f971d527 --- /dev/null +++ b/test-cases/generated-pages/src/guides/current/README.md @@ -0,0 +1,9 @@ +--- +title: Current Guide +redirectFrom: + - /docs/old-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..05e45a6d --- /dev/null +++ b/test-cases/generated-pages/src/indexes.pages.js @@ -0,0 +1,48 @@ +/** + * @import { PagesFunction } from '#types' + */ + +/** + * @typedef {object} BlogPost + * @property {string} path + * @property {string} url + * @property {string} title + * @property {string} publishDate + */ + +/** + * @typedef {object} BlogIndex + * @property {string} year + * @property {BlogPost[]} posts + */ + +/** + * @typedef {object} IndexVars + * @property {string} layout + * @property {string} title + * @property {BlogPost[]} posts + */ + +/** + * @typedef {object} CollectionVars + * @property {string} siteName + * @property {BlogIndex[]} blogIndexes + */ + +/** @type {PagesFunction} */ +export default function indexesPages ({ vars }) { + const indexes = [] + + for (const { year, posts } of vars.blogIndexes) { + indexes.push({ + outputName: `blog/${year}/index.html`, + vars: { + layout: 'blog-index', + title: `${vars.siteName}: ${year} posts`, + posts, + }, + }) + } + + return indexes +} 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..a01a7469 --- /dev/null +++ b/test-cases/generated-pages/src/new-url/README.md @@ -0,0 +1,10 @@ +--- +title: New URL +redirectFrom: + - /old-url/ + - /legacy-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..db499809 --- /dev/null +++ b/test-cases/generated-pages/src/redirects.pages.js @@ -0,0 +1,38 @@ +/** @import { PagesFunction } from '#types' */ + +/** + * @typedef {object} Redirect + * @property {string} from + * @property {string} to + */ + +/** + * @param {string} from + * @returns {string} + */ +function redirectOutputName (from) { + if (!from.startsWith('/') || from.startsWith('//')) throw new Error(`redirectFrom must be a same-origin URL path: ${from}`) + if (from.includes('?') || from.includes('#')) throw new Error(`redirectFrom must not include a query or fragment: ${from}`) + + const relativePath = from.slice(1) + if (relativePath.length === 0) return 'index.html' + return relativePath.endsWith('/') ? `${relativePath}index.html` : relativePath +} + +/** @type {PagesFunction, any, { redirects: Redirect[] }>} */ +export default function redirectsPages ({ vars }) { + const pages = [] + + for (const { from, to } of vars.redirects) { + pages.push({ + outputName: redirectOutputName(from), + vars: { + layout: 'redirect', + title: 'Redirecting...', + redirectTo: to, + }, + }) + } + + return pages +} 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..a54fe497 --- /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, sourcePageCount?: 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..d3b15fcd --- /dev/null +++ b/test-cases/generated-pages/src/summary.template.js @@ -0,0 +1,15 @@ +/** + * @import { TemplateFunction } from '#types' + */ + +/** @type {TemplateFunction<{ blogPosts: unknown[], sourcePageCount: number }>} */ +export default async function summaryTemplate ({ pages, vars }) { + return { + outputName: 'summary.json', + content: JSON.stringify({ + sourcePageCount: vars.sourcePageCount, + blogPostCount: vars.blogPosts.length, + generatedPagesInTemplate: pages.filter(page => Boolean(page.pageInfo.generated)).length, + }, null, 2), + } +} diff --git a/test-cases/type-exports/index.test.ts b/test-cases/type-exports/index.test.ts index a60d04a7..33f01dac 100644 --- a/test-cases/type-exports/index.test.ts +++ b/test-cases/type-exports/index.test.ts @@ -27,6 +27,7 @@ import type { DomstackManifestRecord, DomstackManifestTransform, DomstackManifestTransformContext, + GeneratedPageDefinition, GlobalDataFunction, GlobalDataFunctionParams, LayoutFunction, @@ -34,6 +35,7 @@ import type { PageFunction, PageFunctionParams, PageInfo, + PagesFunction, Results, ServiceWorkerInfo, SiteData, @@ -119,6 +121,20 @@ const templateAsyncIterator: TemplateAsyncIterator<{ siteName: string }> = async const globalDataFunction: GlobalDataFunction<{ generated: true }> = () => ({ generated: true }) const asyncGlobalDataFunction: AsyncGlobalDataFunction<{ generated: true }> = async () => ({ generated: true }) +const generatedPageWithoutChildren: GeneratedPageDefinition<{ layout: string }, string> = { + outputName: 'without-children/index.html', + vars: { layout: 'root' }, +} +const generatedPageWithUndefinedChildren: GeneratedPageDefinition<{ layout: string }, string> = { + outputName: 'undefined-children/index.html', + vars: { layout: 'root' }, + children: undefined, +} +const generatedPagesFunction: PagesFunction<{ layout: string }, string> = () => [ + generatedPageWithoutChildren, + generatedPageWithUndefinedChildren, +] + const templateOutputOverride: TemplateOutputOverride = { content: 'Hello', outputName: 'hello.txt', @@ -205,6 +221,9 @@ assert.equal([ templateAsyncIterator, globalDataFunction, asyncGlobalDataFunction, + generatedPageWithoutChildren, + generatedPageWithUndefinedChildren, + generatedPagesFunction, templateOutputOverride, buildOptions, domStackOpts, @@ -222,7 +241,7 @@ assert.equal([ domstackManifestPolicyTransformContext, domstackManifestPolicyTransform, domstackManifestOptions, -].length, 29) +].length, 32) test('PageData is importable from the package entry point', () => { assert.strictEqual(typeof PageData, 'function', 'PageData is a class') 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,