From 357b23234ea81000078292ccf22d32c49be1fe13 Mon Sep 17 00:00:00 2001 From: Bret Comnes Date: Mon, 7 Sep 2026 11:46:16 -0700 Subject: [PATCH 1/8] Add explicit nested layout chains --- README.md | 147 +++++------------- docs/v12-migration.md | 32 ++++ examples/blog/src/layouts/post.layout.css | 2 - examples/blog/src/layouts/post.layout.ts | 9 +- .../blog/src/layouts/year-index.layout.ts | 9 +- index.js | 52 +++---- lib/build-pages/index.js | 11 +- lib/build-pages/page-data.js | 54 ++++--- lib/build-pages/resolve-layout-chain.js | 29 ++++ lib/build-pages/resolve-layout-chain.test.js | 37 +++++ test-cases/nested-layouts/index.test.js | 142 +++++++++++++++++ 11 files changed, 348 insertions(+), 176 deletions(-) create mode 100644 lib/build-pages/resolve-layout-chain.js create mode 100644 lib/build-pages/resolve-layout-chain.test.js create mode 100644 test-cases/nested-layouts/index.test.js diff --git a/README.md b/README.md index 2b9da99f..629568d4 100644 --- a/README.md +++ b/README.md @@ -2231,138 +2231,63 @@ 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`: +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' +export const parentLayout = 'root' +export const vars = { showSidebar: true } -// 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 - } -
-
- `) - - 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 }) +// post.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: - -```typescript -export default function articleLayout ({ children, vars, scripts, styles, page, pages, workers }) { - return defaultRootLayout({ children, vars, scripts, styles, page, pages, workers }) -} -``` +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. -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. - -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: +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. -```css -/* article.layout.css */ -@import "./root.layout.css"; -``` +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. -This will include the layout style from the `root` layout in the `article` layout style. +#### Manual composition -```typescript -/* article.layout.client.ts */ -import './root.layout.client.ts' -``` +Existing function-based composition remains supported. +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. -Adding these imports will include the `root.layout.ts` layout assets into the `blog.layout.ts` asset files. +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..2362a99a 100644 --- a/docs/v12-migration.md +++ b/docs/v12-migration.md @@ -116,6 +116,38 @@ 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`. + +```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 continue to work. +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/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..e1787cf1 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' @@ -50,7 +50,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 +92,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 */ @@ -212,6 +213,7 @@ 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) buildLogger(report, this.#logger) this.#logger.info('Initial JS, CSS and Page Build Complete') } catch (err) { @@ -458,6 +460,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 +469,7 @@ ${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) buildLogger( isFiltered ? pageBuildResults : { warnings: pageBuildResults.warnings, siteData, pageBuildResults }, this.#logger, @@ -579,7 +583,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 +629,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 @@ -926,9 +926,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-data.js b/lib/build-pages/page-data.js index c7d1d840..1ad433c4 100644 --- a/lib/build-pages/page-data.js +++ b/lib/build-pages/page-data.js @@ -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')), } } @@ -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[]} Outermost to innermost. */ layoutChain = [] /** @type {Partial} */ globalVars /** @type {Partial} */ globalDataVars = {} /** @type {Partial} */ layoutVars = {} @@ -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) { @@ -329,20 +333,22 @@ export class PageData { */ 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: /** @type {U} */ (/** @type {unknown} */ (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..d5ee0fe7 --- /dev/null +++ b/lib/build-pages/resolve-layout-chain.test.js @@ -0,0 +1,37 @@ +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 chains without inferring parents from vars or imports', () => { + const root = { name: 'root' } + const article = { name: 'article', parentLayout: 'root' } + const post = { name: 'post', parentLayout: 'article', vars: { layout: 'unrelated' } } + const layouts = { root, article, post } + assert.deepEqual(resolveLayoutChain('post', layouts), [root, article, post]) + assert.deepEqual(resolveLayoutChain('root', layouts), [root]) + 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('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/test-cases/nested-layouts/index.test.js b/test-cases/nested-layouts/index.test.js new file mode 100644 index 00000000..d79449ed --- /dev/null +++ b/test-cases/nested-layouts/index.test.js @@ -0,0 +1,142 @@ +/** @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 } = await setup(t) + 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*
    /) + 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(/