diff --git a/README.md b/README.md index 2b9da99f..de64b797 100644 --- a/README.md +++ b/README.md @@ -538,7 +538,57 @@ title: 'My Article Title' Thanks for reading my article ``` -A page referencing a layout name that doesn't have a matching layout file will result in a build error. To reuse a common frame across multiple layouts, see [Compose nested layouts](#compose-nested-layouts). +A page referencing a layout name that doesn't have a matching layout file will result in a build error. +Filenames determine layout names, but nesting is an explicit module declaration, not a directory or import convention. + +### Layout module exports + +DOMStack recognizes these exports from a layout module: + +| Export | Required | Contract | +| --- | --- | --- | +| `default` | Yes | A synchronous or asynchronous [layout render function](#layout-render-function). | +| `vars` | No | An object, or a sync/async function returning an object, providing [layout defaults](#layout-variables). | +| `parentLayout` | No | A non-empty string naming the immediate outer layout; see [Declaring nested layouts](#declaring-nested-layouts). | + +### Declaring nested layouts + +Declare a parent with a named `parentLayout` export in the child layout module: + +```ts +// src/layouts/article.layout.ts +import type { LayoutFunction } from '@domstack/static/types.js' + +export const parentLayout = 'root' + +const articleLayout: LayoutFunction, string, string> = ({ children }) => { + return `
${children}
` +} + +export default articleLayout +``` + +`parentLayout` is a layout name, not a file path or imported function. +For example, `'root'` resolves the discovered `root.layout.ts` or `root.layout.js`, wherever it lives under `src`, or DOMStack's bundled root when no custom root exists. +Names are matched exactly, using the same filename-derived names as the page's `layout` variable. + +Omit `parentLayout` (or export `undefined`) when the layout has no parent; DOMStack does not automatically wrap a selected non-root layout in `root`. + +DOMStack renders the page, passes its result to `article`, then passes that result to `root`: `root(article(page()))`. +Each parent can declare another parent, forming a chain that ends at a layout without `parentLayout`. +Missing parents and cycles, including a layout naming itself, fail the build. + +Every render step is awaited, and each parent receives its immediate child's return value as `children` without intermediate string conversion. +The outermost result is converted to a string for HTML output. +All layouts receive the same final resolved page vars, metadata, and asset lists. +Layout defaults merge outermost-to-innermost before page overrides, and ancestor CSS/client entries are included automatically between global and page assets. +Watch mode tracks the resolved chain and each layout's static imports for source-backed and generated pages, updating those relationships after successful rebuilds. + +Manual function composition remains supported, but `parentLayout` is recommended so DOMStack manages the ancestor chain and its rebuild dependencies. +Do not both declare a parent and call its render function manually, or the parent will render twice. +See [Compose nested layouts](#compose-nested-layouts) for a complete example, asset guidance, and the manual-composition alternative. + +### Layout variables Layouts may also export an optional [`vars` variable provider](#variable-providers) containing defaults for pages that use the layout: @@ -552,25 +602,30 @@ export const vars = { Layout vars are merged into the same resolved page variable cascade that pages, layouts, templates, and domstack manifest settings receive. Precedence is: ```txt -page/frontmatter vars > page.vars.* > layout vars > global.data/global.vars > domstack defaults +page/frontmatter vars > page.vars.* > inner layout vars > outer layout vars > global.data/global.vars > domstack defaults ``` This makes layout vars useful for section-wide defaults while still letting individual pages override them. -### The default `root.layout.ts` +### Layout render function -A layout is a `ts` file that default-exports an async or sync function implementing an outer HTML template that houses the page's inner content (`children`). Think of the frame around a picture. That's a layout. πŸ–ΌοΈ +A layout's default export is an async or sync function that wraps its `children` in an outer template. +With nested layouts, `children` is the result of the immediately inner layout, or the page itself for the innermost layout. It is always passed a single object argument with the following entries. See [Page data and introspection](#page-data-and-introspection) for details about the `page` and `pages` entries: - `vars`: The resolved page variable cascade, including domstack defaults, global vars/data, layout vars, page vars, and page builder vars/frontmatter. Pages can customize layouts by overriding global or layout defaults. - `scripts`: array of paths that should be included onto the page in a script tag src with type `module`. - `styles`: array of paths that should be included onto the page in a `link rel="stylesheet"` tag with the `href` pointing to the paths in the array. -- `children`: A string containing the page's inner content, or whatever type your `ts` page function returns. `md` and `html` page types always return strings. +- `children`: The immediate child's render result: the page's content for the innermost layout, or the next inner layout's return value for a parent. +Markdown and HTML pages return strings; TypeScript pages and nested layouts may return other values. - `pages`: An array of page data that you can use to generate index pages with, or any other page-introspection based content that you desire. - `page`: An object with metadata and other facts about the current page being rendered into the template. This will also be found somewhere in the `pages` array. -The default `root.layout.ts` is featured below, and is implemented with [`fragtml`][fragtml], though it could just be done with a template literal or any other template system that runs in Node.js. See the [`fragtml` docs][fragtml-docs] for escaping, raw HTML, rendering, and fragment usage. +### The default `root.layout.ts` + +The default `root.layout.ts` is featured below, and is implemented with [`fragtml`][fragtml], though it could just be done with a template literal or any other template system that runs in Node.js. +See the [`fragtml` docs][fragtml-docs] for escaping, raw HTML, rendering, and fragment usage. `root.layout.ts` can live anywhere in the `src` directory. @@ -647,9 +702,11 @@ While the layout file can live anywhere in `src`, the layout style must live nex /* This layout style is included in every page rendered with the 'article' layout */ ``` -Layout styles are loaded on all pages that use that layout. +Layout styles are loaded on all pages that use that layout directly or through a `parentLayout` chain. Layout styles are bundled with [`esbuild`][esbuild] and can bundle relative and `npm` css using css `@import` statements. -DOMStack loads stylesheets in this order: global, layout, then page. Under the normal [CSS cascade](https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_cascade/Cascade), later styles take precedence when origin, importance, cascade layer, and specificity are otherwise equal. This lets page styles override layout styles, and layout styles override global styles. +DOMStack loads stylesheets in this order: optional defaults, global, outermost-to-innermost layouts, then page. +Under the normal [CSS cascade](https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_cascade/Cascade), later styles take precedence when origin, importance, cascade layer, and specificity are otherwise equal. +This lets page styles override layout styles, and inner layout styles override outer layout styles. ### Layout client bundles @@ -668,7 +725,7 @@ console.log('I run on every page rendered with the \'article\' layout') /* This layout client is included in every page rendered with the 'article' layout */ ``` -Layout client bundles are loaded on all pages that use that layout. +Layout client bundles are loaded on all pages that use that layout directly or through a `parentLayout` chain. Layout client bundles are built with [`esbuild`][esbuild] and can bundle relative and `npm` modules using ESM `import` statements. ### Layout types @@ -676,7 +733,7 @@ Layout client bundles are built with [`esbuild`][esbuild] and can bundle relativ Layouts can be typed using `LayoutFunction` where: - `T` is the variables type -- `U` is the type of content received from pages (defaults to `any`) +- `U` is the immediate child's render result, from a page or nested layout (defaults to `any`) - `V` is the layout's return type (defaults to `string` for HTML output) ```typescript @@ -2231,138 +2288,82 @@ Applied examples that combine multiple DOMStack features. ### Compose nested layouts -Since layouts are just functionsℒ️, they nest naturally. If you define the majority of your HTML page metadata in a `root.layout.ts`, you can define additional layouts that act as child wrappers without having to redefine everything in `root.layout.ts`. - -For example, you could define a `blog.layout.ts` that re-uses the `root.layout.ts`: +This recipe uses the [explicit `parentLayout` declaration](#declaring-nested-layouts) described in the layout API. +Pages select their innermost layout with `vars.layout`. +A layout can export a static `parentLayout` name to let DOMStack wrap it in another layout. ```typescript -import defaultRootLayout from './root.layout.ts' +// article.layout.ts import { html, raw, render } from 'fragtml' -import type { HtmlResult } from 'fragtml/types.js' import type { LayoutFunction } from '@domstack/static/types.js' +import type { RootLayoutVars } from './root.layout.ts' -// Import the type from root layout -import type { RootLayoutVars } from './root.layout' - -// Extend the RootLayoutVars with blog-specific properties -interface BlogLayoutVars extends RootLayoutVars { - authorImgUrl?: string; - authorImgAlt?: string; - authorName?: string; - authorUrl?: string; - publishDate?: string; - updatedDate?: string; -} - -const blogLayout: LayoutFunction = (layoutVars) => { - const { children: innerChildren, ...rest } = layoutVars - const vars = layoutVars.vars - - const children = render(html` -
-
-

${vars.title}

- -
- -
- ${typeof innerChildren === 'string' - ? html`
${raw(innerChildren)}
` - : innerChildren - } -
-
- `) +export const parentLayout = 'root' +export const vars = { showSidebar: true } - const rootArgs = { ...rest, children } - return defaultRootLayout(rootArgs) +const articleLayout: LayoutFunction = ({ children }) => { + return render(html`
${raw(children)}
`) } -export default blogLayout -``` - -Now `blog.layout.ts` becomes a nested layout of `root.layout.ts`. No magic, just functions. - -Alternatively, you could compose your layouts from re-usable template functions and strings. -If you find your layouts nesting more than one or two levels, perhaps composition would be a better strategy. - -#### Layout composition pitfalls - -> [!WARNING] -> Nested layouts must explicitly forward `scripts` and `styles`. If these values are omitted, the page renders without its CSS or client-side JavaScript, and no error is reported. - -```typescript -// wrong: scripts and styles are dropped -return defaultRootLayout({ children, vars }) - -// correct: forward them along -return defaultRootLayout({ children, vars, scripts, styles }) +export default articleLayout ``` -**Vars can be modified before forwarding.** The rest-spread pattern shown above forwards vars unchanged, but you can extend the object before passing it to the base layout. This is useful for setting layout-specific flags that the root layout reads: - ```typescript -const extendedVars = { ...vars, showSidebar: true, pageType: 'article' } -return defaultRootLayout({ children, vars: extendedVars, scripts, styles }) +// posts/example/page.ts +export const vars = { layout: 'article', title: 'A post' } +export default () => '

Hello from the post.

' ``` -**Forward `page`, `pages`, and `workers` when the base layout uses them.** If your root layout accesses `page.path` for canonical URLs, iterates `pages` for navigation, or uses `workers`, those params must also be forwarded: +DOMStack renders `root(article(page()))`. +A root layout omits `parentLayout`; child layouts can name any discovered layout, including the bundled `root`. +Names are the same filename-derived names used by `vars.layout`, not import paths. +Missing parents, invalid parent exports, and cycles fail the build with the offending layout or chain. -```typescript -export default function articleLayout ({ children, vars, scripts, styles, page, pages, workers }) { - return defaultRootLayout({ children, vars, scripts, styles, page, pages, workers }) -} -``` - -Layout-specific styles and client bundles have a similar explicit-composition requirement: parent layout assets are not included automatically in nested layouts. See [Nested layout client bundles and styles](#nested-layout-client-bundles-and-styles) for the required `@import` and `import` pattern. +All renderers receive the same resolved vars, page metadata, worker URLs, and asset lists. +Vars merge from outermost to innermost layout, followed by page vars and builder/frontmatter vars. +Layout `vars.layout` does not select a parent; only the named `parentLayout` export establishes nesting. +Async layouts are awaited at every step, and intermediate values pass through unchanged until the final result is serialized. +Each parent must accept the kind of children its immediate child returns. #### Nested layout client bundles and styles -> [!WARNING] -> Nested layouts do not automatically inherit the styles or client bundle of the layout they wrap. Import those assets explicitly or the rendered page will omit them. +DOMStack includes each ancestor's own style and client entry automatically. +The order is defaults β†’ globals β†’ outer layouts β†’ inner layouts β†’ page assets. +For example, a post using `article` receives `root.layout.css` before `article.layout.css`. +Do not also import the parent's layout CSS or client from the child: doing both duplicates its contents or execution. -Import the wrapped layout's assets from the additional layout's client and style files. For example, if `article.layout.ts` wraps `root.layout.ts`, do the following: +Watch mode uses the resolved chain for source-backed and generated pages. +Changing a parent layout or one of its imported helpers rebuilds descendant pages, and changing the chain updates those relationships after a successful build. +Existing asset edits use esbuild's watcher; adding or removing a layout asset updates the affected pages' asset lists. -```css -/* article.layout.css */ -@import "./root.layout.css"; -``` +#### Manual composition -This will include the layout style from the `root` layout in the `article` layout style. +Manual function composition is supported and tested for source-backed and generated pages. +Prefer `parentLayout` for ordinary nesting: DOMStack can then manage the full chain's defaults, assets, dependencies, and rebuilds for you. +A layout without `parentLayout` still runs once, and it may import and call other render functions itself. +DOMStack does not infer a parent from those imports, merge the imported function's vars, or add its assets. +Manual composition must forward the required arguments and explicitly import parent assets. ```typescript -/* article.layout.client.ts */ -import './root.layout.client.ts' +// manual.layout.ts +import rootLayout from './root.layout.ts' +import type { LayoutFunction } from '@domstack/static/types.js' +import type { RootLayoutVars } from './root.layout.ts' + +const manualLayout: LayoutFunction = args => { + return rootLayout({ ...args, children: `
${args.children}
` }) +} + +export default manualLayout ``` -Adding these imports will include the `root.layout.ts` layout assets into the `blog.layout.ts` asset files. +Static import tracking still rebuilds these pages when an imported parent or helper changes. +If the parent has layout CSS or client code, import those files from the composing layout's corresponding asset entries. +These manual responsibilities are why explicit `parentLayout` nesting is recommended, not a restriction on using ordinary functions. + +To migrate, replace the parent function call with a `parentLayout` export and return only the child wrapper. +Move shared defaults into exported layout `vars`, and remove child imports of the parent's layout CSS and client. +Do not keep the manual parent call when adding `parentLayout`, or the parent will render twice. ### Generate RSS and JSON feeds diff --git a/docs/v12-migration.md b/docs/v12-migration.md index f20b57e0..bbb35e6f 100644 --- a/docs/v12-migration.md +++ b/docs/v12-migration.md @@ -116,6 +116,42 @@ This is additive for most sites. If a layout module already exported a named `va --- +## Declare nested layouts with parentLayout + +Layouts can now export a static `parentLayout` name instead of importing and invoking their parent render function. +Pages still select the innermost layout through `vars.layout`. +`parentLayout` is an optional named string export, not a field in `vars`, an import path, or a callback. +Omitting it leaves the selected layout without a parent; a non-root layout is not automatically wrapped by `root`. +See the [layout module reference](../README.md#layout-module-exports) and [nested-layout declaration contract](../README.md#declaring-nested-layouts) for name resolution, validation, rendering order, and rebuild behavior. + +```ts +// article.layout.ts +export const parentLayout = 'root' +export const vars = { showSidebar: true } + +export default function articleLayout ({ children }) { + return `
${children}
` +} +``` + +DOMStack renders from the page outward and passes intermediate values unchanged between layouts. +All renderers receive the final resolved vars, with precedence: + +```text +builder/frontmatter > page.vars.* > inner layout vars > outer layout vars > global vars > defaults +``` + +Styles and client entry points are included automatically in default, global, outer-layout, inner-layout, page order. +Watch mode follows the resolved chain and each layout's ordinary imported helpers for both source-backed and generated pages. +Missing parents, invalid parent names, and cycles fail the build. + +Existing single layouts and manual function composition from earlier versions continue to work. +Migrate manually nested layouts to `parentLayout` so DOMStack can follow their full dependency chain for reliable rebuilds and manage their assets automatically. +To migrate a manually nested layout, replace its parent call with `parentLayout`, return only its own wrapper, and remove explicit imports of the parent's layout CSS and client. +Move manually merged defaults to the appropriate layout's `vars` export. +Do not retain both the parent function call and `parentLayout`, because that renders the parent twice. +Manual composition remains responsible for its own vars, asset imports, and argument forwarding. + ## Keep layout dependencies explicit DOMStack only installs dependencies for its bundled defaults. diff --git a/examples/basic/src/layouts/child.layout.ts b/examples/basic/src/layouts/child.layout.ts index 276a147c..bbba853a 100644 --- a/examples/basic/src/layouts/child.layout.ts +++ b/examples/basic/src/layouts/child.layout.ts @@ -2,15 +2,15 @@ import type { LayoutFunction } from '@domstack/static/types.js' import { html, raw, render } from 'fragtml' import type { HtmlResult } from 'fragtml/types.js' -import defaultRootLayout from './root.layout.ts' import type { PageVars } from './root.layout.ts' -const articleLayout: LayoutFunction = (args) => { - const { children, ...rest } = args - const wrappedChildren = render(html` +export const parentLayout = 'root' + +const articleLayout: LayoutFunction = ({ children, vars }) => { + return render(html`
-

${rest.vars.title}

+

${vars.title}

${typeof children === 'string' @@ -20,8 +20,6 @@ const articleLayout: LayoutFunction = (ar
`) - - return defaultRootLayout({ children: wrappedChildren, ...rest }) } export default articleLayout diff --git a/examples/blog/src/blog/2024/second-post/README.md b/examples/blog/src/blog/2024/second-post/README.md index e2abe854..1a7eb1ba 100644 --- a/examples/blog/src/blog/2024/second-post/README.md +++ b/examples/blog/src/blog/2024/second-post/README.md @@ -10,36 +10,52 @@ tags: # Layouts All the Way Down -Domstack layouts are just functions. The `post` layout is a TypeScript function that -receives `children` (the rendered page content), wraps it in article markup, and -delegates to the `root` layout for the full HTML shell: +Domstack layouts are functions with an explicit parent declaration. +The `post` layout receives `children` (the rendered page content) and returns its article markup. +It exports `parentLayout = 'root'` so Domstack adds the full HTML shell around that result. +Here is a simplified `post.layout.ts`: ```ts -const postLayout: LayoutFunction = (args) => { - const { children, ...rest } = args - const wrappedChildren = render(html` +import { html, raw, render } from 'fragtml' +import type { HtmlResult } from 'fragtml/types.js' +import type { LayoutFunction } from '@domstack/static/types.js' +import type { RootVars } from './root.layout.ts' + +export const parentLayout = 'root' + +const postLayout: LayoutFunction = ({ children, vars }) => { + return render(html`
-
...
-
${children}
+

${vars.title}

+
+ ${typeof children === 'string' ? raw(children) : children} +
`) - return rootLayout({ ...rest, children: wrappedChildren }) } + +export default postLayout ``` -No magic inheritance, no template partials, no special syntax. Just function composition. +The page still selects its innermost layout with `layout: post` in frontmatter. +Domstack resolves the chain and renders `root(post(page()))`, with each layout running once. +Each parent can declare another parent; a layout without `parentLayout` ends the chain. +Do not also import and call the parent render function when declaring `parentLayout`, or the parent will render twice. + +Manual function composition remains supported, but `parentLayout` lets Domstack manage ancestor defaults, assets, and rebuild dependencies automatically. -## Styles follow the same pattern +## Styles follow the declared chain -`post.layout.css` imports `root.layout.css` with a plain CSS `@import`. esbuild -bundles them together. Each layout advertises its own stylesheet and client script, -and domstack injects the right ones automatically based on which layout a page uses. +`post.layout.css` contains only the post layout's styles; it does not need to import `root.layout.css`. +Domstack includes the styles and client entry points for every layout in the resolved chain, ordered from the outermost parent to the innermost child, between global and page assets. +Changing an ancestor layout or one of its statically imported helpers rebuilds the pages that use that chain. ## The `vars` merge order ``` -{ ...globalVars, ...globalDataVars, ...pageVars, ...builderVars } +{ ...globalVars, ...globalDataVars, ...rootLayoutVars, ...postLayoutVars, ...pageVars, ...builderVars } ``` -`globalDataVars` sits between global and page vars, so `global.data.ts` output is -available everywhere but can be overridden per-page if needed. +Layout defaults merge from the outermost parent to the innermost child. +Page vars override those defaults, and builder vars, including Markdown frontmatter, take precedence last. +`global.data.ts` output is merged into vars before layout defaults. diff --git a/examples/blog/src/layouts/post.layout.css b/examples/blog/src/layouts/post.layout.css index 87b22ed6..7d8e1ee5 100644 --- a/examples/blog/src/layouts/post.layout.css +++ b/examples/blog/src/layouts/post.layout.css @@ -1,5 +1,3 @@ -@import './root.layout.css'; - @layer domstack.layout { /* ── Post layout ── */ diff --git a/examples/blog/src/layouts/post.layout.ts b/examples/blog/src/layouts/post.layout.ts index 72b86dff..197c45c6 100644 --- a/examples/blog/src/layouts/post.layout.ts +++ b/examples/blog/src/layouts/post.layout.ts @@ -1,9 +1,10 @@ import { html, raw, render } from 'fragtml' import type { HtmlResult } from 'fragtml/types.js' import type { LayoutFunction } from '@domstack/static/types.js' -import rootLayout from './root.layout.ts' import type { RootVars } from './root.layout.ts' +export const parentLayout = 'root' + export type PostVars = RootVars & { publishDate?: string updatedDate?: string @@ -16,13 +17,13 @@ export type PostVars = RootVars & { * schema.org/h-entry microformats, publish date, author card, tag list. */ const postLayout: LayoutFunction = (args) => { - const { children, page, pages, ...rest } = args + const { children, page } = args const { vars } = args const publishDate = vars.publishDate ? new Date(vars.publishDate) : null const updatedDate = vars.updatedDate ? new Date(vars.updatedDate) : null - const wrappedChildren = render(html` + return render(html`
@@ -91,8 +92,6 @@ const postLayout: LayoutFunction = (args)
`) - - return rootLayout({ ...rest, page, pages, children: wrappedChildren }) } export default postLayout diff --git a/examples/blog/src/layouts/year-index.layout.ts b/examples/blog/src/layouts/year-index.layout.ts index f57e657a..8e2b79a6 100644 --- a/examples/blog/src/layouts/year-index.layout.ts +++ b/examples/blog/src/layouts/year-index.layout.ts @@ -1,10 +1,11 @@ import { html, raw, render } from 'fragtml' import type { HtmlResult } from 'fragtml/types.js' import type { LayoutFunction } from '@domstack/static/types.js' -import rootLayout from './root.layout.ts' import type { RootVars } from './root.layout.ts' import type { BlogPost } from '../global.data.ts' +export const parentLayout = 'root' + export type YearIndexVars = RootVars & { posts?: BlogPost[] } @@ -14,9 +15,9 @@ export type YearIndexVars = RootVars & { * collection and `blog-indexes.pages.ts` assigns it to a generated page. */ const yearIndexLayout: LayoutFunction = (args) => { - const { children, ...rest } = args + const { children } = args - const wrappedChildren = render(html` + return render(html`

${args.vars.title}

    @@ -43,8 +44,6 @@ const yearIndexLayout: LayoutFunction `) - - return rootLayout({ ...rest, children: wrappedChildren }) } export default yearIndexLayout diff --git a/index.js b/index.js index 69eef4b5..1cd1cffa 100644 --- a/index.js +++ b/index.js @@ -9,7 +9,7 @@ * @import { Logger as PinoLogger } from 'pino' * @import { DomstackManifestRecord } from './lib/domstack-manifest/index.js' * @typedef {{ dispose: () => Promise }} DisposableBuildContext - * @typedef {{ pageFilePath: string, pagesFilePath?: string | undefined, layoutName?: string | undefined, outputs?: DomstackManifestRecord[] | undefined }} WatchedPageReport + * @typedef {{ pageFilePath: string, sourcePageFilePath?: string | undefined, pagesFilePath?: string | undefined, layoutNames: string[], outputs?: DomstackManifestRecord[] | undefined }} WatchedPageReport */ import { once } from 'events' import assert from 'node:assert' @@ -33,10 +33,7 @@ import { buildEsbuildWatch } from './lib/build-esbuild/index.js' import { buildPages } from './lib/build-pages/index.js' import { identifyPages, - layoutSuffixs, layoutStyleSuffix, - templateSuffixs, - pagesSuffixs, globalVarsNames, globalDataNames, esbuildSettingsNames, @@ -50,7 +47,6 @@ import { pageWorkerSuffixs, serviceWorkerNames, } from './lib/identify-pages.js' -import { resolveVars } from './lib/build-pages/resolve-vars.js' import { ensureDest } from './lib/helpers/ensure-dest.js' import { DomStackAggregateError } from './lib/helpers/domstack-aggregate-error.js' import { createDomStackLogger } from './lib/logger.js' @@ -93,6 +89,8 @@ export class DomStack { #layoutDepMap = new Map() /** @type {Map>} layoutName β†’ Set */ #layoutPageMap = new Map() + /** @type {Map} source filepath β†’ last successfully rendered layout chain */ + #pageLayoutNamesMap = new Map() /** @type {Map} filepath β†’ PageInfo */ #pageFileMap = new Map() /** @type {Map} filepath β†’ layoutName */ @@ -111,6 +109,8 @@ export class DomStack { #pagesFileOutputMap = new Map() /** @type {Map>} *.pages.* filepath β†’ layouts used by its generated pages */ #pagesFileLayoutMap = new Map() + /** @type {boolean} Failed builds may leave the previous routing state incomplete. */ + #pageBuildFailed = false // Serialized lock so concurrent chokidar events don't pile up /** @type {Promise} */ @@ -212,12 +212,15 @@ export class DomStack { this.#pageOutputRelnames = getPageOutputRelnames(pageBuildResults.outputs) this.#pagesFileOutputMap = getPagesFileOutputMap(pageBuildResults.report.pages) this.#pagesFileLayoutMap = getPagesFileLayoutMap(pageBuildResults.report.pages) + this.#updatePageLayoutNames(pageBuildResults.report.pages, true) + this.#pageBuildFailed = false buildLogger(report, this.#logger) this.#logger.info('Initial JS, CSS and Page Build Complete') } catch (err) { - errorLogger(err, this.#logger) if (!(err instanceof DomStackAggregateError)) throw new Error('Non-aggregate error thrown', { cause: err }) + this.#pageBuildFailed = true report = err.results + errorLogger(err, this.#logger) } // Build watch maps after initial build @@ -444,6 +447,9 @@ ${siteData.errors.map(err => ` ${err.message}`).join('\n')}`) * @param {string[] | null} [pagesFileFilterPaths] */ async #runPageBuild (siteData, pageFilterPaths = null, templateFilterPaths = null, pagesFileFilterPaths = null) { + // Retry the complete page phase after a failure: layout routing from a + // failed build cannot safely drive an incremental retry. + if (this.#pageBuildFailed) pageFilterPaths = templateFilterPaths = pagesFileFilterPaths = null try { const pageBuildResults = await buildPages(this.#src, this.#dest, siteData, { ...this.opts, @@ -458,6 +464,7 @@ ${siteData.errors.map(err => ` ${err.message}`).join('\n')}`) }) } const isFiltered = pageFilterPaths !== null || templateFilterPaths !== null || pagesFileFilterPaths !== null + this.#updatePageLayoutNames(pageBuildResults.report.pages, !isFiltered) if (!isFiltered) { await this.#removeObsoletePageOutputs(pageBuildResults.outputs) this.#pagesFileOutputMap = getPagesFileOutputMap(pageBuildResults.report.pages) @@ -466,6 +473,8 @@ ${siteData.errors.map(err => ` ${err.message}`).join('\n')}`) await this.#removeObsoleteGeneratedPageOutputs(pagesFileFilterPaths, pageBuildResults.report.pages) updatePagesFileLayoutMap(this.#pagesFileLayoutMap, pagesFileFilterPaths, pageBuildResults.report.pages) } + await this.#rebuildMaps(siteData) + this.#pageBuildFailed = false buildLogger( isFiltered ? pageBuildResults : { warnings: pageBuildResults.warnings, siteData, pageBuildResults }, this.#logger, @@ -473,6 +482,7 @@ ${siteData.errors.map(err => ` ${err.message}`).join('\n')}`) ) return pageBuildResults } catch (err) { + this.#pageBuildFailed = true errorLogger(err, this.#logger) } } @@ -535,20 +545,6 @@ ${siteData.errors.map(err => ` ${err.message}`).join('\n')}`) } } - /** - * Rebuild generated outputs owned by selected pages files and refresh their - * dependency maps after a successful build. - * - * @param {SiteData} siteData - * @param {Set} pagesFiles - */ - async #runTargetedPagesFileBuild (siteData, pagesFiles) { - const pagesFileFilterPaths = Array.from(pagesFiles, pagesFile => pagesFile.pagesFile.filepath) - const pageBuildResults = await this.#runPageBuild(siteData, [], [], pagesFileFilterPaths) - if (pageBuildResults) await this.#rebuildMaps(siteData) - return pageBuildResults - } - /** * Find generated-page owners whose last successful outputs used any affected layout. * @@ -579,7 +575,20 @@ ${siteData.errors.map(err => ` ${err.message}`).join('\n')}`) } /** - * Build and maintain the six watch maps from siteData. + * Record source-page layout chains only after successful builds. + * + * @param {WatchedPageReport[]} reports + * @param {boolean} replace + */ + #updatePageLayoutNames (reports, replace) { + if (replace) this.#pageLayoutNamesMap.clear() + for (const report of reports) { + if (report.sourcePageFilePath) this.#pageLayoutNamesMap.set(report.sourcePageFilePath, report.layoutNames) + } + } + + /** + * Build and maintain the watch maps from siteData. * `find()` returns CWD-relative paths; we resolve them to absolute for map keys. * * @param {SiteData} siteData @@ -612,29 +621,12 @@ ${siteData.errors.map(err => ` ${err.message}`).join('\n')}`) } } - // layoutPageMap: layoutName β†’ Set - // Build by reading each page's vars file (lightweight, no full render) - const defaultVars = /** @type {{ layout?: string }} */ (await resolveVars({ - varsPath: resolve(import.meta.dirname, 'lib/defaults/default.vars.js'), - })) - const bareGlobalVars = /** @type {{ layout?: string }} */ (await resolveVars({ - varsPath: siteData?.globalVars?.filepath, - })) - const globalVars = { ...defaultVars, ...bareGlobalVars } - const defaultLayout = globalVars.layout ?? 'root' - + // Use the worker's actual selection, including frontmatter and all ancestors. for (const pageInfo of siteData.pages) { - let layoutName = defaultLayout - if (pageInfo.pageVars) { - try { - const pageVars = /** @type {{ layout?: string }} */ (await resolveVars({ varsPath: pageInfo.pageVars.filepath })) - if (typeof pageVars.layout === 'string') layoutName = pageVars.layout - } catch { - // fall back to default - } + for (const layoutName of this.#pageLayoutNamesMap.get(pageInfo.pageFile.filepath) ?? []) { + if (!layoutPageMap.has(layoutName)) layoutPageMap.set(layoutName, new Set()) + layoutPageMap.get(layoutName)?.add(pageInfo) } - if (!layoutPageMap.has(layoutName)) layoutPageMap.set(layoutName, new Set()) - layoutPageMap.get(layoutName)?.add(pageInfo) } // pageFileMap: page filepath & page.vars filepath β†’ PageInfo @@ -761,102 +753,56 @@ ${siteData.errors.map(err => ` ${err.message}`).join('\n')}`) return } - // 6. esbuild entry point (client.js, style.css, .layout.css, .layout.client.*, *.worker.*, global.client.*, global.css) - // esbuild's own watcher handles these. Stable filenames mean page HTML doesn't - // change, so no page rebuild is needed. - if (this.#esbuildEntryPoints.has(changedPath)) { - this.#logger.info(`"${changedBasename}" changed, esbuild will handle rebundling.`) - return - } - - // 7. Layout file itself β†’ rebuild pages using that layout - if (layoutSuffixs.some(s => changedBasename.endsWith(s))) { - const layoutName = this.#layoutFileMap.get(changedPath) - if (layoutName) { - const affectedPages = this.#layoutPageMap.get(layoutName) - const pagesFileFilterPaths = this.#getPagesFilePathsUsingLayouts(new Set([layoutName])) - if ((affectedPages?.size ?? 0) > 0 || pagesFileFilterPaths.length > 0) { - logRebuildTree(changedBasename, this.#logger, affectedPages) - const pageFilterPaths = Array.from(affectedPages ?? []).map(p => p.pageFile.filepath) - return this.#runPageBuild(siteData, pageFilterPaths, [], pagesFileFilterPaths) - } - this.#logger.info(`"${changedBasename}" changed but no pages use layout "${layoutName}", skipping.`) - return - } - // Not a registered layout β€” fall through to dep checks + if (this.#pageBuildFailed) { + this.#logger.info(`"${changedBasename}" changed, retrying all pages after the previous build failure...`) + return this.#runPageBuild(siteData) } - // 8. Dep of a layout - if (this.#layoutDepMap.has(changedPath)) { - const affectedLayoutNames = this.#layoutDepMap.get(changedPath) ?? new Set() - const affectedPages = new Set(/** @type {PageInfo[]} */ ([])) - for (const layoutName of affectedLayoutNames) { - const pages = this.#layoutPageMap.get(layoutName) - if (pages) for (const p of pages) affectedPages.add(p) - } - const pagesFileFilterPaths = this.#getPagesFilePathsUsingLayouts(affectedLayoutNames) - if (affectedPages.size > 0 || pagesFileFilterPaths.length > 0) { - logRebuildTree(changedBasename, this.#logger, affectedPages) - const pageFilterPaths = Array.from(affectedPages).map(p => p.pageFile.filepath) - return this.#runPageBuild(siteData, pageFilterPaths, [], pagesFileFilterPaths) - } + // A source can serve several roles at once: an imported parent layout may + // also be selected directly, and a helper may be shared by pages and templates. + // Union every matching consumer before scheduling one build. + const affectedLayouts = new Set(this.#layoutDepMap.get(changedPath)) + const directLayout = this.#layoutFileMap.get(changedPath) + if (directLayout) affectedLayouts.add(directLayout) + + const affectedPages = new Set(this.#pageDepMap.get(changedPath)) + const directPage = this.#pageFileMap.get(changedPath) + if (directPage) affectedPages.add(directPage) + for (const name of affectedLayouts) { + for (const page of this.#layoutPageMap.get(name) ?? []) affectedPages.add(page) } - // 9. Page file or page.vars file - if (this.#pageFileMap.has(changedPath)) { - const affectedPage = this.#pageFileMap.get(changedPath) - if (affectedPage) { - logRebuildTree(changedBasename, this.#logger, new Set([affectedPage])) - return this.#runPageBuild(siteData, [affectedPage.pageFile.filepath], [], []) - } + const affectedTemplates = new Set(this.#templateDepMap.get(changedPath)) + for (const template of siteData.templates) { + if (template.templateFile.filepath === changedPath) affectedTemplates.add(template) } - // 10. Pages file itself β†’ rebuild outputs owned by that file - if (pagesSuffixs.some(s => changedBasename.endsWith(s))) { - const pagesFile = siteData.pagesFiles?.find(p => p.pagesFile.filepath === changedPath) - if (pagesFile) { - this.#logger.info(`"${changedBasename}" changed, rebuilding its generated pages...`) - return this.#runTargetedPagesFileBuild(siteData, new Set([pagesFile])) - } + const affectedOwners = new Set(this.#getPagesFilePathsUsingLayouts(affectedLayouts)) + for (const pagesFile of this.#pagesFileDepMap.get(changedPath) ?? []) { + affectedOwners.add(pagesFile.pagesFile.filepath) } - - // 11. Template file itself - if (templateSuffixs.some(s => changedBasename.endsWith(s))) { - const templateInfo = siteData.templates.find(t => t.templateFile.filepath === changedPath) - if (templateInfo) { - logRebuildTree(changedBasename, this.#logger, undefined, new Set([templateInfo])) - return this.#runPageBuild(siteData, [], [templateInfo.templateFile.filepath], []) - } - } - - // 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) { - logRebuildTree(changedBasename, this.#logger, affectedPages) - const pageFilterPaths = Array.from(affectedPages).map(p => p.pageFile.filepath) - return this.#runPageBuild(siteData, pageFilterPaths, [], []) - } + for (const pagesFile of siteData.pagesFiles ?? []) { + if (pagesFile.pagesFile.filepath === changedPath) affectedOwners.add(changedPath) } - // 13. Dep of a pages file β†’ rebuild outputs owned by affected files - if (this.#pagesFileDepMap.has(changedPath)) { - const affectedPagesFiles = this.#pagesFileDepMap.get(changedPath) ?? new Set() - this.#logger.info(`"${changedBasename}" changed, rebuilding affected generated pages...`) - return this.#runTargetedPagesFileBuild(siteData, affectedPagesFiles) + if (affectedPages.size || affectedTemplates.size || affectedOwners.size) { + logRebuildTree(changedBasename, this.#logger, affectedPages, affectedTemplates) + return this.#runPageBuild( + siteData, + Array.from(affectedPages, page => page.pageFile.filepath), + Array.from(affectedTemplates, template => template.templateFile.filepath), + [...affectedOwners] + ) } - // 14. Dep of a template file - if (this.#templateDepMap.has(changedPath)) { - const affectedTemplates = this.#templateDepMap.get(changedPath) ?? new Set() - if (affectedTemplates.size > 0) { - logRebuildTree(changedBasename, this.#logger, undefined, affectedTemplates) - const templateFilterPaths = Array.from(affectedTemplates).map(t => t.templateFile.filepath) - return this.#runPageBuild(siteData, [], templateFilterPaths, []) - } + // Browser entry points can also be imported by server-side consumers. + // Only skip the page phase once all those consumers have been considered. + if (this.#esbuildEntryPoints.has(changedPath)) { + this.#logger.info(`"${changedBasename}" changed, esbuild will handle rebundling.`) + return } - // 15. No matching rule β€” skip. + // No matching rule β€” skip. this.#logger.info(`"${changedBasename}" changed but did not match any rebuild rule, skipping.`) } @@ -926,9 +872,9 @@ function getPagesFileLayoutMap (pageReports) { const layoutsByOwner = new Map() for (const report of pageReports) { - if (!report.pagesFilePath || !report.layoutName) continue + if (!report.pagesFilePath) continue const layouts = layoutsByOwner.get(report.pagesFilePath) ?? new Set() - layouts.add(report.layoutName) + for (const name of report.layoutNames) layouts.add(name) layoutsByOwner.set(report.pagesFilePath, layouts) } diff --git a/lib/build-pages/index.js b/lib/build-pages/index.js index 878bb2ca..4a145e8e 100644 --- a/lib/build-pages/index.js +++ b/lib/build-pages/index.js @@ -15,6 +15,7 @@ import { keyBy } from '../helpers/key-by.js' import { resolveVars, resolveGlobalData } from './resolve-vars.js' import { pageBuilders, templateBuilder } from './page-builders/index.js' import { PageData, resolveLayout } from './page-data.js' +import { resolveLayoutChain } from './resolve-layout-chain.js' import { pageWriter } from './page-builders/page-writer.js' import { computePageUrl } from './compute-page-url.js' import { DomStackOutputConflictError } from '../helpers/domstack-error.js' @@ -27,8 +28,10 @@ const __dirname = import.meta.dirname /** * @typedef {object} PageReport * @property {string} pageFilePath + * @property {string | undefined} [sourcePageFilePath] * @property {string | undefined} [pagesFilePath] * @property {string | undefined} [layoutName] + * @property {string[]} layoutNames - Outermost-to-innermost resolved layout chain. * @property {DomstackManifestRecord[]} outputs */ @@ -470,8 +473,7 @@ export async function buildPagesDirect (_src, dest, siteData, opts) { const resolvedLayoutResults = await pMap(Object.values(siteData.layouts), async (layout) => { const resolvedLayout = await resolveLayout(layout.filepath) return { - render: resolvedLayout.render, - vars: resolvedLayout.vars, + ...resolvedLayout, name: layout.layoutName, layoutStylePath: layout.layoutStyle ? `/${layout.layoutStyle.outputRelname}` : null, layoutClientPath: layout.layoutClient ? `/${layout.layoutClient.outputRelname}` : null, @@ -479,6 +481,7 @@ export async function buildPagesDirect (_src, dest, siteData, opts) { }, { concurrency: MAX_CONCURRENCY }) const resolvedLayouts = keyBy(resolvedLayoutResults, 'name') + for (const layout of resolvedLayoutResults) resolveLayoutChain(layout.name, resolvedLayouts) // Default vars is an internal detail, here we create globalVars that the user sees. /** @type {object} */ @@ -620,8 +623,10 @@ export async function buildPagesDirect (_src, dest, siteData, opts) { result.report.pages.push({ pageFilePath: buildResult.pageFilePath, + sourcePageFilePath: page.pageInfo.generated ? undefined : page.pageInfo.pageFile.filepath, pagesFilePath: page.pageInfo.generated?.pagesFile.pagesFile.filepath, - layoutName: typeof page.vars.layout === 'string' ? page.vars.layout : undefined, + layoutName: page.layout?.name, + layoutNames: page.layoutChain.map(layout => layout.name), outputs: buildResult.outputs, }) result.outputs.push(...buildResult.outputs) diff --git a/lib/build-pages/page-builders/page-writer.js b/lib/build-pages/page-builders/page-writer.js index 8684456e..8957e293 100644 --- a/lib/build-pages/page-builders/page-writer.js +++ b/lib/build-pages/page-builders/page-writer.js @@ -30,7 +30,7 @@ import { createDomstackManifestRecord } from '../../domstack-manifest/index.js' * @property {string[]} [scripts] - Array of script URLs to include. * @property {string[]} [styles] - Array of stylesheet URLs to include. * @property {PageInfo} page - Info about the current page - * @property {PageData[]} pages - An array of info about every page + * @property {PageData[]} pages - Pages may use different page and layout render result types. * @property {Object} [workers] - Map of worker names to their output paths */ @@ -89,7 +89,7 @@ import { createDomstackManifestRecord } from '../../domstack-manifest/index.js' * @param {object} params * @param {string} params.dest - The dest folder. * @param {PageData} params.page - The PageInfo object of the current page - * @param {PageData[]} params.pages - The PageInfo[] array of all pages + * @param {PageData[]} params.pages - Pages may use different page and layout render result types. * @returns {Promise<{ pageFilePath: string, outputs: DomstackManifestRecord[] }>} */ export async function pageWriter ({ diff --git a/lib/build-pages/page-data.js b/lib/build-pages/page-data.js index c7d1d840..21190fd7 100644 --- a/lib/build-pages/page-data.js +++ b/lib/build-pages/page-data.js @@ -1,6 +1,6 @@ /** * @import { PageInfo } from '../identify-pages.js' - * @import { BuilderOptions } from './page-builders/page-writer.js' + * @import { BuilderOptions, InternalPageFunction } from './page-builders/page-writer.js' */ import { readFile } from 'node:fs/promises' @@ -8,6 +8,7 @@ import { resolveVars, resolvePostVars, resolveVarsExport } from './resolve-vars. import { pageBuilders } from './page-builders/index.js' import { parseMdFileContents } from './page-builders/md/parse-md.js' import pretty from 'pretty' +import { resolveLayoutChain } from './resolve-layout-chain.js' /** * @typedef {Object} WorkerFiles @@ -21,13 +22,18 @@ import pretty from 'pretty' * @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) * @param {string} layoutPath - The string path to the layout ESM module. - * @returns {Promise<{ render: InternalLayoutFunction, vars: Partial }>} The resolved layout render function and optional vars exported from the module. + * @returns {Promise<{ render: InternalLayoutFunction, vars: Partial, parentLayout: string | undefined }>} The resolved layout module exports. */ export async function resolveLayout (layoutPath) { - const { default: layout, vars } = await import(layoutPath) + const { default: layout, vars, parentLayout } = await import(layoutPath) + if (typeof layout !== 'function') throw new TypeError(`Layout "${layoutPath}" must export a default render function`) + if (parentLayout !== undefined && (typeof parentLayout !== 'string' || !parentLayout.trim())) { + throw new TypeError(`Layout "${layoutPath}" parentLayout must be a non-empty string`) + } return { render: layout, + parentLayout, vars: /** @type {Partial} */ (await resolveVarsExport(vars, 'Layout vars')), } } @@ -70,7 +76,7 @@ export async function resolveLayout (layoutPath) { * @property {string[]} [styles] - Array of stylesheet URLs to include. * @property {U} children - The children content, either as a string or a render function. * @property {PageInfo} page - Info about the current page - * @property {PageData[]} pages - An array of info about every page + * @property {PageData[]} pages - Pages may use different page and layout render result types. * @property {Object} [workers] - Map of worker names to their output paths */ @@ -115,6 +121,7 @@ export async function resolveLayout (layoutPath) { * @property {InternalLayoutFunction} render - The layout function * @property {Partial} [vars] - Variables exported by the layout module. * @property {string} name - The name of the layout + * @property {string | undefined} [parentLayout] - Name of the optional outer layout. * @property {string | null} layoutStylePath - The string path to the layout style * @property {string | null} layoutClientPath - The string path to the layout client */ @@ -128,6 +135,7 @@ export async function resolveLayout (layoutPath) { export class PageData { /** @type {PageInfo} */ pageInfo /** @type {ResolvedLayout | null | undefined} */ layout + /** @type {ResolvedLayout[]} Each parent may accept and return a different render value. */ layoutChain = [] /** @type {Partial} */ globalVars /** @type {Partial} */ globalDataVars = {} /** @type {Partial} */ layoutVars = {} @@ -240,9 +248,9 @@ export class PageData { } /** - * [init description] + * Resolve the page's vars, layout chain, and assets once before rendering. * @param {object} params - Parameters required to initialize - * @param {Record>} params.layouts - The array of ResolvedLayouts + * @param {Record>} params.layouts - Layouts indexed by name. */ async init ({ layouts }) { if (this.#initialized) return @@ -260,16 +268,12 @@ export class PageData { const layoutName = resolveLayoutName(globalVars, this.pageVars, builderVars) - this.layout = layouts[layoutName] - if (!this.layout) throw new Error('Unable to resolve a layout') - this.layoutVars = this.layout.vars ?? {} - - if (this.layout.layoutStylePath) { - this.styles.push(this.layout.layoutStylePath) - } - - if (this.layout.layoutClientPath) { - this.scripts.push(this.layout.layoutClientPath) + this.layoutChain = resolveLayoutChain(layoutName, layouts) + this.layout = this.layoutChain.at(-1) + for (const layout of this.layoutChain) { + Object.assign(this.layoutVars, layout.vars) + if (layout.layoutStylePath) this.styles.push(layout.layoutStylePath) + if (layout.layoutClientPath) this.scripts.push(layout.layoutClientPath) } if (pageInfo.pageStyle) { @@ -309,7 +313,8 @@ export class PageData { /** * Render the inner contents of a page. * @param {object} params The params required to render the page - * @param {PageData[]} params.pages An array of initialized PageDatas. + * @param {PageData[]} params.pages An array of initialized PageDatas. + * @returns {Promise>} The page's render value, before any layout runs. */ async renderInnerPage ({ pages }) { if (!this.#initialized) throw new Error('Must be initialized before rendering inner pages') @@ -317,32 +322,35 @@ export class PageData { if (!pageInfo) throw new Error('A page is required to render') const builder = pageBuilders[pageInfo.type] const { pageLayout } = await builder({ pageInfo, options: builderOptions }) - // @ts-expect-error - Builder types vary by page type, but the runtime type is correct - const results = await pageLayout({ vars, styles, scripts, pages, page: pageInfo, workers }) + // Discovery selects the builder; the caller's U describes that page's result. + const render = /** @type {InternalPageFunction} */ (pageLayout) + const results = await render({ vars, styles, scripts, pages, page: pageInfo, workers }) return results } /** * Render the full contents of a page with its layout * @param {object} params The params required to render the page - * @param {PageData[]} params.pages An array of initialized PageDatas. + * @param {PageData[]} params.pages An array of initialized PageDatas. */ async renderFullPage ({ pages }) { if (!this.#initialized) throw new Error('Must be initialized before rendering full pages') - const { pageInfo, layout, vars, styles, scripts } = this + const { pageInfo, layout, layoutChain, vars, styles, scripts } = this if (!pageInfo) throw new Error('A page is required to render') if (!layout) throw new Error('A layout is required to render') - const innerPage = await this.renderInnerPage({ pages }) - - const rendered = await layout.render({ - vars, - styles, - scripts, - page: pageInfo, - pages, - children: /** @type {U} */ (/** @type {unknown} */ (innerPage)), - workers: this.workers - }) + /** @type {unknown} */ + let rendered = await this.renderInnerPage({ pages }) + for (const currentLayout of layoutChain.toReversed()) { + rendered = await currentLayout.render({ + vars, + styles, + scripts, + page: pageInfo, + pages, + children: rendered, + workers: this.workers + }) + } return pretty(String(rendered)) } } diff --git a/lib/build-pages/resolve-layout-chain.js b/lib/build-pages/resolve-layout-chain.js new file mode 100644 index 00000000..7ccef3f6 --- /dev/null +++ b/lib/build-pages/resolve-layout-chain.js @@ -0,0 +1,29 @@ +/** + * Resolve a named layout and its ancestors in outermost-to-innermost order. + * Imports are deliberately not consulted: only parentLayout defines nesting. + * + * @template {{ name: string, parentLayout?: string | undefined }} L + * @param {string} name + * @param {Record} layouts + * @returns {L[]} + */ +export function resolveLayoutChain (name, layouts) { + /** @type {L[]} */ + const chain = [] + const visited = new Set() + let current = name + + while (true) { + const path = [...visited, current].join(' -> ') + if (visited.has(current)) throw new Error(`Layout cycle: ${path}`) + if (!Object.hasOwn(layouts, current)) throw new Error(`Unable to resolve layout "${current}" (chain: ${path})`) + const layout = layouts[current] + if (!layout) throw new Error(`Unable to resolve layout "${current}"`) + visited.add(current) + chain.push(layout) + if (layout.parentLayout === undefined) break + current = layout.parentLayout + } + + return chain.reverse() +} diff --git a/lib/build-pages/resolve-layout-chain.test.js b/lib/build-pages/resolve-layout-chain.test.js new file mode 100644 index 00000000..fbbbe7bb --- /dev/null +++ b/lib/build-pages/resolve-layout-chain.test.js @@ -0,0 +1,50 @@ +import { test } from 'node:test' +import assert from 'node:assert/strict' +import { mkdtemp, writeFile, rm } from 'node:fs/promises' +import { join } from 'node:path' +import { tmpdir } from 'node:os' +import { resolveLayoutChain } from './resolve-layout-chain.js' +import { resolveLayout } from './page-data.js' + +test('resolves explicit parent chains without treating vars.layout as a parent', () => { + const root = { name: 'root' } + const article = { name: 'article', parentLayout: 'root' } + const post = { name: 'post', parentLayout: 'article', vars: { layout: 'unrelated' } } + const standalone = { name: 'standalone', vars: { layout: 'root' } } + const layouts = { root, article, post, standalone } + assert.deepEqual(resolveLayoutChain('post', layouts), [root, article, post]) + assert.deepEqual(resolveLayoutChain('root', layouts), [root]) + assert.deepEqual(resolveLayoutChain('standalone', layouts), [standalone]) + assert.equal(post.parentLayout, 'article', 'resolution does not mutate modules') + assert.throws(() => resolveLayoutChain('missing', layouts), /Unable to resolve layout "missing"/) + assert.throws(() => resolveLayoutChain('toString', layouts), /Unable to resolve layout "toString"/) + assert.throws(() => resolveLayoutChain('post', { post }), /post -> article/) + assert.throws(() => resolveLayoutChain('root', { root: { name: 'root', parentLayout: 'root' } }), /Layout cycle: root -> root/) + assert.throws(() => resolveLayoutChain('post', { + post, article: { ...article, parentLayout: 'post' } + }), /Layout cycle: post -> article -> post/) +}) + +test('importing another layout does not implicitly declare it as a parent', async t => { + const dir = await mkdtemp(join(tmpdir(), 'domstack-layout-import-')) + t.after(() => rm(dir, { recursive: true, force: true })) + await writeFile(join(dir, 'root.mjs'), "export default () => 'root'") + const childPath = join(dir, 'child.mjs') + await writeFile(childPath, "import './root.mjs'; export const vars = { layout: 'root' }; export default () => 'child'") + const child = { ...await resolveLayout(childPath), name: 'child' } + assert.equal(child.parentLayout, undefined) + assert.deepEqual(resolveLayoutChain('child', { child }), [child]) +}) + +test('validates the parentLayout and default exports', async t => { + const dir = await mkdtemp(join(tmpdir(), 'domstack-layout-exports-')) + t.after(() => rm(dir, { recursive: true, force: true })) + for (const [index, value] of ['null', 'false', '42', "''", "' '", "['root']", '() => "root"'].entries()) { + const file = join(dir, `${index}.mjs`) + await writeFile(file, `export const parentLayout = ${value}; export default () => ''`) + await assert.rejects(resolveLayout(file), /parentLayout must be a non-empty string/) + } + const file = join(dir, 'invalid.mjs') + await writeFile(file, 'export default 42') + await assert.rejects(resolveLayout(file), /must export a default render function/) +}) diff --git a/lib/build-pages/resolve-vars.js b/lib/build-pages/resolve-vars.js index d8626f36..77b6e66e 100644 --- a/lib/build-pages/resolve-vars.js +++ b/lib/build-pages/resolve-vars.js @@ -1,3 +1,7 @@ +/** + * @import { PageData } from './page-data.js' + */ + import { isFunction, isObject, isPlainObject } from '../helpers/type-guards.js' /** @@ -39,10 +43,6 @@ export async function resolveVars ({ return await resolveVarsExport(imported[key], 'Var') } -/** - * @import { PageData } from './page-data.js' - */ - /** * Resolve and call a global.data.js file with initialized source-backed pages. * Receives fully resolved PageData instances (with .vars, .pageInfo, etc.) so diff --git a/lib/build-pages/worker.js b/lib/build-pages/worker.js index fabe8cef..db0df519 100644 --- a/lib/build-pages/worker.js +++ b/lib/build-pages/worker.js @@ -4,14 +4,22 @@ import { buildPagesDirect } from './index.js' async function run () { if (!parentPort) throw new Error('parentPort returned null') const { src, dest, siteData, opts } = workerData - let results try { - results = await buildPagesDirect(src, dest, siteData, opts ?? {}) + const results = await buildPagesDirect(src, dest, siteData, opts ?? {}) parentPort.postMessage(results) } catch (err) { - console.dir(results, { colors: true, depth: 999 }) - console.error(err) - throw err + // Layout loading and vars resolution can fail before any page is rendered. + // Report those failures through the normal build channel so watch can retry. + parentPort.postMessage({ + type: 'page', + report: { pages: [], templates: [] }, + outputs: [], + warnings: [], + errors: [{ + error: err instanceof Error ? err : new Error('Non-error thrown during page build', { cause: err }), + errorData: {}, + }], + }) } } diff --git a/test-cases/nested-layouts/index.test.js b/test-cases/nested-layouts/index.test.js new file mode 100644 index 00000000..16aeef57 --- /dev/null +++ b/test-cases/nested-layouts/index.test.js @@ -0,0 +1,264 @@ +/** @import { TestContext } from 'node:test' */ +import { test } from 'node:test' +import assert from 'node:assert/strict' +import { mkdtemp, mkdir, writeFile, readFile, rm, stat, unlink } from 'node:fs/promises' +import { dirname, join } from 'node:path' +import pino from 'pino' +import { DomStack } from '../../index.js' + +const rootLayout = ` +import { label } from './label.js' +export const vars = { inherited: 'root', overridden: 'root', title: 'root' } +export default async function ({ children, vars, styles, scripts }) { + return '' + styles.map(s => '').join('') + + scripts.map(s => '').join('') + + '' + children + '' +}` +const articleLayout = ` +export const parentLayout = 'root' +export const vars = async () => ({ overridden: 'article', title: 'article' }) +export default async ({ children }) => '
    ' + children.html + '
    ' +` +const postLayout = ` +export const parentLayout = 'article' +export default ({ children }) => ({ html: '
    ' + children + '
    ' }) +` + +/** @param {TestContext} t */ +async function setup (t) { + const dir = await mkdtemp(join(import.meta.dirname, '.tmp-')) + const src = join(dir, 'src') + const dest = join(dir, 'public') + await mkdir(src) + /** @type {Record} */ + const files = { + 'root.layout.js': rootLayout, + 'article.layout.js': articleLayout, + 'post.layout.js': postLayout, + 'other.layout.js': "export default ({children}) => ''", + 'label.js': "export const label = 'v1'", + 'other-label.js': "export const label = 'alternate'", + 'global.vars.js': "export default { inherited: 'global', overridden: 'global', layout: 'other' }", + 'source/page.md': '---\nlayout: post\ntitle: source\n---\nContent', + 'plain/page.html': '

    Plain

    ', + 'typed/page.ts': "export const vars = {layout: 'post', title: 'typed'}; export default () => '

    Typed

    '", + 'markup/page.html': '

    Markup

    ', + 'markup/page.vars.js': "export default {layout: 'post', title: 'markup'}", + 'archive.pages.js': "export default [{ outputName: 'archive.html', vars: {layout: 'post', title: 'archive'}, children: '

    Archive

    ' }]", + 'global.css': 'body { color: black }', + 'global.client.js': 'console.log("global")', + 'source/style.css': 'p { color: blue }', + 'source/client.js': 'console.log("page")', + 'root.layout.css': 'body { background: white }', + 'article.layout.css': 'article { display: block }', + 'post.layout.css': 'section { display: block }', + 'root.layout.client.js': 'console.log("root")', + 'article.layout.client.js': 'console.log("article")', + 'post.layout.client.js': 'console.log("post")', + } + await Promise.all(Object.entries(files).map(async ([name, contents]) => { + await mkdir(dirname(join(src, name)), { recursive: true }) + await writeFile(join(src, name), contents) + })) + const domstack = new DomStack(src, dest, { logger: pino({ level: 'silent' }) }) + t.after(async () => { + if (domstack.watching) await domstack.stopWatching() + await rm(dir, { recursive: true, force: true }) + }) + return { + src, + dest, + domstack, + read: (/** @type {string} */ name) => readFile(join(dest, name), 'utf8'), + write: (/** @type {string} */ name, /** @type {string} */ text) => writeFile(join(src, name), text), + } +} + +test('nested layouts render source and generated pages, cascade vars and preserve intermediate values', async t => { + const { domstack, read, write } = await setup(t) + await write('source/page.vars.js', "export default {layout: 'other', title: 'adjacent'}") + const results = await domstack.build() + assert.equal(results.pageBuildResults?.errors.length, 0) + for (const [output, title] of [['source/index.html', 'source'], ['typed/index.html', 'typed'], ['markup/index.html', 'markup'], ['archive.html', 'archive']]) { + const html = await read(/** @type {string} */ (output)) + assert.match(html, new RegExp(`data-vars="root:article:${title}"`)) + assert.match(html, /
    \s*
    /) + assert.equal((html.match(//g) ?? []).length, 1, 'the root layout runs once') + assert.equal((html.match(/
    /g) ?? []).length, 1, 'the middle layout runs once') + assert.equal((html.match(/
    /g) ?? []).length, 1, 'the inner layout runs once') + const styles = [...html.matchAll(/ match[1]?.replace(/-[A-Z0-9]+\./, '.')) + assert.deepEqual(styles, ['/global.css', '/root.layout.css', '/article.layout.css', '/post.layout.css', ...(title === 'source' ? ['./style.css'] : [])]) + const scripts = [...html.matchAll(/