From 71fca078adf5dca75c477de295752e13faece2fa Mon Sep 17 00:00:00 2001 From: Bret Comnes Date: Wed, 16 Sep 2026 18:21:11 -0700 Subject: [PATCH 01/20] fix(page-outputs): preserve subscription error metadata --- lib/build-pages/page-data.js | 5 +++- lib/build-pages/page-outputs.js | 7 +++++- test-cases/page-outputs/index.test.js | 36 +++++++++++++++++++++++++++ 3 files changed, 46 insertions(+), 2 deletions(-) diff --git a/lib/build-pages/page-data.js b/lib/build-pages/page-data.js index e4167e76..332b5da3 100644 --- a/lib/build-pages/page-data.js +++ b/lib/build-pages/page-data.js @@ -433,7 +433,10 @@ export class PageData { try { yield * normalizePageOutputs(hook({ page, vars: pageData.vars, data: getData() }), provenance) } catch (cause) { - throw new Error(`pageOutputs for page "${pageData.pageInfo.pageFile.relname}" from ${provenance.kind} "${provenance.source}" failed: ${cause instanceof Error ? cause.message : String(cause)}`, { cause }) + const message = `pageOutputs for page "${pageData.pageInfo.pageFile.relname}" from ${provenance.kind} "${provenance.source}" failed: ${cause instanceof Error ? cause.message : String(cause)}` + throw cause instanceof DomStackDataError + ? new DomStackDataError(message, cause.dataDependency, { cause }) + : new Error(message, { cause }) } } for (const layout of this.layoutChain) { diff --git a/lib/build-pages/page-outputs.js b/lib/build-pages/page-outputs.js index f8f8668f..a22d4576 100644 --- a/lib/build-pages/page-outputs.js +++ b/lib/build-pages/page-outputs.js @@ -15,6 +15,8 @@ * @typedef {Readonly> & { readonly pageFile: Readonly, readonly readMarkdownContent: () => Promise }} PageOutputsPage */ +import { DomStackDataError } from '../helpers/domstack-error.js' + /** * @template {Record} [T=Record] * @template {object} [D=Record] @@ -75,7 +77,10 @@ export async function * normalizePageOutputs (result, provenance) { yield validate(resolved) } } catch (cause) { - throw new Error(`Invalid pageOutputs from ${provenance.kind} "${provenance.source}": ${cause instanceof Error ? cause.message : String(cause)}`, { cause }) + const message = `Invalid pageOutputs from ${provenance.kind} "${provenance.source}": ${cause instanceof Error ? cause.message : String(cause)}` + throw cause instanceof DomStackDataError + ? new DomStackDataError(message, cause.dataDependency, { cause }) + : new Error(message, { cause }) } } diff --git a/test-cases/page-outputs/index.test.js b/test-cases/page-outputs/index.test.js index 8f1526f6..ebdafe31 100644 --- a/test-cases/page-outputs/index.test.js +++ b/test-cases/page-outputs/index.test.js @@ -3,6 +3,7 @@ import assert from 'node:assert/strict' import { stat, utimes } from 'node:fs/promises' import { join } from 'node:path' import { errorText, hook, setup, writeFiles } from './helpers.js' +import { DomStackDataError } from '../../lib/helpers/domstack-error.js' const rawLayout = `export default ({ children }) => '
' + children + '
' export const pageOutputs = async ({ page }) => ({ outputName: './source.txt', content: await page.readMarkdownContent() })` @@ -98,6 +99,41 @@ for (const extension of ['html', 'js', 'ts']) { }) } +for (const provider of ['page', 'companion', 'layout']) { + for (const scenario of [ + { name: 'synchronous', declaration: 'function', body: "return { outputName: 'secret.txt', content: data.secret }" }, + { name: 'asynchronous', declaration: 'async function', body: "await Promise.resolve(); return { outputName: 'secret.txt', content: data.secret }" }, + { name: 'iterator', declaration: 'async function*', body: "yield { outputName: 'first.txt', content: 'written' }; yield { outputName: 'secret.txt', content: data.secret }" }, + ]) { + test(`subscription errors survive worker transport from ${scenario.name} ${provider} pageOutputs`, async t => { + const providerFile = provider === 'layout' ? 'root.layout.js' : provider === 'companion' ? 'page.vars.js' : 'page.js' + const render = provider === 'layout' ? 'export default ({ children }) => children' : provider === 'companion' ? 'export default {}' : "export default () => 'main'" + const { build, src, read } = await setup(t, { + 'global.data.js': "export default { secret: 'private' }", + 'page.js': "export default () => 'main'", + [providerFile]: `${render}; export ${scenario.declaration} pageOutputs ({ data }) { ${scenario.body} }`, + }) + await assert.rejects(build(), error => { + assert.ok(error instanceof AggregateError) + const dataError = error.errors.find(err => err instanceof DomStackDataError) + assert.ok(dataError, 'a DomStackDataError survives worker transport') + assert.equal(dataError.name, 'DomStackDataError') + assert.equal(dataError.code, 'DOM_STACK_ERROR_DATA') + assert.deepEqual(dataError.dataDependency, { + reason: 'UNDECLARED_KEY', + consumer: provider === 'layout' ? 'Layout "root"' : 'Page "page.js"', + key: 'secret', + }) + assert.ok(dataError.message.includes(`pageOutputs for page "page.js" from ${provider} "${join(src, providerFile)}"`)) + assert.ok(dataError.cause instanceof Error) + assert.match(errorText(dataError.cause), /undeclared global data key "secret"/) + return true + }) + if (scenario.name === 'iterator') assert.equal(await read('first.txt'), 'written') + }) + } +} + test('JS page modules support promised async iterables, arrays, and empty results', async t => { const { build, read } = await setup(t, { 'page.js': `export default () => 'main'; export const pageOutputs = async () => (async function* () { From 6b958c112ea9e293cf28c520ed2f9e8c88ce53f4 Mon Sep 17 00:00:00 2001 From: Bret Comnes Date: Wed, 16 Sep 2026 18:21:19 -0700 Subject: [PATCH 02/20] fix(types): accept explicit undefined in optional build options --- lib/build-esbuild/index.js | 2 +- lib/domstack-manifest/records.js | 4 ++-- lib/identify-pages.js | 4 ++-- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/lib/build-esbuild/index.js b/lib/build-esbuild/index.js index 7dacd392..f37f86c4 100644 --- a/lib/build-esbuild/index.js +++ b/lib/build-esbuild/index.js @@ -569,7 +569,7 @@ export async function buildEsbuildWatch (src, dest, siteData, opts, watchOpts = * @param {string} params.dest * @param {string} params.label * @param {PinoLogger} params.logger - * @param {(result: esbuild.BuildResult) => void | Promise} [params.onEnd] + * @param {((result: esbuild.BuildResult) => void | Promise) | undefined} [params.onEnd] * @param {boolean} params.shouldWriteMetafile * @returns {Promise<{ context: esbuild.BuildContext, initialResult: esbuild.BuildResult }>} */ diff --git a/lib/domstack-manifest/records.js b/lib/domstack-manifest/records.js index 7148143e..d050bb8f 100644 --- a/lib/domstack-manifest/records.js +++ b/lib/domstack-manifest/records.js @@ -16,8 +16,8 @@ import { hashFileDigest, revisionToIntegrity } from './hash.js' * @param {string} [params.outputRelname] - Destination-relative output path when `filepath` should not be relativized. * @param {DomstackManifestKind} params.kind - Build artifact category for this output. * @param {string} [params.url] - Public same-origin URL when the default output path URL is not correct. - * @param {string} [params.sourceRelname] - Source-relative path that produced this output when known. - * @param {string} [params.entryPoint] - esbuild entry point path for bundled outputs when available. + * @param {string | undefined} [params.sourceRelname] - Source-relative path that produced this output when known. + * @param {string | undefined} [params.entryPoint] - esbuild entry point path for bundled outputs when available. * @param {string} [params.pagePath] - Source-relative page path for page-owned outputs. * @param {string} [params.pageUrl] - Canonical public page URL for page-owned outputs. * @param {string} [params.templatePath] - Source-relative template path for template outputs. diff --git a/lib/identify-pages.js b/lib/identify-pages.js index 2ba86995..8b6f55d6 100644 --- a/lib/identify-pages.js +++ b/lib/identify-pages.js @@ -111,8 +111,8 @@ const shaper = ({ * @export * @param {string} src - The source directory to identify pages from. * @param {object} [opts={}] - Options to modify the behavior of the function. - * @param {string[]?} [opts.ignore] - Array of file/folder patterns to ignore during the walk. - * @param {boolean?} [opts.buildDrafts=false] - Includes pages with the variable publushed:false when set to true + * @param {string[] | null | undefined} [opts.ignore] - Array of file/folder patterns to ignore during the walk. + * @param {boolean | null | undefined} [opts.buildDrafts=false] - Includes pages with the variable publushed:false when set to true * @throws When the `src` argument is not provided or something else goes wrong. */ export async function identifyPages (src, opts = {}) { From dc66aad04fa914e527046f614a525aa0ea976790 Mon Sep 17 00:00:00 2001 From: Bret Comnes Date: Wed, 16 Sep 2026 18:21:45 -0700 Subject: [PATCH 03/20] refactor: replace make-array with native ignore normalization --- index.js | 4 +--- package.json | 1 - .../constructor-copy-paths/index.test.js | 21 +++++++++++++++++++ 3 files changed, 22 insertions(+), 4 deletions(-) diff --git a/index.js b/index.js index 31b284ba..a4fb63cb 100644 --- a/index.js +++ b/index.js @@ -32,8 +32,6 @@ import { lstat, mkdtemp, readFile, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import chokidar from 'chokidar' import { basename, dirname, join, relative, resolve } from 'node:path' -// @ts-expect-error -import makeArray from 'make-array' import ignore from 'ignore' import { watch as cpxWatch } from 'cpx2' import { inspect } from 'util' @@ -1058,7 +1056,7 @@ function normalizeDomStackOpts (opts, dest) { ...DEFAULT_IGNORES, basename(dest), ...copyDirs.map(dir => basename(dir)), - ...makeArray(buildOpts.ignore), + ...[buildOpts.ignore ?? []].flat(), ], } } diff --git a/package.json b/package.json index 62747e1b..3a9fb6b0 100644 --- a/package.json +++ b/package.json @@ -63,7 +63,6 @@ "ignore": "^7.0.0", "js-yaml": "^5.1.0", "json-schema-to-ts": "^3.1.1", - "make-array": "^1.0.5", "markdown-it": "^15.0.0", "markdown-it-abbr": "^2.0.0", "markdown-it-anchor": "^10.0.0", diff --git a/test-cases/constructor-copy-paths/index.test.js b/test-cases/constructor-copy-paths/index.test.js index cea92167..61701c7e 100644 --- a/test-cases/constructor-copy-paths/index.test.js +++ b/test-cases/constructor-copy-paths/index.test.js @@ -1,3 +1,4 @@ +/** @import { DomStackOpts } from '../../lib/builder.js' */ import { test } from 'node:test' import assert from 'node:assert' import { isAbsolute, resolve, join } from 'node:path' @@ -7,6 +8,26 @@ import { DomStack } from '../../index.js' const tmpSrc = join(tmpdir(), 'domstack-test-src') const tmpDest = join(tmpdir(), 'domstack-test-dest') +for (const { name, options, expected } of [ + { name: 'omitted', options: {}, expected: [] }, + { name: 'undefined', options: { ignore: undefined }, expected: [] }, + { name: 'null', options: { ignore: null }, expected: [] }, + { name: 'empty array', options: { ignore: [] }, expected: [] }, + { name: 'array', options: { ignore: ['private', '*.secret'] }, expected: ['private', '*.secret'] }, + { name: 'single string', options: { ignore: 'private' }, expected: ['private'] }, +]) { + test(`DomStack constructor normalizes ${name} ignore options`, () => { + const defaults = new DomStack(tmpSrc, tmpDest).opts.ignore ?? [] + // JavaScript callers have historically been able to pass null or a single string. + const ds = new DomStack(tmpSrc, tmpDest, /** @type {DomStackOpts} */ (options)) + assert.deepStrictEqual(ds.opts.ignore, [...defaults, ...expected]) + if (Array.isArray(options.ignore)) { + assert.deepStrictEqual(options.ignore, expected, 'the caller\'s array is not mutated') + assert.notStrictEqual(ds.opts.ignore, options.ignore) + } + }) +} + test.describe('DomStack constructor - copy path resolution', () => { test('resolves a relative copy path to an absolute path', () => { const ds = new DomStack(tmpSrc, tmpDest, { From f1a0197f9fac490654f76c149c86960071680fdd Mon Sep 17 00:00:00 2001 From: Bret Comnes Date: Wed, 16 Sep 2026 18:21:56 -0700 Subject: [PATCH 04/20] refactor(watch): isolate lifecycle, dependency routing, and output ownership Extract the public facade's watch implementation into lib/watch, retain the pure planner, and add focused lifecycle and bookkeeping tests. Reduce repeated planning, dependency analysis, ownership scans, and disabled logging work. --- docs/implementation/README.md | 13 +- index.js | 1027 +---------------- lib/build-pages/global-data-state.js | 2 +- lib/build-pages/global-data-state.test.js | 2 +- lib/watch/dependency-index.js | 284 +++++ lib/watch/dependency-index.test.js | 416 +++++++ lib/watch/index.js | 596 ++++++++++ lib/watch/logging.js | 103 ++ lib/watch/logging.test.js | 158 +++ lib/watch/page-output-ledger.js | 139 +++ lib/watch/page-output-ledger.test.js | 361 ++++++ lib/{watch-plan.js => watch/plan.js} | 116 +- .../plan.test.js} | 66 +- test-cases/page-outputs/watch.test.js | 18 + test-cases/watch-lifecycle/index.test.js | 29 + types.ts | 2 +- 16 files changed, 2277 insertions(+), 1055 deletions(-) create mode 100644 lib/watch/dependency-index.js create mode 100644 lib/watch/dependency-index.test.js create mode 100644 lib/watch/index.js create mode 100644 lib/watch/logging.js create mode 100644 lib/watch/logging.test.js create mode 100644 lib/watch/page-output-ledger.js create mode 100644 lib/watch/page-output-ledger.test.js rename lib/{watch-plan.js => watch/plan.js} (80%) rename lib/{watch-plan.test.js => watch/plan.test.js} (89%) diff --git a/docs/implementation/README.md b/docs/implementation/README.md index 6514d50e..c4c24d88 100644 --- a/docs/implementation/README.md +++ b/docs/implementation/README.md @@ -193,7 +193,18 @@ Watch mode coordinates three independent watchers: Chokidar events pass through a pure planner before any rebuild executes. The planner reads an explicit snapshot of discovery, dependency maps, and the previous page-build outcome; it does not perform I/O or mutate that state. -`DomStack` owns the watch session, serializes events, executes plans, and releases its watchers, esbuild context, and server on shutdown. +The public `DomStack` class in `index.js` validates and normalizes options, runs one-shot builds, and delegates its watch API to an internal `DomStackWatcher` in `lib/watch/index.js`. +Watch coordination and its helpers live together in `lib/watch/`: + +- `index.js` owns the watch session, serializes events, executes plans, and releases its watchers, esbuild context, and server on shutdown. +- `plan.js` makes pure rebuild decisions from an explicit routing snapshot. +- `dependency-index.js` owns file-dependency maps and successful source-page and generated-page layout selections. +- `page-output-ledger.js` owns output claims, the sidecar write cache, and safe removal of obsolete page-owned files. +- `logging.js` formats rebuild trees, errors, and build summaries. + +Initial page builds and rebuilds share one acceptance path: record emitted files, reject page errors, reconcile obsolete outputs, refresh dependency routing, then commit subscriptions and the global-data baseline. +Recording writes is separate from accepting the baseline because failed builds and failed cleanup can still leave files on disk. +One coordinator and output ledger are retained per `DomStack` instance so output ownership survives stop/start cycles, while shutdown clears session resources and global-data state.
 flowchart TD
diff --git a/index.js b/index.js
index a4fb63cb..c178f4e5 100644
--- a/index.js
+++ b/index.js
@@ -1,55 +1,20 @@
 /// 
 
 /**
- * @import { DomStackOpts, Results, SiteData } from './lib/builder.js'
-
- * @import { FSWatcher } from 'chokidar'
- * @import { WorkerBuildStepResult } from './lib/build-pages/index.js'
- * @import { PageInfo, TemplateInfo, PagesFileInfo } from './lib/identify-pages.js'
+ * @import { DomStackOpts, Results } from './lib/builder.js'
  * @import { TestBuildResult } from './types.js'
- * @import { BsInstance } from '@domstack/sync'
- * @import { Logger as PinoLogger } from 'pino'
- * @import { DomstackManifestRecord } from './lib/domstack-manifest/index.js'
- * @import { WatchDependencyState } from './lib/build-pages/watch-dependencies.js'
- * @import { PageOutputCache } from './lib/build-pages/page-builders/page-output-writer.js'
- * @import { WatchSnapshot, WatchEvent, WatchPlan } from './lib/watch-plan.js'
- * @import { GlobalDataBaseline, GlobalDataInputChanges } from './lib/build-pages/global-data-state.js'
- * @typedef {{ dispose: () => Promise }} DisposableBuildContext
- * @typedef {{ pageFilePath: string, sourcePageFilePath?: string | undefined, pagesFilePath?: string | undefined, layoutNames: string[], outputs?: DomstackManifestRecord[] | undefined }} WatchedPageReport
- * @typedef {object} WatchSession
- * @property {'starting' | 'watching' | 'stopping'} state
- * @property {AbortController} cancellation - Cancels event waits, not resource acquisition.
- * @property {Promise} startupWork - The current resource-acquiring startup phase; never the user callback.
- * @property {Promise | null} shutdown - Shared by explicit stops and startup failure cleanup.
- * @property {WatchEvent[]} pendingEvents
- * @property {boolean} drainScheduled
- * @property {GlobalDataBaseline | null} globalDataBaseline
+ * @import { DisposableBuildContext as DisposableBuildContextType, WatchedPageReport as WatchedPageReportType, WatchSession as WatchSessionType } from './lib/watch/index.js'
+ * @typedef {DisposableBuildContextType} DisposableBuildContext
+ * @typedef {WatchedPageReportType} WatchedPageReport
+ * @typedef {WatchSessionType} WatchSession
  */
-import { once } from 'events'
-import { setImmediate } from 'node:timers/promises'
-
-import { lstat, mkdtemp, readFile, rm } from 'node:fs/promises'
+import { mkdtemp, readFile, rm } from 'node:fs/promises'
 import { tmpdir } from 'node:os'
-import chokidar from 'chokidar'
-import { basename, dirname, join, relative, resolve } from 'node:path'
-import ignore from 'ignore'
-import { watch as cpxWatch } from 'cpx2'
-import { inspect } from 'util'
-import { createServer } from '@domstack/sync'
-import { find } from '@11ty/dependency-tree-typescript'
+import { basename, join, relative, resolve } from 'node:path'
 
-import { assertInsideDest } from './lib/helpers/path.js'
-import { getCopyGlob } from './lib/build-static/index.js'
-import { getCopyDirs } from './lib/build-copy/index.js'
-import { isProcessedFile, globalBundleAssets, pageBundleAssets, layoutBundleAssets } from './lib/file-conventions.js'
 import { builder } from './lib/builder.js'
-import { buildEsbuildWatch } from './lib/build-esbuild/index.js'
-import { buildPages } from './lib/build-pages/index.js'
-import { identifyPages } from './lib/identify-pages.js'
-import { classifyWatchEvent, planWatchEvent, planWatchBatch, planBundleChange } from './lib/watch-plan.js'
-import { ensureDest } from './lib/helpers/ensure-dest.js'
-import { DomStackAggregateError } from './lib/helpers/domstack-aggregate-error.js'
 import { createDomStackLogger } from './lib/logger.js'
+import { DomStackWatcher } from './lib/watch/index.js'
 
 export { PageData } from './lib/build-pages/page-data.js'
 export {
@@ -77,61 +42,7 @@ export class DomStack {
   /** @type {string} */ #src = ''
   /** @type {string} */ #dest = ''
   /** @type {Readonly} */ opts
-  /** @type {FSWatcher?} */ #watcher = null
-  /** @type {ReturnType[]} */ #cpxWatchers = []
-  /** @type {BsInstance?} */ #syncServer = null
-  /** @type {DisposableBuildContext?} */ #esbuildContext = null
-  /** @type {SiteData?} */ #siteData = null
-  /** @type {PinoLogger} */ #logger
-
-  // Watch maps (rebuilt after every full rebuild)
-  /** @type {Map>} depFilepath → Set */
-  #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 */
-  #layoutFileMap = new Map()
-  /** @type {Map>} depFilepath → Set */
-  #pageDepMap = new Map()
-  /** @type {Map>} depFilepath → Set */
-  #templateDepMap = new Map()
-  /** @type {Map>} depFilepath → Set */
-  #pagesFileDepMap = new Map()
-  /** @type {Set} Imported inputs of global.data, including its entry file. */
-  #globalDataDepPaths = new Set()
-  /** @type {Set} Settings roots and imports always require a full rebuild. */
-  #settingsDepPaths = new Set()
-  #dependencyAnalysisFailed = false
-  /** @type {Set} absolute filepaths of esbuild entry points */
-  #esbuildEntryPoints = new Set()
-  /** @type {Set} Known browser-only helpers can skip the page phase. */
-  #esbuildDepPaths = new Set()
-  /** @type {Map>} source page or *.pages.* filepath → owned absolute output paths */
-  #pageOutputMap = new Map()
-  /** @type {PageOutputCache} Successful writes, including those before an iterator failure. */
-  #pageOutputCache = new Map()
-  /** @type {Map>} template filepath → currently claimed absolute output paths */
-  #templateOutputMap = new Map()
-  /** @type {Map>} *.pages.* filepath → layouts used by its generated pages */
-  #pagesFileLayoutMap = new Map()
-  /** @type {WatchDependencyState | null} subscriptions and fingerprints from the last successful page build */
-  #watchDependencies = null
-  /** @type {boolean} Failed builds may leave the previous routing state incomplete. */
-  #pageBuildFailed = false
-
-  // One session owns the resources above until shutdown finishes.
-  // Normal path: absent → starting → watching → stopping → absent.
-  // Startup failure or cancellation: starting → stopping → absent.
-  /** @type {WatchSession | null} */
-  #watchSession = null
-
-  // Serialized lock so concurrent chokidar events don't pile up
-  /** @type {Promise} */
-  #buildLock = Promise.resolve()
+  /** @type {DomStackWatcher} */ #watcher
 
   /**
    * Create a DomStack build instance.
@@ -150,7 +61,7 @@ export class DomStack {
 
     this.#src = src
     this.#dest = dest
-    this.#logger = opts.logger ?? createDomStackLogger()
+    const logger = opts.logger ?? createDomStackLogger()
     this.opts = normalizeDomStackOpts(opts, dest)
 
     const copyDirs = this.opts.copy ?? []
@@ -164,11 +75,14 @@ export class DomStack {
         }
       }
     }
+
+    // Reuse the coordinator so output ownership survives stop/start cycles.
+    this.#watcher = new DomStackWatcher(src, dest, () => this.opts, logger)
   }
 
   /** True from the start of watch() until shutdown completes, including startup. */
   get watching () {
-    return this.#watchSession !== null
+    return this.#watcher.watching
   }
 
   build () {
@@ -186,742 +100,18 @@ export class DomStack {
    * @param  {(results: Results) => void | Promise} [params.onInitialBuild]
    * @return {Promise}
    */
-  async watch ({
-    serve,
-    onInitialBuild,
-  } = {
-    serve: true,
-  }) {
-    if (this.watching) throw new Error('Already watching.')
-    /** @type {WatchSession} */
-    const session = {
-      state: 'starting',
-      cancellation: new AbortController(),
-      startupWork: Promise.resolve(),
-      shutdown: null,
-      pendingEvents: [],
-      drainScheduled: false,
-      globalDataBaseline: null,
-    }
-    this.#watchSession = session
-    try {
-      return await this.#startWatch(session, { serve, onInitialBuild })
-    } catch (error) {
-      try {
-        await this.#stopWatchSession(session)
-      } catch (cleanupError) {
-        // The callback may already be propagating this same shutdown failure.
-        if (error === cleanupError) throw error
-        throw new AggregateError([error, cleanupError], 'Watch startup and cleanup failed')
-      }
-      throw error
-    }
-  }
-
-  /**
-   * Resource acquisition must finish before shutdown can release its results.
-   * Readiness waits, on the other hand, must be cancelled when watchers close.
-   * The user callback is outside the acquisition phases so it can await a stop.
-   *
-   * @param {WatchSession} session
-   * @param {{ serve: boolean, onInitialBuild: ((results: Results) => void | Promise) | undefined }} params
-   */
-  async #startWatch (session, { serve, onInitialBuild }) {
-    const { signal } = session.cancellation
-    const preparation = this.#prepareWatch(session)
-    session.startupWork = preparation
-    const report = await preparation
-    if (signal.aborted) return report
-
-    await onInitialBuild?.(report)
-    if (signal.aborted) return report
-
-    if (serve) {
-      session.startupWork = this.#startWatchServer()
-      await session.startupWork
-      if (signal.aborted) return report
-    }
-
-    session.state = 'watching'
-    this.#scheduleWatchBatch(session)
-
-    return report
-  }
-
-  /** @param {WatchSession} session */
-  async #prepareWatch (session) {
-    const { signal } = session.cancellation
-    // Establish observation before discovery. Initial scan adds are not edits;
-    // subsequent events stay buffered until startup and the user callback finish.
-    await this.#createSourceWatcher(session)
-    // ── Initial build (inline, not via builder()) ────────────────────────
-    const siteData = await identifyPages(this.#src, this.opts)
-
-    if (siteData.errors.length > 0) {
-      throw new DomStackAggregateError(siteData.errors, 'Page walk finished but there were errors.', siteData)
-    }
-
-    await ensureDest(this.#dest, siteData)
-
-    // Start esbuild in watch mode (stable filenames, no hash)
-    let esbuildContext
-    try {
-      const { context } = await buildEsbuildWatch(this.#src, this.#dest, siteData, this.opts, { logger: this.#logger })
-      esbuildContext = context
-    } catch (err) {
-      throw new Error('Error starting esbuild watch context', { cause: err })
-    }
-    this.#esbuildContext = esbuildContext
-    this.#siteData = siteData
-
-    // Build pages (initial full build)
-    let report
-    try {
-      const pageBuildResults = await buildPages(this.#src, this.#dest, siteData, {
-        ...this.opts,
-        trackWatchDependencies: true,
-      })
-      this.#pageOutputCache = pageBuildResults.report.pageOutputCache ?? this.#pageOutputCache
-      delete pageBuildResults.report.pageOutputCache
-      if (pageBuildResults.errors.length > 0) {
-        this.#rememberPartialPageOutputs(pageBuildResults)
-        throw new DomStackAggregateError(pageBuildResults.errors, 'Page build finished but there were errors.', {
-          siteData,
-          pageBuildResults,
-        })
-      }
-      report = {
-        warnings: [...siteData.warnings, ...pageBuildResults.warnings],
-        siteData,
-        pageBuildResults,
-      }
-      await this.#removeObsoletePageOutputs(pageBuildResults, false)
-      this.#pagesFileLayoutMap = getPagesFileLayoutMap(pageBuildResults.report.pages)
-      this.#updatePageLayoutNames(pageBuildResults.report.pages, true)
-      this.#pageBuildFailed = false
-      this.#watchDependencies = pageBuildResults.report.watchDependencies ?? null
-      session.globalDataBaseline = pageBuildResults.report.globalDataBaseline ?? null
-      delete pageBuildResults.report.globalDataBaseline
-      delete pageBuildResults.report.watchDependencies
-      delete pageBuildResults.report.rebuiltPagesFilePaths
-      buildLogger(report, this.#logger)
-      this.#logger.debug('Initial JS, CSS and Page Build Complete')
-    } catch (err) {
-      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
-    await this.#rebuildMaps(siteData)
-
-    // Copy readiness is cancellable: cpx2 invalidates pending scans on close.
-    const copyDirs = getCopyDirs(this.opts.copy ?? [])
-    const copyStartup = await Promise.allSettled([
-      this.#startCopyWatcher(getCopyGlob(this.#src), signal, this.opts.ignore ?? []),
-      ...copyDirs.map(copyDir => this.#startCopyWatcher(copyDir, signal)),
-    ])
-    const copyErrors = copyStartup.filter(result => result.status === 'rejected').map(result => result.reason)
-    if (copyErrors.length) throw new AggregateError(copyErrors, 'Copy watch startup failed')
-
-    return report
-  }
-
-  /** @param {WatchSession} session */
-  #createSourceWatcher (session) {
-    const { signal } = session.cancellation
-    const ig = ignore().add(this.opts.ignore ?? [])
-
-    const anymatch = (/** @type {string} */name) => ig.ignores(relname(this.#src, name))
-
-    const watcher = chokidar.watch(this.#src, {
-      // Observe non-page extensions too (for example statically imported JSON).
-      // Route only processed files and known dependencies after maps are ready.
-      ignored: filePath => anymatch(filePath),
-      persistent: true,
-      ignoreInitial: true,
-      // Increase the atomic write window so editors that do slow atomic saves
-      // (write to a temp file then rename) emit a `change` event rather than
-      // `unlink` + `add`, which would otherwise trigger unnecessary full rebuilds.
-      atomic: 300,
-    })
-
-    this.#watcher = watcher
-    const record = (/** @type {string} */ path, /** @type {WatchEvent['type']} */ type) => {
-      if (session.state === 'stopping') return
-      session.pendingEvents.push(classifyWatchEvent(type, path))
-      this.#scheduleWatchBatch(session)
-    }
-    watcher.on('add', path => record(path, 'added'))
-    watcher.on('change', path => record(path, 'change'))
-    watcher.on('unlink', path => record(path, 'removed'))
-    watcher.on('error', err => errorLogger(err, this.#logger))
-    // Attach the listener before returning; the watcher can become ready before
-    // the caller resumes. Cancellation settles this wait even without a ready event.
-    return once(watcher, 'ready', { signal }).catch(error => {
-      if (!signal.aborted || error.name !== 'AbortError') throw error
-    })
-  }
-
-  /**
-   * @param {string} source
-   * @param {AbortSignal} signal
-   * @param {string[]} [ignores]
-   */
-  async #startCopyWatcher (source, signal, ignores = []) {
-    const watcher = cpxWatch(source, this.#dest, { ignore: ignores })
-    this.#cpxWatchers.push(watcher)
-    let ready = false
-    let initialCopies = 0
-    watcher.on('copy', (/** @type{{ srcPath: string, dstPath: string }} */e) => {
-      if (!ready) initialCopies++
-      this.#logger.debug(`Copy ${e.srcPath} to ${e.dstPath}`)
-      if (ready) this.#logger.info(`Static asset updated: ${e.srcPath}`)
-    })
-    watcher.on('remove', (/** @type{{ path: string }} */e) => {
-      this.#logger.info(`Remove ${e.path}`)
-    })
-    watcher.on('watch-error', (/** @type{Error} */err) => {
-      this.#logger.error(`Copy error: ${err.message}`)
-    })
-
-    // cpx2 reports startup failure as "watch-error", not EventEmitter's "error".
-    // A closed session may never emit readiness, so cancellation must also settle
-    // this wait. This does not drain file operations already started by cpx2.
-    const { promise, resolve, reject } = Promise.withResolvers()
-    const onAbort = () => resolve(undefined)
-    watcher.once('watch-ready', resolve)
-    watcher.once('watch-error', reject)
-    signal.addEventListener('abort', onAbort, { once: true })
-    try {
-      if (signal.aborted) return
-      await promise
-      ready = true
-      if (!signal.aborted) this.#logger.info(`Static asset watcher ready (${initialCopies} initial copy operations)`)
-    } finally {
-      watcher.off('watch-ready', resolve)
-      watcher.off('watch-error', reject)
-      signal.removeEventListener('abort', onAbort)
-    }
-  }
-
-  async #startWatchServer () {
-    this.#syncServer = await createServer({
-      server: this.#dest,
-      files: basename(this.#dest),
-      ignore: ['**/domstack-esbuild-meta.json'],
-      logger: this.#logger.child({ component: 'sync', logPrefix: '[domstack-sync]' }),
-    })
-  }
-
-  /**
-   * Full rebuild: re-identify pages, restart esbuild, rebuild all pages, rebuild maps.
-   * Used for structural changes (add/unlink), global.vars.*, esbuild.settings.*.
-   */
-  async #fullRebuild (/** @type {GlobalDataInputChanges} */ inputChanges) {
-    this.#logger.info('Triggering full rebuild...')
-    // Dispose the old esbuild context
-    if (this.#esbuildContext) {
-      await this.#esbuildContext.dispose()
-      this.#esbuildContext = null
-    }
-
-    const siteData = await identifyPages(this.#src, this.opts)
-
-    if (siteData.errors.length > 0) {
-      throw new DomStackAggregateError(siteData.errors, 'Page discovery failed.', siteData)
-    }
-
-    await ensureDest(this.#dest, siteData)
-
-    const { context } = await buildEsbuildWatch(this.#src, this.#dest, siteData, this.opts, { logger: this.#logger })
-    this.#esbuildContext = context
-    this.#siteData = siteData
-
-    await this.#runPageBuild(siteData, null, null, null, inputChanges)
-  }
-
-  /** @returns {WatchSnapshot | undefined} */
-  #watchSnapshot () {
-    if (!this.#siteData) return
-    return {
-      siteData: this.#siteData,
-      layoutDepMap: this.#layoutDepMap,
-      layoutPageMap: this.#layoutPageMap,
-      pageFileMap: this.#pageFileMap,
-      layoutFileMap: this.#layoutFileMap,
-      pageDepMap: this.#pageDepMap,
-      templateDepMap: this.#templateDepMap,
-      pagesFileDepMap: this.#pagesFileDepMap,
-      pagesFileLayoutMap: this.#pagesFileLayoutMap,
-      globalDataDepPaths: this.#globalDataDepPaths,
-      settingsDepPaths: this.#settingsDepPaths,
-      dependencyAnalysisFailed: this.#dependencyAnalysisFailed,
-      pageBuildFailed: this.#pageBuildFailed,
-      esbuildEntryPoints: this.#esbuildEntryPoints,
-      esbuildDepPaths: this.#esbuildDepPaths,
-    }
-  }
-
-  /** @param {WatchEvent[]} events */
-  #filterWatchEvents (events) {
-    // Unknown inputs may be needed to recover after a failed build or analysis.
-    if (this.#pageBuildFailed || this.#dependencyAnalysisFailed) return events
-
-    const dependencies = [
-      this.#globalDataDepPaths,
-      this.#settingsDepPaths,
-      this.#layoutDepMap,
-      this.#pageDepMap,
-      this.#templateDepMap,
-      this.#pagesFileDepMap,
-      this.#esbuildDepPaths,
-    ]
-    return events.filter(({ filepath }) =>
-      isProcessedFile(filepath) || dependencies.some(paths => paths.has(filepath))
-    )
-  }
-
-  /** @param {WatchEvent[]} events */
-  async #handleWatchBatch (events) {
-    const snapshot = this.#watchSnapshot()
-    if (!snapshot) return
-    events = this.#filterWatchEvents(events)
-    const event = events[0]
-    if (!event) return
-    const { plan, inputChanges } = planWatchBatch(snapshot, events)
-    // Bundle replanning can skip page work, so it must not bypass a required reset.
-    const singlePlan = inputChanges.resetReason === undefined &&
-      events.length === 1 && event.type !== 'change' && event.convention?.bundleScope
-      ? planWatchEvent(snapshot, event)
-      : null
-    await this.#executeWatchPlan(
-      snapshot.pageBuildFailed
-        ? { kind: 'full', message: 'Rediscovering and retrying all pages after the previous build failure...' }
-        : singlePlan?.kind === 'restart' ? singlePlan : plan,
-      event, inputChanges
-    )
-  }
-
-  /**
-   * Keep resource ownership and successful-build state updates in the executor.
-   * @param {WatchPlan} plan
-   * @param {WatchEvent} event
-   * @param {GlobalDataInputChanges} inputChanges
-   * @returns {Promise}
-   */
-  async #executeWatchPlan (plan, event, inputChanges) {
-    if (plan.message) this.#logger.info(plan.message)
-    if (plan.kind === 'skip') return
-    if (plan.kind === 'full') {
-      await this.#fullRebuild(inputChanges)
-      return
-    }
-    if (plan.kind === 'restart') {
-      await this.#restartEsbuildForEvent(event, inputChanges)
-      return
-    }
-    if (!this.#siteData) return
-    if (plan.pages || plan.templates) {
-      logRebuildTree(event.name, this.#logger, new Set(plan.pages), new Set(plan.templates))
-    }
-    await this.#runPageBuild(this.#siteData, plan.pageFilterPaths, plan.templateFilterPaths, plan.pagesFileFilterPaths, inputChanges)
-  }
-
-  /** @param {WatchEvent} event @param {GlobalDataInputChanges} inputChanges */
-  async #restartEsbuildForEvent (event, inputChanges) {
-    const siteData = await identifyPages(this.#src, this.opts)
-    if (siteData.errors.length > 0) {
-      throw new DomStackAggregateError(siteData.errors, 'Page discovery failed.', siteData)
-    }
-    await ensureDest(this.#dest, siteData)
-    if (this.#esbuildContext) {
-      await this.#esbuildContext.dispose()
-      this.#esbuildContext = null
-    }
-    const { context } = await buildEsbuildWatch(this.#src, this.#dest, siteData, this.opts, { logger: this.#logger })
-    this.#esbuildContext = context
-    this.#siteData = siteData
-    const snapshot = this.#watchSnapshot()
-    if (!snapshot) return
-    const plan = planBundleChange(snapshot, event, this.#src)
-    // Successful page builds refresh their own maps. Service workers have no
-    // HTML consumers, but their entry map still changes.
-    if (plan.kind === 'skip') await this.#rebuildMaps(siteData)
-    await this.#executeWatchPlan(plan, event, inputChanges)
-  }
-
-  /**
-   * Run a full or filtered page build with the existing esbuild context.
-   *
-   * @param {SiteData} siteData
-   * @param {string[] | null} [pageFilterPaths]
-   * @param {string[] | null} [templateFilterPaths]
-   * @param {string[] | null} [pagesFileFilterPaths]
-   * @param {GlobalDataInputChanges} [inputChanges]
-   */
-  async #runPageBuild (siteData, pageFilterPaths = null, templateFilterPaths = null, pagesFileFilterPaths = null, inputChanges) {
-    // Retry the complete page phase after a failure: neither subscriptions nor
-    // layout routing from a failed build can safely drive an incremental retry.
-    if (this.#pageBuildFailed) pageFilterPaths = templateFilterPaths = pagesFileFilterPaths = null
-    try {
-      const pageBuildResults = await buildPages(this.#src, this.#dest, siteData, {
-        ...this.opts,
-        ...(pageFilterPaths ? { pageFilterPaths } : {}),
-        ...(templateFilterPaths ? { templateFilterPaths } : {}),
-        ...(pagesFileFilterPaths ? { pagesFileFilterPaths } : {}),
-        previousGlobalDataBaseline: this.#watchSession?.globalDataBaseline,
-        globalDataInputChanges: inputChanges,
-        previousWatchDependencies: this.#watchDependencies,
-        previousPageOutputCache: this.#pageOutputCache,
-        trackWatchDependencies: true,
-      })
-      this.#pageOutputCache = pageBuildResults.report.pageOutputCache ?? this.#pageOutputCache
-      delete pageBuildResults.report.pageOutputCache
-      if (pageBuildResults.errors.length > 0) {
-        this.#rememberPartialPageOutputs(pageBuildResults)
-        throw new DomStackAggregateError(pageBuildResults.errors, 'Page build finished but there were errors.', {
-          siteData,
-          pageBuildResults,
-        })
-      }
-      const isFiltered = pageFilterPaths !== null || templateFilterPaths !== null || pagesFileFilterPaths !== null
-      try {
-        await this.#removeObsoletePageOutputs(pageBuildResults, isFiltered)
-      } catch (error) {
-        // The worker's writes succeeded even if cleanup did not. Keep their
-        // ownership for recovery without committing producer/subscriber state.
-        this.#rememberPartialPageOutputs(pageBuildResults)
-        throw error
-      }
-      this.#updatePageLayoutNames(pageBuildResults.report.pages, !isFiltered)
-      if (!isFiltered) {
-        this.#pagesFileLayoutMap = getPagesFileLayoutMap(pageBuildResults.report.pages)
-      } else {
-        updatePagesFileLayoutMap(this.#pagesFileLayoutMap, pageBuildResults.report.rebuiltPagesFilePaths ?? [], pageBuildResults.report.pages)
-      }
-      await this.#rebuildMaps(siteData)
-      this.#watchDependencies = pageBuildResults.report.watchDependencies ?? this.#watchDependencies
-      if (this.#watchSession) this.#watchSession.globalDataBaseline = pageBuildResults.report.globalDataBaseline ?? null
-      delete pageBuildResults.report.globalDataBaseline
-      delete pageBuildResults.report.watchDependencies
-      delete pageBuildResults.report.rebuiltPagesFilePaths
-      this.#pageBuildFailed = false
-      buildLogger(
-        isFiltered ? pageBuildResults : { warnings: pageBuildResults.warnings, siteData, pageBuildResults },
-        this.#logger,
-        isFiltered ? this.#dest : undefined
-      )
-      return pageBuildResults
-    } catch (err) {
-      this.#pageBuildFailed = true
-      errorLogger(err, this.#logger)
-    }
-  }
-
-  /**
-   * Failed direct builds can leave new files. Keep their paths alongside prior
-   * ownership without cleaning anything up until a successful rebuild.
-   * @param {Pick} results
-   */
-  #rememberPartialPageOutputs (results) {
-    for (const [owner, outputs] of getPageOutputMap(resolve(this.#dest), results.report.pages)) {
-      const previous = this.#pageOutputMap.get(owner) ?? new Set()
-      for (const path of outputs) previous.add(path)
-      this.#pageOutputMap.set(owner, previous)
-    }
-  }
-
-  /**
-   * Reconcile page ownership only after a successful page phase. Untouched page
-   * and template owners still protect their outputs during targeted builds.
-   *
-   * @param {Pick} results
-   * @param {boolean} isFiltered
-   */
-  async #removeObsoletePageOutputs (results, isFiltered) {
-    const dest = resolve(this.#dest)
-    const rebuiltPages = getPageOutputMap(dest, results.report.pages)
-    const pages = isFiltered ? new Map(this.#pageOutputMap) : new Map()
-    const templates = isFiltered ? new Map(this.#templateOutputMap) : new Map()
-
-    // Factories can successfully rebuild to zero pages; regular pages always
-    // report their HTML output, even when their page-output hook is gone.
-    for (const owner of results.report.rebuiltPagesFilePaths ?? []) pages.delete(owner)
-    for (const [owner, outputs] of rebuiltPages) pages.set(owner, outputs)
-    for (const report of results.report.templates) {
-      templates.set(report.templateInfo.templateFile.filepath, new Set(
-        report.outputs.map(output => resolve(dest, report.templateInfo.path, output))
-      ))
-    }
-
-    const claimed = new Set(results.outputs.map(output => resolve(dest, output.outputRelname)))
-    for (const outputs of [...pages.values(), ...templates.values()]) {
-      for (const filepath of outputs) claimed.add(filepath)
-    }
-    const stale = new Set()
-    for (const outputs of this.#pageOutputMap.values()) {
-      for (const filepath of outputs) {
-        if (!claimed.has(filepath)) stale.add(filepath)
-      }
-    }
-    for (const filepath of stale) await removeStalePageOutput(dest, filepath)
-
-    const pageOwnedPaths = new Set([...pages.values()].flatMap(outputs => [...outputs]))
-    for (const filepath of this.#pageOutputCache.keys()) {
-      if (!pageOwnedPaths.has(filepath)) this.#pageOutputCache.delete(filepath)
-    }
-    this.#pageOutputMap = pages
-    this.#templateOutputMap = templates
-  }
-
-  /** @param {WatchSession} session */
-  #scheduleWatchBatch (session) {
-    if (session.state !== 'watching' || session.drainScheduled || !session.pendingEvents.length) return
-    session.drainScheduled = true
-    this.#buildLock = this.#buildLock.then(async () => {
-      try {
-        while (session.state === 'watching' && session.pendingEvents.length) {
-          // Coalesce the current event-loop turn, then detach. Events observed
-          // during asynchronous build work belong to the next batch, never this one.
-          await setImmediate()
-          if (session.state !== 'watching') break
-          const events = session.pendingEvents.splice(0)
-          try {
-            await this.#handleWatchBatch(events)
-          } catch (err) {
-            this.#pageBuildFailed = true
-            errorLogger(err, this.#logger)
-          }
-        }
-      } finally {
-        session.drainScheduled = false
-      }
-    })
-  }
-
-  /**
-   * 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
-   */
-  async #rebuildMaps (siteData) {
-    const layoutDepMap = /** @type {Map>} */ (new Map())
-    const layoutPageMap = /** @type {Map>} */ (new Map())
-    const pageFileMap = /** @type {Map} */ (new Map())
-    const layoutFileMap = /** @type {Map} */ (new Map())
-    const pageDepMap = /** @type {Map>} */ (new Map())
-    const templateDepMap = /** @type {Map>} */ (new Map())
-    const pagesFileDepMap = /** @type {Map>} */ (new Map())
-    let dependencyAnalysisFailed = false
-    /** @param {...(string | undefined)} filepaths */
-    const rootDependencies = async (...filepaths) => {
-      const paths = new Set(/** @type {string[]} */ ([]))
-      for (const filepath of filepaths) {
-        if (!filepath) continue
-        paths.add(resolve(filepath))
-        try {
-          for (const dep of await find(filepath)) paths.add(resolve(dep))
-        } catch {
-          dependencyAnalysisFailed = true
-        }
-      }
-      return paths
-    }
-    const globalDataDepPaths = await rootDependencies(siteData.globalData?.filepath)
-    const settingsDepPaths = await rootDependencies(
-      siteData.globalVars?.filepath,
-      siteData.markdownItSettings?.filepath,
-      siteData.esbuildSettings?.filepath
-    )
-
-    // layoutFileMap: layout filepath → layoutName
-    for (const layout of Object.values(siteData.layouts)) {
-      layoutFileMap.set(layout.filepath, layout.layoutName)
-    }
-
-    // layoutDepMap: dep filepath → Set
-    for (const layout of Object.values(siteData.layouts)) {
-      try {
-        const deps = await find(layout.filepath)
-        for (const dep of deps) {
-          const absPath = resolve(dep)
-          if (!layoutDepMap.has(absPath)) layoutDepMap.set(absPath, new Set())
-          layoutDepMap.get(absPath)?.add(layout.layoutName)
-        }
-      } catch {
-        dependencyAnalysisFailed = true
-      }
-    }
-
-    // Use the worker's actual selection, including frontmatter and all ancestors.
-    for (const pageInfo of siteData.pages) {
-      for (const layoutName of this.#pageLayoutNamesMap.get(pageInfo.pageFile.filepath) ?? []) {
-        if (!layoutPageMap.has(layoutName)) layoutPageMap.set(layoutName, new Set())
-        layoutPageMap.get(layoutName)?.add(pageInfo)
-      }
-    }
-
-    // pageFileMap: page filepath & page.vars filepath → PageInfo
-    for (const pageInfo of siteData.pages) {
-      pageFileMap.set(pageInfo.pageFile.filepath, pageInfo)
-      if (pageInfo.pageVars) pageFileMap.set(pageInfo.pageVars.filepath, pageInfo)
-    }
-
-    // pageDepMap: dep filepath → Set
-    for (const pageInfo of siteData.pages) {
-      const filesToTrack = /\.[cm]?[jt]sx?$/.test(pageInfo.pageFile.filepath) ? [pageInfo.pageFile.filepath] : []
-      if (pageInfo.pageVars) filesToTrack.push(pageInfo.pageVars.filepath)
-      for (const file of filesToTrack) {
-        try {
-          const deps = await find(file)
-          for (const dep of deps) {
-            const absPath = resolve(dep)
-            if (!pageDepMap.has(absPath)) pageDepMap.set(absPath, new Set())
-            pageDepMap.get(absPath)?.add(pageInfo)
-          }
-        } catch {
-          dependencyAnalysisFailed = true
-        }
-      }
-    }
-
-    // templateDepMap: dep filepath → Set
-    for (const templateInfo of siteData.templates) {
-      try {
-        const deps = await find(templateInfo.templateFile.filepath)
-        for (const dep of deps) {
-          const absPath = resolve(dep)
-          if (!templateDepMap.has(absPath)) templateDepMap.set(absPath, new Set())
-          templateDepMap.get(absPath)?.add(templateInfo)
-        }
-      } catch {
-        dependencyAnalysisFailed = true
-      }
-    }
-
-    // pagesFileDepMap: dep filepath → Set
-    for (const pagesFileInfo of siteData.pagesFiles ?? []) {
-      try {
-        const deps = await find(pagesFileInfo.pagesFile.filepath)
-        for (const dep of deps) {
-          const absPath = resolve(dep)
-          if (!pagesFileDepMap.has(absPath)) pagesFileDepMap.set(absPath, new Set())
-          pagesFileDepMap.get(absPath)?.add(pagesFileInfo)
-        }
-      } catch (err) {
-        dependencyAnalysisFailed = true
-        const message = err instanceof Error ? err.message : String(err)
-        this.#logger.debug(`Could not analyze dependencies for pages file "${pagesFileInfo.pagesFile.relname}": ${message}`)
-      }
-    }
-
-    // esbuildEntryPoints: absolute filepaths of all esbuild entry points
-    const esbuildEntryPoints = /** @type {Set} */ (new Set())
-    for (const asset of globalBundleAssets(siteData)) esbuildEntryPoints.add(resolve(asset.filepath))
-    if (siteData.serviceWorker) esbuildEntryPoints.add(resolve(siteData.serviceWorker.filepath))
-    for (const page of siteData.pages) {
-      for (const asset of pageBundleAssets(page)) esbuildEntryPoints.add(resolve(asset.filepath))
-    }
-    for (const layout of Object.values(siteData.layouts)) {
-      for (const asset of layoutBundleAssets(layout)) esbuildEntryPoints.add(resolve(asset.filepath))
-    }
-
-    const esbuildDepPaths = new Set(/** @type {string[]} */ ([]))
-    for (const filepath of esbuildEntryPoints) {
-      if (!/\.[cm]?[jt]sx?$/.test(filepath)) continue
-      try {
-        for (const dep of await find(filepath)) esbuildDepPaths.add(resolve(dep))
-      } catch {
-        // Unknown browser helpers still take the conservative reset path.
-      }
-    }
-
-    this.#layoutDepMap = layoutDepMap
-    this.#layoutPageMap = layoutPageMap
-    this.#pageFileMap = pageFileMap
-    this.#layoutFileMap = layoutFileMap
-    this.#pageDepMap = pageDepMap
-    this.#templateDepMap = templateDepMap
-    this.#pagesFileDepMap = pagesFileDepMap
-    this.#globalDataDepPaths = globalDataDepPaths
-    this.#settingsDepPaths = settingsDepPaths
-    this.#dependencyAnalysisFailed = dependencyAnalysisFailed
-    this.#esbuildEntryPoints = esbuildEntryPoints
-    this.#esbuildDepPaths = esbuildDepPaths
+  async watch (params = { serve: true }) {
+    return this.#watcher.watch(params)
   }
 
   /**
    * Cancel startup/event waits, drain owned work, and release the session.
    * The user callback is not drained: it may itself be awaiting this stop.
    * Concurrent stops share cleanup; a new watch may start once cleanup settles.
+   * @returns {Promise}
    */
   async stopWatching () {
-    if (!this.#watchSession) throw new Error('Not watching')
-    return this.#stopWatchSession(this.#watchSession)
-  }
-
-  /** @param {WatchSession} session */
-  #stopWatchSession (session) {
-    // Retain this promise on the session even after cleanup. An old callback may
-    // finish or throw after a new session starts; it must not clean up that session.
-    if (session.shutdown) return session.shutdown
-    session.state = 'stopping'
-    session.cancellation.abort()
-    session.shutdown = this.#disposeWatchResources(session)
-    return session.shutdown
-  }
-
-  /** @param {WatchSession} session */
-  async #disposeWatchResources (session) {
-    // 1. Drain resource acquisition. No new startup phase may begin after a stop.
-    //    watch() reports startup errors; shutdown still releases partial resources.
-    await session.startupWork.catch(() => {})
-
-    // 2. Stop filesystem producers. The session state already rejects new and
-    //    queued rebuilds, including callbacks retained by a previous watch session.
-    const closures = [
-      () => this.#watcher?.close(),
-      ...this.#cpxWatchers.map(w => () => w.close()),
-    ]
-    const results = await Promise.allSettled(closures.map(close => Promise.resolve().then(close)))
-
-    // 3. Drain the active rebuild before releasing the esbuild context it may replace.
-    results.push(...await Promise.allSettled([this.#buildLock]))
-
-    // 4. Release the final contexts and server, even if another cleanup step failed.
-    results.push(...await Promise.allSettled([
-      Promise.resolve().then(() => this.#esbuildContext?.dispose()),
-      Promise.resolve().then(() => this.#syncServer?.exit()),
-    ]))
-    session.pendingEvents = []
-    session.globalDataBaseline = null
-    this.#watchDependencies = null
-    this.#pageBuildFailed = false
-    this.#watcher = null
-    this.#cpxWatchers = []
-    this.#esbuildContext = null
-    this.#syncServer = null
-    this.#siteData = null
-    this.#buildLock = Promise.resolve()
-    this.#watchSession = null
-    const errors = results.filter(result => result.status === 'rejected').map(result => result.reason)
-    if (errors.length > 0) throw new AggregateError(errors, 'Watch cleanup failed')
+    return this.#watcher.stopWatching()
   }
 
   /**
@@ -929,83 +119,7 @@ export class DomStack {
    * @returns {Promise}
    */
   async settled () {
-    await this.#buildLock
-  }
-}
-
-/**
- * @param {string} dest
- * @param {WatchedPageReport[]} pageReports
- * @returns {Map>}
- */
-function getPageOutputMap (dest, pageReports) {
-  /** @type {Map>} */
-  const outputsByOwner = new Map()
-  for (const report of pageReports) {
-    const owner = report.pagesFilePath ?? report.sourcePageFilePath
-    if (!owner) continue
-    const outputs = outputsByOwner.get(owner) ?? new Set()
-    for (const output of report.outputs ?? []) outputs.add(resolve(dest, output.outputRelname))
-    outputsByOwner.set(owner, outputs)
-  }
-  return outputsByOwner
-}
-
-/**
- * Never follow a replaced output directory outside the destination. A symlink
- * at the output itself is safe to unlink; directories are never removed.
- * @param {string} dest
- * @param {string} filepath
- */
-async function removeStalePageOutput (dest, filepath) {
-  assertInsideDest(dest, filepath)
-  if (filepath === dest) throw new Error('Refusing to remove the build destination')
-  try {
-    for (let ancestor = dirname(filepath); ; ancestor = dirname(ancestor)) {
-      const stats = await lstat(ancestor)
-      if (stats.isSymbolicLink() || !stats.isDirectory()) return
-      if (ancestor === dest) break
-    }
-    const stats = await lstat(filepath)
-    if (!stats.isDirectory()) await rm(filepath, { force: true })
-  } catch (err) {
-    if (/** @type {NodeJS.ErrnoException} */ (err).code !== 'ENOENT') throw err
-  }
-}
-
-/**
- * Group layouts used by generated pages by their owning *.pages.* filepath.
- *
- * @param {WatchedPageReport[]} pageReports
- * @returns {Map>}
- */
-function getPagesFileLayoutMap (pageReports) {
-  /** @type {Map>} */
-  const layoutsByOwner = new Map()
-
-  for (const report of pageReports) {
-    if (!report.pagesFilePath) continue
-    const layouts = layoutsByOwner.get(report.pagesFilePath) ?? new Set()
-    for (const name of report.layoutNames) layouts.add(name)
-    layoutsByOwner.set(report.pagesFilePath, layouts)
-  }
-
-  return layoutsByOwner
-}
-
-/**
- * Replace layout membership for generated-page owners included in a targeted build.
- *
- * @param {Map>} layoutMap
- * @param {string[]} rebuiltOwnerPaths
- * @param {WatchedPageReport[]} pageReports
- */
-function updatePagesFileLayoutMap (layoutMap, rebuiltOwnerPaths, pageReports) {
-  const rebuiltLayouts = getPagesFileLayoutMap(pageReports)
-  for (const ownerPath of rebuiltOwnerPaths) {
-    const layouts = rebuiltLayouts.get(ownerPath)
-    if (layouts?.size) layoutMap.set(ownerPath, layouts)
-    else layoutMap.delete(ownerPath)
+    await this.#watcher.settled()
   }
 }
 
@@ -1029,16 +143,6 @@ export async function testBuild (src, opts = {}) {
   }
 }
 
-/**
- * relanem is the bsaename if (root === name), otherwise relative(root, name)
- * @param  {string} root The root path string
- * @param  {string} name The name string
- * @return {string}      the relname
- */
-function relname (root, name) {
-  return root === name ? basename(name) : relative(root, name)
-}
-
 /**
  * @param {DomStackOpts} opts
  * @param {string} dest
@@ -1060,96 +164,3 @@ function normalizeDomStackOpts (opts, dest) {
     ],
   }
 }
-
-/**
- * Log a rebuild tree showing what triggered a rebuild and what will be rebuilt.
- * @param {string} trigger - The changed file (display name)
- * @param {PinoLogger} logger
- * @param {Set} [pages]
- * @param {Set} [templates]
- */
-function logRebuildTree (trigger, logger, pages, templates) {
-  const lines = [`"${trigger}" changed:`]
-  for (const p of pages ?? []) {
-    lines.push(`  → ${p.outputRelname}`)
-  }
-  for (const t of templates ?? []) {
-    lines.push(`  → ${t.outputName} (template)`)
-  }
-  logger.info(lines.join('\n'))
-}
-
-/**
- * An error logger
- * @param  {Error | AggregateError | any } err The error to log
- * @param {PinoLogger} logger
- */
-function errorLogger (err, logger) {
-  if (!(err instanceof Error || err instanceof AggregateError)) throw new Error('Non-error thrown', { cause: err })
-  if ('results' in err) delete err.results
-  logger.error(inspect(err, { depth: 999, colors: true }))
-  logger.error('Build Failed!')
-}
-
-/**
- * Log build results.
- * @param  {Partial | WorkerBuildStepResult} results
- * @param {PinoLogger} logger
- * @param  {string} [dest] - dest path for relativizing output paths in filtered builds
- */
-function buildLogger (results, logger, dest) {
-  if ((results?.warnings?.length ?? 0) > 0) {
-    logger.warn('There were build warnings:')
-  }
-  for (const warning of results?.warnings ?? []) {
-    if ('message' in warning) {
-      logger.warn(`  ${warning.message}`)
-    } else {
-      logger.warn(inspect(warning, { depth: 999, colors: true }))
-    }
-  }
-
-  if ('siteData' in results && results.siteData) {
-    // Full build: show site totals
-    const layoutCount = Object.keys(results.siteData.layouts).length
-    logger.info(`Source pages: ${results.siteData.pages.length} Layouts: ${layoutCount} Templates: ${results.siteData.templates.length}`)
-    const outputs = results.pageBuildResults?.outputs
-    if (outputs) {
-      const summary = summarizePageDomstackManifests(outputs)
-      logger.info(`Pages built: ${summary.pages} Templates built: ${summary.templates}`)
-    }
-  } else if ('outputs' in results) {
-    // Filtered build: show what was actually built
-    const outputs = results.outputs
-    if (dest) {
-      for (const output of outputs) {
-        if (output.kind === 'page' || output.kind === 'template') {
-          logger.info(`  Built ${relative(dest, output.filepath)}`)
-        }
-      }
-    }
-    const summary = summarizePageDomstackManifests(outputs)
-    logger.info(`Pages built: ${summary.pages} Templates built: ${summary.templates}`)
-  }
-  logger.info('Build Success!')
-}
-
-/**
- * @param {DomstackManifestRecord[]} outputs
- */
-function summarizePageDomstackManifests (outputs) {
-  const templateSources = new Set()
-  let pages = 0
-
-  for (const output of outputs) {
-    if (output.kind === 'page') pages += 1
-    if (output.kind === 'template') {
-      templateSources.add(output.sourceRelname ?? output.templatePath ?? output.outputRelname)
-    }
-  }
-
-  return {
-    pages,
-    templates: templateSources.size,
-  }
-}
diff --git a/lib/build-pages/global-data-state.js b/lib/build-pages/global-data-state.js
index 0364daea..f4e6a952 100644
--- a/lib/build-pages/global-data-state.js
+++ b/lib/build-pages/global-data-state.js
@@ -1,7 +1,7 @@
 /**
  * @import { PageData } from './page-data.js'
  * @import { GlobalDataFunctionParams } from './index.js'
- * @import { WatchEvent } from '../watch-plan.js'
+ * @import { WatchEvent } from '../watch/plan.js'
  */
 import { resolve } from 'node:path'
 import { BlockList } from 'node:net'
diff --git a/lib/build-pages/global-data-state.test.js b/lib/build-pages/global-data-state.test.js
index 9c12cce7..ef59a7b9 100644
--- a/lib/build-pages/global-data-state.test.js
+++ b/lib/build-pages/global-data-state.test.js
@@ -11,7 +11,7 @@ import { createHistogram, monitorEventLoopDelay } from 'node:perf_hooks'
 import { createGlobalDataState } from './global-data-state.js'
 import { buildPages, buildPagesDirect } from './index.js'
 import { identifyPages } from '../identify-pages.js'
-import { classifyWatchEvent } from '../watch-plan.js'
+import { classifyWatchEvent } from '../watch/plan.js'
 import { resolveGlobalData } from './resolve-vars.js'
 
 /** @param {TestContext} t @param {string} producer */
diff --git a/lib/watch/dependency-index.js b/lib/watch/dependency-index.js
new file mode 100644
index 00000000..a441fab6
--- /dev/null
+++ b/lib/watch/dependency-index.js
@@ -0,0 +1,284 @@
+/**
+ * @import { SiteData } from '../builder.js'
+ * @import { PageBuilderReport, PageReport } from '../build-pages/index.js'
+ * @import { PageInfo, TemplateInfo, PagesFileInfo } from '../identify-pages.js'
+ * @import { Logger as PinoLogger } from 'pino'
+ * @import { WatchSnapshot, WatchEvent } from './plan.js'
+ */
+import { resolve } from 'node:path'
+import { find } from '@11ty/dependency-tree-typescript'
+import { isProcessedFile, globalBundleAssets, pageBundleAssets, layoutBundleAssets } from '../file-conventions.js'
+
+/** File dependencies and successful layout selections used for watch routing. */
+export class WatchDependencyIndex {
+  /** @type {PinoLogger} */ #logger
+  /** @type {Map>} depFilepath → Set */
+  #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 */
+  #layoutFileMap = new Map()
+  /** @type {Map>} depFilepath → Set */
+  #pageDepMap = new Map()
+  /** @type {Map>} depFilepath → Set */
+  #templateDepMap = new Map()
+  /** @type {Map>} depFilepath → Set */
+  #pagesFileDepMap = new Map()
+  /** @type {Map>} *.pages.* filepath → layouts used by its generated pages */
+  #pagesFileLayoutMap = new Map()
+  /** @type {Set} Imported inputs of global.data, including its entry file. */
+  #globalDataDepPaths = new Set()
+  /** @type {Set} Settings roots and imports always require a full rebuild. */
+  #settingsDepPaths = new Set()
+  #dependencyAnalysisFailed = false
+  /** @type {Set} Absolute filepaths of esbuild entry points. */
+  #esbuildEntryPoints = new Set()
+  /** @type {Set} Known browser-only helpers can skip the page phase. */
+  #esbuildDepPaths = new Set()
+
+  /** @param {PinoLogger} logger */
+  constructor (logger) {
+    this.#logger = logger
+  }
+
+  /**
+   * Routing references are readonly to callers, not deep copies.
+   * @returns {Omit}
+   */
+  snapshot () {
+    return {
+      layoutDepMap: this.#layoutDepMap,
+      layoutPageMap: this.#layoutPageMap,
+      pageFileMap: this.#pageFileMap,
+      layoutFileMap: this.#layoutFileMap,
+      pageDepMap: this.#pageDepMap,
+      templateDepMap: this.#templateDepMap,
+      pagesFileDepMap: this.#pagesFileDepMap,
+      pagesFileLayoutMap: this.#pagesFileLayoutMap,
+      globalDataDepPaths: this.#globalDataDepPaths,
+      settingsDepPaths: this.#settingsDepPaths,
+      dependencyAnalysisFailed: this.#dependencyAnalysisFailed,
+      esbuildEntryPoints: this.#esbuildEntryPoints,
+      esbuildDepPaths: this.#esbuildDepPaths,
+    }
+  }
+
+  /**
+   * @param {WatchEvent[]} events
+   * @param {{ pageBuildFailed: boolean }} options
+   */
+  filterEvents (events, { pageBuildFailed }) {
+    // Unknown inputs may be needed to recover after a failed build or analysis.
+    if (pageBuildFailed || this.#dependencyAnalysisFailed) return events
+
+    const dependencies = [
+      this.#globalDataDepPaths,
+      this.#settingsDepPaths,
+      this.#layoutDepMap,
+      this.#pageDepMap,
+      this.#templateDepMap,
+      this.#pagesFileDepMap,
+      this.#esbuildDepPaths,
+    ]
+    return events.filter(({ filepath }) =>
+      isProcessedFile(filepath) || dependencies.some(paths => paths.has(filepath))
+    )
+  }
+
+  /**
+   * Record layout selections only after a successful build. Rebuild routing maps
+   * separately, using the latest discovery data.
+   * @param {Pick} report
+   * @param {{ filtered: boolean }} options
+   */
+  recordLayouts (report, { filtered }) {
+    if (!filtered) this.#pageLayoutNamesMap.clear()
+    for (const page of report.pages) {
+      if (page.sourcePageFilePath) this.#pageLayoutNamesMap.set(page.sourcePageFilePath, page.layoutNames)
+    }
+    if (!filtered) {
+      this.#pagesFileLayoutMap = getPagesFileLayoutMap(report.pages)
+    } else {
+      updatePagesFileLayoutMap(this.#pagesFileLayoutMap, report.rebuiltPagesFilePaths ?? [], report.pages)
+    }
+  }
+
+  /**
+   * Reconstruct routing from discovery and the last successful layout selections.
+   * `find()` returns CWD-relative paths; resolve them to absolute map keys.
+   * @param {SiteData} siteData
+   */
+  async rebuild (siteData) {
+    const layoutDepMap = /** @type {Map>} */ (new Map())
+    const layoutPageMap = /** @type {Map>} */ (new Map())
+    const pageFileMap = /** @type {Map} */ (new Map())
+    const layoutFileMap = /** @type {Map} */ (new Map())
+    const pageDepMap = /** @type {Map>} */ (new Map())
+    const templateDepMap = /** @type {Map>} */ (new Map())
+    const pagesFileDepMap = /** @type {Map>} */ (new Map())
+    const esbuildEntryPoints = /** @type {Set} */ (new Set())
+    for (const asset of globalBundleAssets(siteData)) esbuildEntryPoints.add(resolve(asset.filepath))
+    if (siteData.serviceWorker) esbuildEntryPoints.add(resolve(siteData.serviceWorker.filepath))
+    let dependencyAnalysisFailed = false
+    /** @type {Map>} */
+    const dependencyCache = new Map()
+    /** @param {string} filepath */
+    const dependenciesFor = filepath => {
+      const key = resolve(filepath)
+      let dependencies = dependencyCache.get(key)
+      if (!dependencies) {
+        // Keep failures too, but let each role apply its own recovery and logging policy.
+        dependencies = find(filepath).then(deps => deps.map(dep => resolve(dep)))
+        dependencyCache.set(key, dependencies)
+      }
+      return dependencies
+    }
+    /** @param {...(string | undefined)} filepaths */
+    const rootDependencies = async (...filepaths) => {
+      const paths = new Set(/** @type {string[]} */ ([]))
+      for (const filepath of filepaths) {
+        if (!filepath) continue
+        paths.add(resolve(filepath))
+        try {
+          for (const dep of await dependenciesFor(filepath)) paths.add(dep)
+        } catch {
+          dependencyAnalysisFailed = true
+        }
+      }
+      return paths
+    }
+    const globalDataDepPaths = await rootDependencies(siteData.globalData?.filepath)
+    const settingsDepPaths = await rootDependencies(
+      siteData.globalVars?.filepath,
+      siteData.markdownItSettings?.filepath,
+      siteData.esbuildSettings?.filepath
+    )
+
+    const layouts = Object.values(siteData.layouts)
+    // Index direct layout files and their imported dependencies together.
+    for (const layout of layouts) {
+      layoutFileMap.set(layout.filepath, layout.layoutName)
+      try {
+        for (const absPath of await dependenciesFor(layout.filepath)) {
+          if (!layoutDepMap.has(absPath)) layoutDepMap.set(absPath, new Set())
+          layoutDepMap.get(absPath)?.add(layout.layoutName)
+        }
+      } catch {
+        dependencyAnalysisFailed = true
+      }
+    }
+
+    // Use the worker's actual selection, including frontmatter and all ancestors.
+    for (const pageInfo of siteData.pages) {
+      for (const layoutName of this.#pageLayoutNamesMap.get(pageInfo.pageFile.filepath) ?? []) {
+        if (!layoutPageMap.has(layoutName)) layoutPageMap.set(layoutName, new Set())
+        layoutPageMap.get(layoutName)?.add(pageInfo)
+      }
+      pageFileMap.set(pageInfo.pageFile.filepath, pageInfo)
+      if (pageInfo.pageVars) pageFileMap.set(pageInfo.pageVars.filepath, pageInfo)
+      for (const asset of pageBundleAssets(pageInfo)) esbuildEntryPoints.add(resolve(asset.filepath))
+
+      const filesToTrack = /\.[cm]?[jt]sx?$/.test(pageInfo.pageFile.filepath) ? [pageInfo.pageFile.filepath] : []
+      if (pageInfo.pageVars) filesToTrack.push(pageInfo.pageVars.filepath)
+      for (const file of filesToTrack) {
+        try {
+          for (const absPath of await dependenciesFor(file)) {
+            if (!pageDepMap.has(absPath)) pageDepMap.set(absPath, new Set())
+            pageDepMap.get(absPath)?.add(pageInfo)
+          }
+        } catch {
+          dependencyAnalysisFailed = true
+        }
+      }
+    }
+
+    // templateDepMap: dep filepath → Set
+    for (const templateInfo of siteData.templates) {
+      try {
+        for (const absPath of await dependenciesFor(templateInfo.templateFile.filepath)) {
+          if (!templateDepMap.has(absPath)) templateDepMap.set(absPath, new Set())
+          templateDepMap.get(absPath)?.add(templateInfo)
+        }
+      } catch {
+        dependencyAnalysisFailed = true
+      }
+    }
+
+    // pagesFileDepMap: dep filepath → Set
+    for (const pagesFileInfo of siteData.pagesFiles ?? []) {
+      try {
+        for (const absPath of await dependenciesFor(pagesFileInfo.pagesFile.filepath)) {
+          if (!pagesFileDepMap.has(absPath)) pagesFileDepMap.set(absPath, new Set())
+          pagesFileDepMap.get(absPath)?.add(pagesFileInfo)
+        }
+      } catch (err) {
+        dependencyAnalysisFailed = true
+        const message = err instanceof Error ? err.message : String(err)
+        this.#logger.debug(`Could not analyze dependencies for pages file "${pagesFileInfo.pagesFile.relname}": ${message}`)
+      }
+    }
+
+    for (const layout of layouts) {
+      for (const asset of layoutBundleAssets(layout)) esbuildEntryPoints.add(resolve(asset.filepath))
+    }
+
+    const esbuildDepPaths = new Set(/** @type {string[]} */ ([]))
+    for (const filepath of esbuildEntryPoints) {
+      if (!/\.[cm]?[jt]sx?$/.test(filepath)) continue
+      try {
+        for (const dep of await dependenciesFor(filepath)) esbuildDepPaths.add(dep)
+      } catch {
+        // Unknown browser helpers still take the conservative reset path.
+      }
+    }
+
+    this.#layoutDepMap = layoutDepMap
+    this.#layoutPageMap = layoutPageMap
+    this.#pageFileMap = pageFileMap
+    this.#layoutFileMap = layoutFileMap
+    this.#pageDepMap = pageDepMap
+    this.#templateDepMap = templateDepMap
+    this.#pagesFileDepMap = pagesFileDepMap
+    this.#globalDataDepPaths = globalDataDepPaths
+    this.#settingsDepPaths = settingsDepPaths
+    this.#dependencyAnalysisFailed = dependencyAnalysisFailed
+    this.#esbuildEntryPoints = esbuildEntryPoints
+    this.#esbuildDepPaths = esbuildDepPaths
+  }
+}
+
+/**
+ * Group layouts used by generated pages by their owning *.pages.* filepath.
+ * @param {PageReport[]} pageReports
+ * @returns {Map>}
+ */
+function getPagesFileLayoutMap (pageReports) {
+  /** @type {Map>} */
+  const layoutsByOwner = new Map()
+  for (const report of pageReports) {
+    if (!report.pagesFilePath) continue
+    const layouts = layoutsByOwner.get(report.pagesFilePath) ?? new Set()
+    for (const name of report.layoutNames) layouts.add(name)
+    layoutsByOwner.set(report.pagesFilePath, layouts)
+  }
+  return layoutsByOwner
+}
+
+/**
+ * Replace layout membership for generated-page owners included in a targeted build.
+ * @param {Map>} layoutMap
+ * @param {string[]} rebuiltOwnerPaths
+ * @param {PageReport[]} pageReports
+ */
+function updatePagesFileLayoutMap (layoutMap, rebuiltOwnerPaths, pageReports) {
+  const rebuiltLayouts = getPagesFileLayoutMap(pageReports)
+  for (const ownerPath of rebuiltOwnerPaths) {
+    const layouts = rebuiltLayouts.get(ownerPath)
+    if (layouts?.size) layoutMap.set(ownerPath, layouts)
+    else layoutMap.delete(ownerPath)
+  }
+}
diff --git a/lib/watch/dependency-index.test.js b/lib/watch/dependency-index.test.js
new file mode 100644
index 00000000..16216c0d
--- /dev/null
+++ b/lib/watch/dependency-index.test.js
@@ -0,0 +1,416 @@
+/**
+ * @import { TestContext } from 'node:test'
+ * @import { SiteData } from '../builder.js'
+ * @import { PageReport } from '../build-pages/index.js'
+ * @import { WalkerFile, PageInfo, PageTypes } from '../identify-pages.js'
+ */
+import { test } from 'node:test'
+import assert from 'node:assert/strict'
+import { mkdtemp, mkdir, writeFile, rm } from 'node:fs/promises'
+import { tmpdir } from 'node:os'
+import { basename, dirname, isAbsolute, join, relative } from 'node:path'
+import pino from 'pino'
+import { classifyWatchEvent } from './plan.js'
+import { WatchDependencyIndex } from './dependency-index.js'
+
+/** @returns {SiteData} */
+function emptySite () {
+  return {
+    pages: [],
+    templates: [],
+    pagesFiles: [],
+    layouts: {},
+    globalStyle: undefined,
+    globalClient: undefined,
+    serviceWorker: undefined,
+    globalVars: undefined,
+    globalData: undefined,
+    esbuildSettings: undefined,
+    markdownItSettings: undefined,
+    domstackManifestSettings: undefined,
+    defaultStyle: null,
+    defaultClient: null,
+    defaultLayout: false,
+    warnings: [],
+    errors: [],
+  }
+}
+
+/** @param {TestContext} t */
+async function fixture (t) {
+  const src = await mkdtemp(join(tmpdir(), 'domstack-watch-index-'))
+  t.after(() => rm(src, { recursive: true, force: true }))
+  const logger = pino({ level: 'silent' })
+  const debug = t.mock.method(logger, 'debug')
+  const index = new WatchDependencyIndex(logger)
+  /** @param {string} relname @returns {WalkerFile} */
+  const file = relname => ({ root: src, filepath: join(src, relname), relname, basename: basename(relname), parentName: dirname(relname) })
+  /** @param {string} relname @param {string} [contents] */
+  const write = async (relname, contents = 'export default {}') => {
+    const info = file(relname)
+    await mkdir(dirname(info.filepath), { recursive: true })
+    await writeFile(info.filepath, contents)
+    return info
+  }
+  /** @param {string} path @param {PageTypes} [type] @returns {PageInfo} */
+  const page = (path, type = 'md') => ({
+    pageFile: file(join(path, `page.${type}`)),
+    type,
+    path,
+    url: `/${path}/`,
+    outputName: 'index.html',
+    outputRelname: join(path, 'index.html'),
+    draft: false,
+  })
+  return { src, index, debug, file, write, page, site: emptySite() }
+}
+
+/** @param {PageInfo} page @param {string[]} layoutNames @returns {PageReport} */
+function sourceReport (page, layoutNames) {
+  return { pageFilePath: page.pageFile.filepath, sourcePageFilePath: page.pageFile.filepath, layoutNames, outputs: [] }
+}
+
+/** @param {string} owner @param {string[]} layoutNames @returns {PageReport} */
+function generatedReport (owner, layoutNames) {
+  return { pageFilePath: `${owner}/generated.html`, pagesFilePath: owner, layoutNames, outputs: [] }
+}
+
+test('snapshot exposes only routing state and shares readonly-typed references', () => {
+  const index = new WatchDependencyIndex(pino({ level: 'silent' }))
+  const snapshot = index.snapshot()
+  const { dependencyAnalysisFailed, ...maps } = snapshot
+  assert.equal(dependencyAnalysisFailed, false)
+  assert.deepEqual(Object.keys(maps).sort(), [
+    'layoutDepMap', 'layoutPageMap', 'pageFileMap', 'layoutFileMap',
+    'pageDepMap', 'templateDepMap', 'pagesFileDepMap', 'pagesFileLayoutMap',
+    'globalDataDepPaths', 'settingsDepPaths', 'esbuildEntryPoints', 'esbuildDepPaths',
+  ].sort())
+  for (const [key, value] of Object.entries(maps)) {
+    assert.equal(value.size, 0)
+    assert.equal(Reflect.get(index.snapshot(), key), value)
+  }
+})
+
+test('full and targeted reports route actual source layout chains, including ancestors and no-layout pages', async t => {
+  const { index, site, page, write } = await fixture(t)
+  const home = page('home')
+  const other = page('other')
+  const bare = page('bare')
+  site.pages = [home, other, bare]
+  home.pageVars = await write('home/page.vars.js', 'export default { layout: "unused" }')
+  for (const name of ['root', 'article', 'alternate', 'unused']) {
+    site.layouts[name] = { ...await write(`${name}.layout.js`), layoutName: name }
+  }
+  await index.rebuild(site)
+  assert.equal(index.snapshot().layoutPageMap.size, 0, 'discovery alone must not infer root or vars layouts')
+
+  index.recordLayouts({ pages: [sourceReport(home, ['root', 'article']), sourceReport(other, ['root']), sourceReport(bare, [])] }, { filtered: false })
+  await index.rebuild(site)
+  let snapshot = index.snapshot()
+  assert.deepEqual(snapshot.layoutPageMap, new Map([['root', new Set([home, other])], ['article', new Set([home])]]))
+  assert.equal(snapshot.pageFileMap.get(home.pageVars.filepath), home)
+  for (const source of site.pages) assert.equal(snapshot.pageFileMap.get(source.pageFile.filepath), source)
+  assert.deepEqual(snapshot.layoutFileMap, new Map(Object.values(site.layouts).map(layout => [layout.filepath, layout.layoutName])))
+
+  index.recordLayouts({ pages: [sourceReport(home, ['alternate'])] }, { filtered: true })
+  await index.rebuild(site)
+  snapshot = index.snapshot()
+  assert.deepEqual(snapshot.layoutPageMap, new Map([['alternate', new Set([home])], ['root', new Set([other])]]))
+
+  const rediscoveredHome = { ...home }
+  site.pages = [rediscoveredHome, other, bare]
+  await index.rebuild(site)
+  assert.equal(index.snapshot().layoutPageMap.get('alternate')?.has(rediscoveredHome), true)
+  assert.equal(index.snapshot().layoutPageMap.get('alternate')?.has(home), false, 'routing uses current discovery objects')
+
+  index.recordLayouts({ pages: [sourceReport(home, ['article'])] }, { filtered: false })
+  await index.rebuild(site)
+  assert.deepEqual(index.snapshot().layoutPageMap, new Map([['article', new Set([rediscoveredHome])]]), 'full reports discard omitted source chains')
+  site.pages = []
+  await index.rebuild(site)
+  assert.equal(index.snapshot().layoutPageMap.size, 0)
+  assert.equal(index.snapshot().pageFileMap.size, 0)
+})
+
+test('generated owners union full reports and replace only rebuilt owners, including zero generated pages', async t => {
+  const { index, file } = await fixture(t)
+  const first = file('first.pages.js').filepath
+  const second = file('second.pages.js').filepath
+  const bare = file('bare.pages.js').filepath
+  index.recordLayouts({
+    pages: [
+      generatedReport(first, ['root', 'article']), generatedReport(first, ['root', 'alternate']),
+      generatedReport(second, ['root']), generatedReport(bare, []),
+    ]
+  }, { filtered: false })
+  assert.deepEqual(index.snapshot().pagesFileLayoutMap, new Map([
+    [first, new Set(['root', 'article', 'alternate'])], [second, new Set(['root'])], [bare, new Set()],
+  ]))
+
+  index.recordLayouts({ pages: [generatedReport(first, ['new']), generatedReport(second, ['ignored'])], rebuiltPagesFilePaths: [first] }, { filtered: true })
+  assert.deepEqual(index.snapshot().pagesFileLayoutMap.get(first), new Set(['new']))
+  assert.deepEqual(index.snapshot().pagesFileLayoutMap.get(second), new Set(['root']), 'reports do not replace owners absent from rebuilt paths')
+
+  index.recordLayouts({ pages: [generatedReport(first, ['ignored'])] }, { filtered: true })
+  assert.deepEqual(index.snapshot().pagesFileLayoutMap.get(first), new Set(['new']), 'missing rebuilt paths default to no owner updates')
+  index.recordLayouts({ pages: [], rebuiltPagesFilePaths: [first] }, { filtered: true })
+  assert.equal(index.snapshot().pagesFileLayoutMap.has(first), false, 'zero emitted pages remove previous layout membership')
+  index.recordLayouts({ pages: [generatedReport(bare, [])], rebuiltPagesFilePaths: [bare] }, { filtered: true })
+  assert.equal(index.snapshot().pagesFileLayoutMap.has(bare), false, 'targeted empty layout sets also remove membership')
+  assert.deepEqual(index.snapshot().pagesFileLayoutMap.get(second), new Set(['root']))
+
+  await index.rebuild(emptySite())
+  assert.deepEqual(index.snapshot().pagesFileLayoutMap.get(second), new Set(['root']), 'discovery does not replace successful generated layout reports')
+  index.recordLayouts({ pages: [] }, { filtered: false })
+  assert.equal(index.snapshot().pagesFileLayoutMap.size, 0, 'full reports replace all owners')
+})
+
+test('real shared imports populate every server role, root dependency and browser route with absolute paths', async t => {
+  const { index, site, file, write, page } = await fixture(t)
+  await write('shared.json', '{}')
+  await write('shared.js', 'import "./shared.json"; export default {}')
+  const imports = 'import "./shared.js"; export default {}'
+  const home = page('', 'js')
+  const markdown = page('markdown')
+  const html = page('html', 'html')
+  await write('page.js', imports)
+  await write('markdown/page.md', 'not valid JavaScript {{{')
+  await write('html/page.html', '

not JavaScript

') + home.pageVars = await write('page.vars.js', imports) + markdown.pageVars = home.pageVars + site.pages = [home, markdown, html] + site.layouts['root'] = { ...await write('root.layout.js', imports), layoutName: 'root' } + site.layouts['child'] = { ...await write('child.layout.js', 'import "./root.layout.js"; export default {}'), layoutName: 'child' } + const template = { templateFile: await write('feed.template.js', imports), path: '', outputName: 'feed.xml' } + const owner = { pagesFile: await write('archive.pages.js', imports), path: '', name: 'archive' } + site.templates = [template] + site.pagesFiles = [owner] + site.globalData = await write('global.data.js', imports) + site.globalVars = await write('global.vars.js', imports) + site.markdownItSettings = await write('markdown-it.settings.js', imports) + site.esbuildSettings = await write('esbuild.settings.js', imports) + site.globalClient = await write('global.client.js', imports) + site.globalStyle = await write('global.css', 'body {}') + site.serviceWorker = await write('service-worker.js', imports) + home.clientBundle = await write('client.tsx', imports) + home.pageStyle = await write('style.css', 'body {}') + home.workers = { task: await write('task.worker.js', imports) } + site.layouts['root'].layoutClient = await write('root.layout.client.js', imports) + site.layouts['root'].layoutStyle = await write('root.layout.css', 'body {}') + // Discovery normally supplies absolute paths; bundle/root collectors also resolve relative inputs. + site.globalData = { ...site.globalData, filepath: relative(process.cwd(), site.globalData.filepath) } + site.globalClient = { ...site.globalClient, filepath: relative(process.cwd(), site.globalClient.filepath) } + await index.rebuild(site) + const snapshot = index.snapshot() + assert.equal(snapshot.dependencyAnalysisFailed, false, 'markdown, HTML and CSS are not analyzed as JavaScript') + for (const name of ['shared.js', 'shared.json']) { + const path = file(name).filepath + assert.deepEqual(snapshot.layoutDepMap.get(path), new Set(['root', 'child'])) + assert.deepEqual(snapshot.pageDepMap.get(path), new Set([home, markdown])) + assert.deepEqual(snapshot.templateDepMap.get(path), new Set([template])) + assert.deepEqual(snapshot.pagesFileDepMap.get(path), new Set([owner])) + assert.ok(snapshot.globalDataDepPaths.has(path)) + assert.ok(snapshot.settingsDepPaths?.has(path)) + assert.ok(snapshot.esbuildDepPaths?.has(path)) + } + assert.deepEqual(snapshot.layoutDepMap.get(file('root.layout.js').filepath), new Set(['child'])) + assert.ok(snapshot.globalDataDepPaths.has(file('global.data.js').filepath)) + for (const name of ['global.vars.js', 'markdown-it.settings.js', 'esbuild.settings.js']) { + assert.ok(snapshot.settingsDepPaths?.has(file(name).filepath)) + } + assert.deepEqual(snapshot.esbuildEntryPoints, new Set([ + 'global.client.js', 'global.css', 'service-worker.js', 'client.tsx', 'style.css', + 'task.worker.js', 'root.layout.client.js', 'root.layout.css', + ].map(name => file(name).filepath))) + for (const [key, value] of Object.entries(snapshot)) { + if (typeof value === 'boolean' || key === 'layoutPageMap') continue + for (const path of value.keys()) assert.ok(isAbsolute(path), `${key}: ${path}`) + } + await index.rebuild(emptySite()) + for (const value of Object.values(index.snapshot())) { + if (typeof value !== 'boolean') assert.equal(value.size, 0, 'rebuild discards stale dependency routes') + } +}) + +test('shared entries retain every consumer and refresh dependencies on each rebuild without mutating earlier snapshots', async t => { + const { index, site, file, write, page } = await fixture(t) + const shared = await write('shared.js', 'import "./before.json"; export default {}') + await write('before.json', '{}') + await write('after.json', '{}') + const home = page('home') + const other = page('other') + home.pageVars = shared + other.pageVars = shared + other.clientBundle = shared + const direct = { ...page('', 'js'), pageFile: shared } + site.pages = [home, other, direct] + site.globalData = { ...shared, filepath: relative(process.cwd(), shared.filepath) } + site.globalVars = shared + site.markdownItSettings = shared + site.esbuildSettings = shared + site.layouts['root'] = { ...shared, layoutName: 'root', layoutClient: shared } + site.layouts['child'] = { ...shared, layoutName: 'child' } + const template = { templateFile: shared, path: '', outputName: 'feed.xml' } + const owner = { pagesFile: shared, path: '', name: 'archive' } + site.templates = [template] + site.pagesFiles = [owner] + site.globalClient = shared + site.serviceWorker = shared + index.recordLayouts({ pages: [sourceReport(home, ['root']), sourceReport(other, ['child'])] }, { filtered: false }) + + /** @param {ReturnType} snapshot @param {string} dependency */ + const assertRoutes = (snapshot, dependency) => { + const depPath = file(dependency).filepath + assert.equal(snapshot.dependencyAnalysisFailed, false) + assert.deepEqual(snapshot.layoutDepMap, new Map([[depPath, new Set(['root', 'child'])]])) + assert.deepEqual(snapshot.pageDepMap, new Map([[depPath, new Set([home, other, direct])]])) + assert.deepEqual(snapshot.templateDepMap, new Map([[depPath, new Set([template])]])) + assert.deepEqual(snapshot.pagesFileDepMap, new Map([[depPath, new Set([owner])]])) + assert.deepEqual(snapshot.globalDataDepPaths, new Set([shared.filepath, depPath])) + assert.deepEqual(snapshot.settingsDepPaths, new Set([shared.filepath, depPath])) + assert.deepEqual(snapshot.esbuildDepPaths, new Set([depPath])) + assert.deepEqual(snapshot.esbuildEntryPoints, new Set([shared.filepath])) + } + await index.rebuild(site) + const before = index.snapshot() + assertRoutes(before, 'before.json') + + await write('shared.js', 'import "./after.json"; export default {}') + index.recordLayouts({ pages: [sourceReport(home, ['child'])] }, { filtered: true }) + await index.rebuild(site) + const after = index.snapshot() + assertRoutes(after, 'after.json') + assertRoutes(before, 'before.json') + assert.deepEqual(before.layoutPageMap, new Map([['root', new Set([home])], ['child', new Set([other])]])) + assert.deepEqual(after.layoutPageMap, new Map([['child', new Set([home, other])]])) + for (const key of ['layoutDepMap', 'layoutPageMap', 'pageFileMap', 'layoutFileMap', 'pageDepMap', 'templateDepMap', 'pagesFileDepMap', 'globalDataDepPaths', 'settingsDepPaths', 'esbuildEntryPoints', 'esbuildDepPaths']) { + assert.notEqual(Reflect.get(before, key), Reflect.get(after, key), key) + } + + await write('shared.js') + await index.rebuild(site) + const empty = index.snapshot() + assert.equal(empty.pageDepMap.size, 0, 'successful empty dependency lists replace previous imports') + assert.deepEqual(empty.globalDataDepPaths, new Set([shared.filepath])) + assert.equal(empty.esbuildDepPaths?.size, 0) + assertRoutes(after, 'after.json') +}) + +test('shared analysis failures preserve root recovery and log every factory consumer, then retry on the next rebuild', async t => { + const { index, site, file, write, page, debug } = await fixture(t) + const shared = await write('shared.js', 'export const =') + site.globalData = { ...shared, filepath: relative(process.cwd(), shared.filepath) } + site.globalVars = shared + site.layouts['root'] = { ...shared, layoutName: 'root' } + const home = page('home') + home.pageVars = shared + site.pages = [home] + site.templates = [{ templateFile: shared, path: '', outputName: 'feed.xml' }] + site.pagesFiles = ['first.pages.js', 'second.pages.js'].map(relname => ({ + pagesFile: { ...shared, relname }, path: '', name: relname, + })) + site.globalClient = shared + const events = [classifyWatchEvent('change', file('unknown.json').filepath)] + + for (let attempt = 0; attempt < 2; attempt++) { + await index.rebuild(site) + const snapshot = index.snapshot() + assert.equal(snapshot.dependencyAnalysisFailed, true) + assert.equal(index.filterEvents(events, { pageBuildFailed: false }), events) + assert.deepEqual(snapshot.globalDataDepPaths, new Set([shared.filepath])) + assert.deepEqual(snapshot.settingsDepPaths, new Set([shared.filepath])) + assert.deepEqual(snapshot.esbuildEntryPoints, new Set([shared.filepath])) + for (const map of [snapshot.layoutDepMap, snapshot.pageDepMap, snapshot.templateDepMap, snapshot.pagesFileDepMap, snapshot.esbuildDepPaths]) { + assert.equal(map?.size, 0) + } + assert.equal(debug.mock.callCount(), (attempt + 1) * 2) + assert.match(String(debug.mock.calls[attempt * 2]?.arguments[0]), /^Could not analyze dependencies for pages file "first\.pages\.js": .+/) + assert.match(String(debug.mock.calls[attempt * 2 + 1]?.arguments[0]), /^Could not analyze dependencies for pages file "second\.pages\.js": .+/) + } + + await write('shared.js', 'import "./fixed.json"; export default {}') + await write('fixed.json', '{}') + await index.rebuild(site) + const recovered = index.snapshot() + assert.equal(recovered.dependencyAnalysisFailed, false) + assert.deepEqual(index.filterEvents(events, { pageBuildFailed: false }), []) + assert.deepEqual(recovered.pageDepMap.get(file('fixed.json').filepath), new Set([home])) + assert.deepEqual(recovered.pagesFileDepMap.get(file('fixed.json').filepath), new Set(site.pagesFiles)) + assert.deepEqual(recovered.esbuildDepPaths, new Set([file('fixed.json').filepath])) + assert.equal(debug.mock.callCount(), 4, 'successful analysis does not log failures') +}) + +test('event filtering retains processed files and each known dependency role, preserving order and duplicates', async t => { + const { index, site, write, file, page } = await fixture(t) + /** @param {string} name */ + const root = async name => { + await write(`${name}.json`, '{}') + return write(`${name}.js`, `import "./${name}.json"; export default {}`) + } + site.globalData = await root('global.data') + site.globalVars = await root('global.vars') + site.layouts['root'] = { ...await root('root.layout'), layoutName: 'root' } + const home = page('', 'js') + home.pageFile = await root('page') + site.pages = [home] + site.templates = [{ templateFile: await root('feed.template'), path: '', outputName: 'feed.xml' }] + site.pagesFiles = [{ pagesFile: await root('archive.pages'), path: '', name: 'archive' }] + site.globalClient = await root('global.client') + await index.rebuild(site) + const known = ['global.data', 'global.vars', 'root.layout', 'page', 'feed.template', 'archive.pages', 'global.client'].map(name => `${name}.json`) + const names = ['unknown.txt', ...known, 'unknown.js', 'unknown.md', 'unknown.css', 'unknown.html', 'unknown.tsx', 'unused.json', 'page.json'] + for (const type of /** @type {const} */ (['change', 'added', 'removed'])) { + const events = names.map(name => classifyWatchEvent(type, file(name).filepath)) + const filtered = index.filterEvents(events, { pageBuildFailed: false }) + assert.deepEqual(filtered, events.filter(event => !['unknown.txt', 'unused.json'].includes(event.name))) + assert.equal(filtered[0], events[1]) + assert.equal(events.length, names.length, 'filtering leaves the input array intact') + assert.equal(index.filterEvents(events, { pageBuildFailed: true }), events, 'failed page builds bypass the filter') + assert.deepEqual(index.filterEvents(events, { pageBuildFailed: false }), filtered, 'page failure is supplied per call, not retained') + } +}) + +test('each server analysis failure enables recovery filtering until a successful rebuild and only pages files log failures', async t => { + const { index, site, write, file, page, debug } = await fixture(t) + site.globalData = await write('global.data.js') + site.globalVars = await write('global.vars.js') + site.markdownItSettings = await write('markdown-it.settings.js') + site.esbuildSettings = await write('esbuild.settings.js') + site.layouts['root'] = { ...await write('root.layout.js'), layoutName: 'root' } + const home = page('', 'js') + home.pageFile = await write('page.js') + home.pageVars = await write('page.vars.js') + site.pages = [home] + site.templates = [{ templateFile: await write('feed.template.js'), path: '', outputName: 'feed.xml' }] + site.pagesFiles = [{ pagesFile: await write('archive.pages.js'), path: '', name: 'archive' }] + const events = [classifyWatchEvent('added', file('recovery.json').filepath)] + for (const name of ['global.data.js', 'global.vars.js', 'markdown-it.settings.js', 'esbuild.settings.js', 'root.layout.js', 'page.js', 'page.vars.js', 'feed.template.js', 'archive.pages.js']) { + await write(name, 'export const =') + await index.rebuild(site) + assert.equal(index.snapshot().dependencyAnalysisFailed, true, name) + assert.equal(index.filterEvents(events, { pageBuildFailed: false }), events, name) + assert.ok(index.snapshot().globalDataDepPaths.has(site.globalData.filepath), 'roots remain tracked during analysis failure') + assert.ok(index.snapshot().settingsDepPaths?.has(site.globalVars.filepath)) + index.recordLayouts({ pages: [] }, { filtered: false }) + assert.equal(index.snapshot().dependencyAnalysisFailed, true, 'layout recording cannot clear analysis failure') + await write(name) + await index.rebuild(site) + assert.equal(index.snapshot().dependencyAnalysisFailed, false, name) + assert.deepEqual(index.filterEvents(events, { pageBuildFailed: false }), []) + } + assert.equal(debug.mock.callCount(), 1) + assert.match(String(debug.mock.calls[0]?.arguments[0]), /^Could not analyze dependencies for pages file "archive\.pages\.js": .+/) +}) + +test('browser analysis failures retain entry points without enabling server dependency recovery', async t => { + const { index, site, write, file, debug } = await fixture(t) + site.globalClient = await write('global.client.js', 'export const =') + site.globalStyle = await write('global.css', 'not JavaScript {{{') + await index.rebuild(site) + const snapshot = index.snapshot() + assert.equal(snapshot.dependencyAnalysisFailed, false) + assert.deepEqual(snapshot.esbuildEntryPoints, new Set([site.globalClient.filepath, site.globalStyle.filepath])) + assert.equal(snapshot.esbuildDepPaths?.size, 0) + assert.deepEqual(index.filterEvents([classifyWatchEvent('change', file('unknown.json').filepath)], { pageBuildFailed: false }), []) + assert.equal(debug.mock.callCount(), 0) +}) diff --git a/lib/watch/index.js b/lib/watch/index.js new file mode 100644 index 00000000..f7682ab1 --- /dev/null +++ b/lib/watch/index.js @@ -0,0 +1,596 @@ +/// + +/** + * @import { DomStackOpts, Results, SiteData } from '../builder.js' + * @import { FSWatcher } from 'chokidar' + * @import { PageBuildStepResult } from '../build-pages/index.js' + * @import { BsInstance } from '@domstack/sync' + * @import { Logger as PinoLogger } from 'pino' + * @import { DomstackManifestRecord } from '../domstack-manifest/index.js' + * @import { WatchDependencyState } from '../build-pages/watch-dependencies.js' + * @import { WatchSnapshot, WatchEvent, WatchPlan } from './plan.js' + * @import { GlobalDataBaseline, GlobalDataInputChanges } from '../build-pages/global-data-state.js' + * @typedef {{ dispose: () => Promise }} DisposableBuildContext + * @typedef {{ pageFilePath: string, sourcePageFilePath?: string | undefined, pagesFilePath?: string | undefined, layoutNames: string[], outputs?: DomstackManifestRecord[] | undefined }} WatchedPageReport + * @typedef {object} WatchSession + * @property {'starting' | 'watching' | 'stopping'} state + * @property {AbortController} cancellation - Cancels event waits, not resource acquisition. + * @property {Promise} startupWork - The current resource-acquiring startup phase; never the user callback. + * @property {Promise | null} shutdown - Shared by explicit stops and startup failure cleanup. + * @property {WatchEvent[]} pendingEvents + * @property {boolean} drainScheduled + * @property {GlobalDataBaseline | null} globalDataBaseline + */ +import { once } from 'events' +import { setImmediate } from 'node:timers/promises' + +import chokidar from 'chokidar' +import { basename, relative } from 'node:path' +import ignore from 'ignore' +import { watch as cpxWatch } from 'cpx2' + +import { createServer } from '@domstack/sync' + +import { getCopyGlob } from '../build-static/index.js' +import { getCopyDirs } from '../build-copy/index.js' +import { buildEsbuildWatch } from '../build-esbuild/index.js' +import { buildPages } from '../build-pages/index.js' +import { identifyPages } from '../identify-pages.js' +import { classifyWatchEvent, planWatchEvent, planWatchBatch, planBundleChange } from './plan.js' +import { ensureDest } from '../helpers/ensure-dest.js' +import { DomStackAggregateError } from '../helpers/domstack-aggregate-error.js' +import { PageOutputLedger } from './page-output-ledger.js' +import { WatchDependencyIndex } from './dependency-index.js' +import { buildLogger, errorLogger, logRebuildTree } from './logging.js' + +/** Internal watch coordinator, retained across sessions by the public DomStack facade. */ +export class DomStackWatcher { + /** @type {string} */ #src = '' + /** @type {string} */ #dest = '' + /** @type {() => Readonly} */ #getOptions + /** @type {FSWatcher?} */ #watcher = null + /** @type {ReturnType[]} */ #cpxWatchers = [] + /** @type {BsInstance?} */ #syncServer = null + /** @type {DisposableBuildContext?} */ #esbuildContext = null + /** @type {SiteData?} */ #siteData = null + /** @type {PinoLogger} */ #logger + + /** @type {WatchDependencyIndex} */ #dependencies + /** @type {PageOutputLedger} */ #outputs + /** @type {WatchDependencyState | null} subscriptions and fingerprints from the last successful page build */ + #watchDependencies = null + /** @type {boolean} Failed builds may leave the previous routing state incomplete. */ + #pageBuildFailed = false + + // One session owns the resources above until shutdown finishes. + // Normal path: absent → starting → watching → stopping → absent. + // Startup failure or cancellation: starting → stopping → absent. + /** @type {WatchSession | null} */ + #watchSession = null + + // Serialized lock so concurrent chokidar events don't pile up + /** @type {Promise} */ + #buildLock = Promise.resolve() + + /** + * @param {string} src + * @param {string} dest + * @param {() => Readonly} getOptions - Read the facade's current options, including replacements. + * @param {PinoLogger} logger + */ + constructor (src, dest, getOptions, logger) { + this.#src = src + this.#dest = dest + this.#getOptions = getOptions + this.#logger = logger + this.#dependencies = new WatchDependencyIndex(logger) + this.#outputs = new PageOutputLedger(dest) + } + + get opts () { + return this.#getOptions() + } + + /** True from the start of watch() until shutdown completes, including startup. */ + get watching () { + return this.#watchSession !== null + } + + /** + * Build and watch a domstack build + * + * Stopping during startup still returns the initial build report, but does not + * activate watch events. Await stopWatching() for completed resource cleanup. + * + * @param {object} [params] + * @param {boolean} params.serve + * @param {(results: Results) => void | Promise} [params.onInitialBuild] + * @return {Promise} + */ + async watch ({ + serve, + onInitialBuild, + } = { + serve: true, + }) { + if (this.watching) throw new Error('Already watching.') + /** @type {WatchSession} */ + const session = { + state: 'starting', + cancellation: new AbortController(), + startupWork: Promise.resolve(), + shutdown: null, + pendingEvents: [], + drainScheduled: false, + globalDataBaseline: null, + } + this.#watchSession = session + try { + return await this.#startWatch(session, { serve, onInitialBuild }) + } catch (error) { + try { + await this.#stopWatchSession(session) + } catch (cleanupError) { + // The callback may already be propagating this same shutdown failure. + if (error === cleanupError) throw error + throw new AggregateError([error, cleanupError], 'Watch startup and cleanup failed') + } + throw error + } + } + + /** + * Resource acquisition must finish before shutdown can release its results. + * Readiness waits, on the other hand, must be cancelled when watchers close. + * The user callback is outside the acquisition phases so it can await a stop. + * + * @param {WatchSession} session + * @param {{ serve: boolean, onInitialBuild: ((results: Results) => void | Promise) | undefined }} params + */ + async #startWatch (session, { serve, onInitialBuild }) { + const { signal } = session.cancellation + const preparation = this.#prepareWatch(session) + session.startupWork = preparation + const report = await preparation + if (signal.aborted) return report + + await onInitialBuild?.(report) + if (signal.aborted) return report + + if (serve) { + session.startupWork = this.#startWatchServer() + await session.startupWork + if (signal.aborted) return report + } + + session.state = 'watching' + this.#scheduleWatchBatch(session) + + return report + } + + /** @param {WatchSession} session */ + async #prepareWatch (session) { + const { signal } = session.cancellation + // Establish observation before discovery. Initial scan adds are not edits; + // subsequent events stay buffered until startup and the user callback finish. + await this.#createSourceWatcher(session) + // ── Initial build (inline, not via builder()) ──────────────────────── + const siteData = await identifyPages(this.#src, this.opts) + + if (siteData.errors.length > 0) { + throw new DomStackAggregateError(siteData.errors, 'Page walk finished but there were errors.', siteData) + } + + await ensureDest(this.#dest, siteData) + + // Start esbuild in watch mode (stable filenames, no hash) + let esbuildContext + try { + const { context } = await buildEsbuildWatch(this.#src, this.#dest, siteData, this.opts, { logger: this.#logger }) + esbuildContext = context + } catch (err) { + throw new Error('Error starting esbuild watch context', { cause: err }) + } + this.#esbuildContext = esbuildContext + this.#siteData = siteData + + // Build pages (initial full build) + let report + try { + const pageBuildResults = await buildPages(this.#src, this.#dest, siteData, { + ...this.opts, + trackWatchDependencies: true, + }) + await this.#acceptPageBuild(siteData, pageBuildResults, { filtered: false }) + report = { + warnings: [...siteData.warnings, ...pageBuildResults.warnings], + siteData, + pageBuildResults, + } + buildLogger(report, this.#logger) + this.#logger.debug('Initial JS, CSS and Page Build Complete') + } catch (err) { + if (!(err instanceof DomStackAggregateError)) throw new Error('Non-aggregate error thrown', { cause: err }) + this.#pageBuildFailed = true + report = err.results + errorLogger(err, this.#logger) + // Failed initial builds still need discovery-based routing for recovery. + await this.#dependencies.rebuild(siteData) + } + + // Copy readiness is cancellable: cpx2 invalidates pending scans on close. + const copyDirs = getCopyDirs(this.opts.copy ?? []) + const copyStartup = await Promise.allSettled([ + this.#startCopyWatcher(getCopyGlob(this.#src), signal, this.opts.ignore ?? []), + ...copyDirs.map(copyDir => this.#startCopyWatcher(copyDir, signal)), + ]) + const copyErrors = copyStartup.filter(result => result.status === 'rejected').map(result => result.reason) + if (copyErrors.length) throw new AggregateError(copyErrors, 'Copy watch startup failed') + + return report + } + + /** @param {WatchSession} session */ + #createSourceWatcher (session) { + const { signal } = session.cancellation + const ig = ignore().add(this.opts.ignore ?? []) + + const anymatch = (/** @type {string} */name) => ig.ignores(relname(this.#src, name)) + + const watcher = chokidar.watch(this.#src, { + // Observe non-page extensions too (for example statically imported JSON). + // Route only processed files and known dependencies after maps are ready. + ignored: filePath => anymatch(filePath), + persistent: true, + ignoreInitial: true, + // Increase the atomic write window so editors that do slow atomic saves + // (write to a temp file then rename) emit a `change` event rather than + // `unlink` + `add`, which would otherwise trigger unnecessary full rebuilds. + atomic: 300, + }) + + this.#watcher = watcher + const record = (/** @type {string} */ path, /** @type {WatchEvent['type']} */ type) => { + if (session.state === 'stopping') return + session.pendingEvents.push(classifyWatchEvent(type, path)) + this.#scheduleWatchBatch(session) + } + watcher.on('add', path => record(path, 'added')) + watcher.on('change', path => record(path, 'change')) + watcher.on('unlink', path => record(path, 'removed')) + watcher.on('error', err => errorLogger(err, this.#logger)) + // Attach the listener before returning; the watcher can become ready before + // the caller resumes. Cancellation settles this wait even without a ready event. + return once(watcher, 'ready', { signal }).catch(error => { + if (!signal.aborted || error.name !== 'AbortError') throw error + }) + } + + /** + * @param {string} source + * @param {AbortSignal} signal + * @param {string[]} [ignores] + */ + async #startCopyWatcher (source, signal, ignores = []) { + const watcher = cpxWatch(source, this.#dest, { ignore: ignores }) + this.#cpxWatchers.push(watcher) + let ready = false + let initialCopies = 0 + watcher.on('copy', (/** @type{{ srcPath: string, dstPath: string }} */e) => { + if (!ready) initialCopies++ + this.#logger.debug(`Copy ${e.srcPath} to ${e.dstPath}`) + if (ready) this.#logger.info(`Static asset updated: ${e.srcPath}`) + }) + watcher.on('remove', (/** @type{{ path: string }} */e) => { + this.#logger.info(`Remove ${e.path}`) + }) + watcher.on('watch-error', (/** @type{Error} */err) => { + this.#logger.error(`Copy error: ${err.message}`) + }) + + // cpx2 reports startup failure as "watch-error", not EventEmitter's "error". + // A closed session may never emit readiness, so cancellation must also settle + // this wait. This does not drain file operations already started by cpx2. + const { promise, resolve, reject } = Promise.withResolvers() + const onAbort = () => resolve(undefined) + watcher.once('watch-ready', resolve) + watcher.once('watch-error', reject) + signal.addEventListener('abort', onAbort, { once: true }) + try { + if (signal.aborted) return + await promise + ready = true + if (!signal.aborted) this.#logger.info(`Static asset watcher ready (${initialCopies} initial copy operations)`) + } finally { + watcher.off('watch-ready', resolve) + watcher.off('watch-error', reject) + signal.removeEventListener('abort', onAbort) + } + } + + async #startWatchServer () { + this.#syncServer = await createServer({ + server: this.#dest, + files: basename(this.#dest), + ignore: ['**/domstack-esbuild-meta.json'], + logger: this.#logger.child({ component: 'sync', logPrefix: '[domstack-sync]' }), + }) + } + + /** + * Full rebuild: re-identify pages, restart esbuild, rebuild all pages, rebuild maps. + * Used for structural changes (add/unlink), global.vars.*, esbuild.settings.*. + */ + async #fullRebuild (/** @type {GlobalDataInputChanges} */ inputChanges) { + this.#logger.info('Triggering full rebuild...') + // Dispose the old esbuild context + if (this.#esbuildContext) { + await this.#esbuildContext.dispose() + this.#esbuildContext = null + } + + const siteData = await identifyPages(this.#src, this.opts) + + if (siteData.errors.length > 0) { + throw new DomStackAggregateError(siteData.errors, 'Page discovery failed.', siteData) + } + + await ensureDest(this.#dest, siteData) + + const { context } = await buildEsbuildWatch(this.#src, this.#dest, siteData, this.opts, { logger: this.#logger }) + this.#esbuildContext = context + this.#siteData = siteData + + await this.#runPageBuild(siteData, null, null, null, inputChanges) + } + + /** @returns {WatchSnapshot | undefined} */ + #watchSnapshot () { + if (!this.#siteData) return + return { + siteData: this.#siteData, + ...this.#dependencies.snapshot(), + pageBuildFailed: this.#pageBuildFailed, + } + } + + /** @param {WatchEvent[]} events */ + async #handleWatchBatch (events) { + const snapshot = this.#watchSnapshot() + if (!snapshot) return + events = this.#dependencies.filterEvents(events, { pageBuildFailed: this.#pageBuildFailed }) + const event = events[0] + if (!event) return + const { plan, inputChanges } = planWatchBatch(snapshot, events) + // Bundle replanning can skip page work, so it must not bypass a required reset. + const singlePlan = inputChanges.resetReason === undefined && + events.length === 1 && event.type !== 'change' && event.convention?.bundleScope + ? planWatchEvent(snapshot, event) + : null + await this.#executeWatchPlan( + snapshot.pageBuildFailed + ? { kind: 'full', message: 'Rediscovering and retrying all pages after the previous build failure...' } + : singlePlan?.kind === 'restart' ? singlePlan : plan, + event, inputChanges + ) + } + + /** + * Keep resource ownership and successful-build state updates in the executor. + * @param {WatchPlan} plan + * @param {WatchEvent} event + * @param {GlobalDataInputChanges} inputChanges + * @returns {Promise} + */ + async #executeWatchPlan (plan, event, inputChanges) { + if (plan.message) this.#logger.info(plan.message) + if (plan.kind === 'skip') return + if (plan.kind === 'full') { + await this.#fullRebuild(inputChanges) + return + } + if (plan.kind === 'restart') { + await this.#restartEsbuildForEvent(event, inputChanges) + return + } + if (!this.#siteData) return + if (this.#logger.isLevelEnabled?.('info') !== false && (plan.pages || plan.templates)) { + logRebuildTree(event.name, this.#logger, new Set(plan.pages), new Set(plan.templates)) + } + await this.#runPageBuild(this.#siteData, plan.pageFilterPaths, plan.templateFilterPaths, plan.pagesFileFilterPaths, inputChanges) + } + + /** @param {WatchEvent} event @param {GlobalDataInputChanges} inputChanges */ + async #restartEsbuildForEvent (event, inputChanges) { + const siteData = await identifyPages(this.#src, this.opts) + if (siteData.errors.length > 0) { + throw new DomStackAggregateError(siteData.errors, 'Page discovery failed.', siteData) + } + await ensureDest(this.#dest, siteData) + if (this.#esbuildContext) { + await this.#esbuildContext.dispose() + this.#esbuildContext = null + } + const { context } = await buildEsbuildWatch(this.#src, this.#dest, siteData, this.opts, { logger: this.#logger }) + this.#esbuildContext = context + this.#siteData = siteData + const snapshot = this.#watchSnapshot() + if (!snapshot) return + const plan = planBundleChange(snapshot, event, this.#src) + // Successful page builds refresh their own maps. Service workers have no + // HTML consumers, but their entry map still changes. + if (plan.kind === 'skip') await this.#dependencies.rebuild(siteData) + await this.#executeWatchPlan(plan, event, inputChanges) + } + + /** + * Run a full or filtered page build with the existing esbuild context. + * + * @param {SiteData} siteData + * @param {string[] | null} [pageFilterPaths] + * @param {string[] | null} [templateFilterPaths] + * @param {string[] | null} [pagesFileFilterPaths] + * @param {GlobalDataInputChanges} [inputChanges] + */ + async #runPageBuild (siteData, pageFilterPaths = null, templateFilterPaths = null, pagesFileFilterPaths = null, inputChanges) { + // Retry the complete page phase after a failure: neither subscriptions nor + // layout routing from a failed build can safely drive an incremental retry. + if (this.#pageBuildFailed) pageFilterPaths = templateFilterPaths = pagesFileFilterPaths = null + try { + const pageBuildResults = await buildPages(this.#src, this.#dest, siteData, { + ...this.opts, + ...(pageFilterPaths ? { pageFilterPaths } : {}), + ...(templateFilterPaths ? { templateFilterPaths } : {}), + ...(pagesFileFilterPaths ? { pagesFileFilterPaths } : {}), + previousGlobalDataBaseline: this.#watchSession?.globalDataBaseline, + globalDataInputChanges: inputChanges, + previousWatchDependencies: this.#watchDependencies, + previousPageOutputCache: this.#outputs.cache, + trackWatchDependencies: true, + }) + const isFiltered = pageFilterPaths !== null || templateFilterPaths !== null || pagesFileFilterPaths !== null + await this.#acceptPageBuild(siteData, pageBuildResults, { filtered: isFiltered }) + buildLogger( + isFiltered ? pageBuildResults : { warnings: pageBuildResults.warnings, siteData, pageBuildResults }, + this.#logger, + isFiltered ? this.#dest : undefined + ) + return pageBuildResults + } catch (err) { + this.#pageBuildFailed = true + errorLogger(err, this.#logger) + } + } + + /** + * Account for filesystem effects before accepting a new successful baseline. + * Used by both initial builds and rebuilds; callers own their error policy. + * @param {SiteData} siteData + * @param {PageBuildStepResult} pageBuildResults + * @param {{ filtered: boolean }} options + */ + async #acceptPageBuild (siteData, pageBuildResults, { filtered }) { + this.#outputs.recordWrites(pageBuildResults) + delete pageBuildResults.report.pageOutputCache + if (pageBuildResults.errors.length > 0) { + throw new DomStackAggregateError(pageBuildResults.errors, 'Page build finished but there were errors.', { + siteData, + pageBuildResults, + }) + } + + // A cleanup failure leaves writes recorded, but cannot advance data state. + await this.#outputs.reconcileSuccessfulBuild(pageBuildResults, { filtered }) + this.#dependencies.recordLayouts(pageBuildResults.report, { filtered }) + await this.#dependencies.rebuild(siteData) + this.#watchDependencies = pageBuildResults.report.watchDependencies ?? this.#watchDependencies + if (this.#watchSession) this.#watchSession.globalDataBaseline = pageBuildResults.report.globalDataBaseline ?? null + delete pageBuildResults.report.globalDataBaseline + delete pageBuildResults.report.watchDependencies + delete pageBuildResults.report.rebuiltPagesFilePaths + this.#pageBuildFailed = false + } + + /** @param {WatchSession} session */ + #scheduleWatchBatch (session) { + if (session.state !== 'watching' || session.drainScheduled || !session.pendingEvents.length) return + session.drainScheduled = true + this.#buildLock = this.#buildLock.then(async () => { + try { + while (session.state === 'watching' && session.pendingEvents.length) { + // Coalesce the current event-loop turn, then detach. Events observed + // during asynchronous build work belong to the next batch, never this one. + await setImmediate() + if (session.state !== 'watching') break + const events = session.pendingEvents + session.pendingEvents = [] + try { + await this.#handleWatchBatch(events) + } catch (err) { + this.#pageBuildFailed = true + errorLogger(err, this.#logger) + } + } + } finally { + session.drainScheduled = false + } + }) + } + + /** + * Cancel startup/event waits, drain owned work, and release the session. + * The user callback is not drained: it may itself be awaiting this stop. + * Concurrent stops share cleanup; a new watch may start once cleanup settles. + */ + async stopWatching () { + if (!this.#watchSession) throw new Error('Not watching') + return this.#stopWatchSession(this.#watchSession) + } + + /** @param {WatchSession} session */ + #stopWatchSession (session) { + // Retain this promise on the session even after cleanup. An old callback may + // finish or throw after a new session starts; it must not clean up that session. + if (session.shutdown) return session.shutdown + session.state = 'stopping' + session.cancellation.abort() + session.shutdown = this.#disposeWatchResources(session) + return session.shutdown + } + + /** @param {WatchSession} session */ + async #disposeWatchResources (session) { + // 1. Drain resource acquisition. No new startup phase may begin after a stop. + // watch() reports startup errors; shutdown still releases partial resources. + await session.startupWork.catch(() => {}) + + // 2. Stop filesystem producers. The session state already rejects new and + // queued rebuilds, including callbacks retained by a previous watch session. + const closures = [ + () => this.#watcher?.close(), + ...this.#cpxWatchers.map(w => () => w.close()), + ] + const results = await Promise.allSettled(closures.map(close => Promise.resolve().then(close))) + + // 3. Drain the active rebuild before releasing the esbuild context it may replace. + results.push(...await Promise.allSettled([this.#buildLock])) + + // 4. Release the final contexts and server, even if another cleanup step failed. + results.push(...await Promise.allSettled([ + Promise.resolve().then(() => this.#esbuildContext?.dispose()), + Promise.resolve().then(() => this.#syncServer?.exit()), + ])) + session.pendingEvents = [] + session.globalDataBaseline = null + this.#watchDependencies = null + this.#pageBuildFailed = false + this.#watcher = null + this.#cpxWatchers = [] + this.#esbuildContext = null + this.#syncServer = null + this.#siteData = null + this.#buildLock = Promise.resolve() + this.#watchSession = null + const errors = results.filter(result => result.status === 'rejected').map(result => result.reason) + if (errors.length > 0) throw new AggregateError(errors, 'Watch cleanup failed') + } + + /** + * Returns a promise that resolves when all queued rebuilds have finished. + * @returns {Promise} + */ + async settled () { + await this.#buildLock + } +} + +/** + * relanem is the bsaename if (root === name), otherwise relative(root, name) + * @param {string} root The root path string + * @param {string} name The name string + * @return {string} the relname + */ +function relname (root, name) { + return root === name ? basename(name) : relative(root, name) +} diff --git a/lib/watch/logging.js b/lib/watch/logging.js new file mode 100644 index 00000000..cf41fecd --- /dev/null +++ b/lib/watch/logging.js @@ -0,0 +1,103 @@ +/** + * @import { Results } from '../builder.js' + * @import { WorkerBuildStepResult } from '../build-pages/index.js' + * @import { PageInfo, TemplateInfo } from '../identify-pages.js' + * @import { Logger as PinoLogger } from 'pino' + * @import { DomstackManifestRecord } from '../domstack-manifest/index.js' + */ +import { relative } from 'node:path' +import { inspect } from 'node:util' + +/** + * Log a rebuild tree showing what triggered a rebuild and what will be rebuilt. + * @param {string} trigger - The changed file (display name) + * @param {PinoLogger} logger + * @param {Set} [pages] + * @param {Set} [templates] + */ +export function logRebuildTree (trigger, logger, pages, templates) { + if (logger.isLevelEnabled?.('info') === false) return + const lines = [`"${trigger}" changed:`] + for (const p of pages ?? []) { + lines.push(` → ${p.outputRelname}`) + } + for (const t of templates ?? []) { + lines.push(` → ${t.outputName} (template)`) + } + logger.info(lines.join('\n')) +} + +/** + * An error logger + * @param {Error | AggregateError | any} err The error to log + * @param {PinoLogger} logger + */ +export function errorLogger (err, logger) { + if (!(err instanceof Error || err instanceof AggregateError)) throw new Error('Non-error thrown', { cause: err }) + if ('results' in err) delete err.results + logger.error(inspect(err, { depth: 999, colors: true })) + logger.error('Build Failed!') +} + +/** + * Log build results. + * @param {Partial | WorkerBuildStepResult} results + * @param {PinoLogger} logger + * @param {string} [dest] - dest path for relativizing output paths in filtered builds + */ +export function buildLogger (results, logger, dest) { + if ((results?.warnings?.length ?? 0) > 0) { + logger.warn('There were build warnings:') + } + for (const warning of results?.warnings ?? []) { + if ('message' in warning) { + logger.warn(` ${warning.message}`) + } else { + logger.warn(inspect(warning, { depth: 999, colors: true })) + } + } + + if (logger.isLevelEnabled?.('info') === false) return + + if ('siteData' in results && results.siteData) { + // Full build: show site totals + const layoutCount = Object.keys(results.siteData.layouts).length + logger.info(`Source pages: ${results.siteData.pages.length} Layouts: ${layoutCount} Templates: ${results.siteData.templates.length}`) + const outputs = results.pageBuildResults?.outputs + if (outputs) { + const summary = summarizePageDomstackManifests(outputs) + logger.info(`Pages built: ${summary.pages} Templates built: ${summary.templates}`) + } + } else if ('outputs' in results) { + // Filtered build: show what was actually built + const outputs = results.outputs + if (dest) { + for (const output of outputs) { + if (output.kind === 'page' || output.kind === 'template') { + logger.info(` Built ${relative(dest, output.filepath)}`) + } + } + } + const summary = summarizePageDomstackManifests(outputs) + logger.info(`Pages built: ${summary.pages} Templates built: ${summary.templates}`) + } + logger.info('Build Success!') +} + +/** @param {DomstackManifestRecord[]} outputs */ +function summarizePageDomstackManifests (outputs) { + const templateSources = new Set() + let pages = 0 + + for (const output of outputs) { + if (output.kind === 'page') pages += 1 + if (output.kind === 'template') { + templateSources.add(output.sourceRelname ?? output.templatePath ?? output.outputRelname) + } + } + + return { + pages, + templates: templateSources.size, + } +} diff --git a/lib/watch/logging.test.js b/lib/watch/logging.test.js new file mode 100644 index 00000000..9bdf9ee5 --- /dev/null +++ b/lib/watch/logging.test.js @@ -0,0 +1,158 @@ +/** + * @import { BuildStepWarnings, SiteData } from '../builder.js' + * @import { WorkerBuildStepResult } from '../build-pages/index.js' + * @import { PageInfo, TemplateInfo } from '../identify-pages.js' + * @import { DomstackManifestRecord } from '../domstack-manifest/index.js' + */ +import assert from 'node:assert/strict' +import { join, resolve } from 'node:path' +import { test } from 'node:test' +import { inspect } from 'node:util' +import pino from 'pino' +import { buildLogger, logRebuildTree } from './logging.js' + +/** @param {string} [level] */ +function recordingLogger (level = 'info') { + /** @type {{ level: number, msg: string }[]} */ + const records = [] + const logger = pino({ level }, { + write (chunk) { + const { level, msg } = JSON.parse(chunk) + records.push({ level, msg }) + }, + }) + return { logger, records } +} + +const dest = resolve('logging-test-output') +const warnings = /** @type {BuildStepWarnings} */ ([ + { message: 'Page warning' }, + { text: 'Bundler warning' }, +]) +const warningRecords = [ + { level: 40, msg: 'There were build warnings:' }, + { level: 40, msg: ' Page warning' }, + { level: 40, msg: inspect(warnings[1], { depth: 999, colors: true }) }, +] + +/** @returns {WorkerBuildStepResult} */ +function buildResults () { + // Only fields read by the logger are needed on these manifest records. + const outputs = /** @type {DomstackManifestRecord[]} */ ([ + { kind: 'page', filepath: join(dest, 'index.html'), outputRelname: 'index.html' }, + { kind: 'template', filepath: join(dest, 'feed.xml'), sourceRelname: 'feed.template.js', templatePath: '/src/first.js' }, + { kind: 'template', filepath: join(dest, 'feed.json'), sourceRelname: 'feed.template.js', templatePath: '/src/second.js' }, + { kind: 'template', filepath: join(dest, 'legacy.xml'), templatePath: '/src/legacy.template.js' }, + { kind: 'template', filepath: join(dest, 'legacy.json'), templatePath: '/src/legacy.template.js' }, + { kind: 'template', filepath: join(dest, 'fallback.xml'), outputRelname: 'fallback.xml' }, + { kind: 'static', filepath: join(dest, 'asset.txt'), outputRelname: 'asset.txt' }, + ]) + return { type: 'page', warnings, errors: [], outputs, report: { pages: [], templates: [] } } +} + +test('info rebuild trees preserve text, insertion order, and Set deduplication', () => { + const { logger, records } = recordingLogger() + const home = /** @type {PageInfo} */ ({ outputRelname: 'index.html' }) + const other = /** @type {PageInfo} */ ({ outputRelname: 'other/index.html' }) + const template = /** @type {TemplateInfo} */ ({ outputName: 'feed.xml' }) + logRebuildTree('shared.js', logger, new Set([other, home, other]), new Set([template, template])) + logRebuildTree('empty.js', logger) + assert.deepEqual(records, [ + { level: 30, msg: '"shared.js" changed:\n → other/index.html\n → index.html\n → feed.xml (template)' }, + { level: 30, msg: '"empty.js" changed:' }, + ]) +}) + +test('info full-build logs preserve warnings, source totals, and deduplicated output totals', () => { + const { logger, records } = recordingLogger() + const pageBuildResults = buildResults() + // The full-build logger only reads collection sizes from discovery data. + const siteData = /** @type {SiteData} */ (/** @type {unknown} */ ({ + pages: [{}, {}], + layouts: { root: {}, article: {} }, + templates: [{}, {}, {}], + })) + buildLogger({ warnings, siteData, pageBuildResults: { ...pageBuildResults, errors: [] } }, logger) + assert.deepEqual(records, [ + ...warningRecords, + { level: 30, msg: 'Source pages: 2 Layouts: 2 Templates: 3' }, + { level: 30, msg: 'Pages built: 1 Templates built: 3' }, + { level: 30, msg: 'Build Success!' }, + ]) +}) + +for (const withDest of [true, false]) { + test(`info filtered-build logs preserve order and template deduplication ${withDest ? 'with' : 'without'} a destination`, () => { + const { logger, records } = recordingLogger() + buildLogger(buildResults(), logger, withDest ? dest : undefined) + assert.deepEqual(records, [ + ...warningRecords, + ...(withDest + ? ['index.html', 'feed.xml', 'feed.json', 'legacy.xml', 'legacy.json', 'fallback.xml'] + .map(name => ({ level: 30, msg: ` Built ${name}` })) + : []), + { level: 30, msg: 'Pages built: 1 Templates built: 3' }, + { level: 30, msg: 'Build Success!' }, + ]) + }) +} + +for (const level of ['warn', 'silent']) { + test(`${level} rebuild-tree logging does not iterate pages or templates`, t => { + const { logger, records } = recordingLogger(level) + const pages = new Set(/** @type {PageInfo[]} */ ([])) + const templates = new Set(/** @type {TemplateInfo[]} */ ([])) + t.mock.method(pages, Symbol.iterator, () => assert.fail('pages must not be iterated')) + t.mock.method(templates, Symbol.iterator, () => assert.fail('templates must not be iterated')) + logRebuildTree('shared.js', logger, pages, templates) + assert.deepEqual(records, []) + }) + + test(`${level} full-build logging preserves warnings without reading site totals or outputs`, () => { + const { logger, records } = recordingLogger(level) + buildLogger({ + warnings, + get siteData () { return assert.fail('site totals must not be read') }, + get pageBuildResults () { return assert.fail('outputs must not be read') }, + }, logger) + assert.deepEqual(records, level === 'warn' ? warningRecords : []) + }) + + test(`${level} filtered-build logging preserves warnings without iterating outputs`, () => { + const { logger, records } = recordingLogger(level) + const results = buildResults() + Object.defineProperty(results.outputs, Symbol.iterator, { + value: () => assert.fail('outputs must not be iterated'), + }) + buildLogger(results, logger, dest) + buildLogger({ ...results, warnings: [] }, logger) + assert.deepEqual(records, level === 'warn' ? warningRecords : []) + }) +} + +test('loggers without level inspection retain info and warning output', () => { + const { logger, records } = recordingLogger() + Object.defineProperty(logger, 'isLevelEnabled', { value: undefined }) + logRebuildTree('page.md', logger) + buildLogger({ warnings }, logger) + assert.deepEqual(records, [ + { level: 30, msg: '"page.md" changed:' }, + ...warningRecords, + { level: 30, msg: 'Build Success!' }, + ]) +}) + +test('info guards follow logger-level changes on every invocation', () => { + const { logger, records } = recordingLogger('warn') + for (const level of ['warn', 'info', 'silent', 'info']) { + logger.level = level + logRebuildTree('page.md', logger) + buildLogger({}, logger) + } + assert.deepEqual(records, [ + { level: 30, msg: '"page.md" changed:' }, + { level: 30, msg: 'Build Success!' }, + { level: 30, msg: '"page.md" changed:' }, + { level: 30, msg: 'Build Success!' }, + ]) +}) diff --git a/lib/watch/page-output-ledger.js b/lib/watch/page-output-ledger.js new file mode 100644 index 00000000..c4e284f6 --- /dev/null +++ b/lib/watch/page-output-ledger.js @@ -0,0 +1,139 @@ +/** + * @import { PageReport, WorkerBuildStepResult } from '../build-pages/index.js' + * @import { PageOutputCache } from '../build-pages/page-builders/page-output-writer.js' + */ +import { lstat, rm } from 'node:fs/promises' +import { dirname, resolve } from 'node:path' +import { assertInsideDest } from '../helpers/path.js' + +/** Internal output ownership and write cache, retained across watch sessions. */ +export class PageOutputLedger { + /** @type {string} */ #dest + /** @type {Map>} source page or *.pages.* filepath → owned absolute output paths */ + #pageOutputMap = new Map() + /** @type {Map>} template filepath → currently claimed absolute output paths */ + #templateOutputMap = new Map() + /** @type {PageOutputCache} Successful writes, including those before an iterator failure. */ + #pageOutputCache = new Map() + + /** @param {string} dest */ + constructor (dest) { + this.#dest = resolve(dest) + } + + /** @returns {PageOutputCache} */ + get cache () { + return this.#pageOutputCache + } + + /** + * Record every worker result, including successful builds before reconciliation. + * Union writes with prior ownership so failures cannot orphan emitted files. + * Report fields remain available to the caller. + * @param {Pick} results + */ + recordWrites (results) { + this.#pageOutputCache = results.report.pageOutputCache ?? this.#pageOutputCache + mergePageOutputs(this.#dest, results.report.pages, this.#pageOutputMap) + } + + /** + * Reconcile a successful page phase after recordWrites. Untouched page and + * template owners still protect their outputs during targeted builds. + * Ownership replacement and cache pruning commit only after cleanup succeeds. + * @param {Pick} results + * @param {{ filtered: boolean }} options + */ + async reconcileSuccessfulBuild (results, { filtered }) { + const dest = this.#dest + const pages = mergePageOutputs(dest, results.report.pages) + let templates = filtered ? this.#templateOutputMap : new Map() + if (filtered && results.report.templates.length) templates = new Map(templates) + + if (filtered) { + // Factories can successfully rebuild to zero pages; regular pages always + // report their HTML output, even when their page-output hook is gone. + const rebuiltFactories = new Set(results.report.rebuiltPagesFilePaths) + for (const [owner, outputs] of this.#pageOutputMap) { + if (!pages.has(owner) && !rebuiltFactories.has(owner)) pages.set(owner, outputs) + } + } + for (const report of results.report.templates) { + const outputs = new Set() + for (const output of report.outputs) outputs.add(resolve(dest, report.templateInfo.path, output)) + templates.set(report.templateInfo.templateFile.filepath, outputs) + } + + const otherClaims = new Set() + const pageOwnedPaths = new Set() + for (const outputs of pages.values()) { + for (const filepath of outputs) pageOwnedPaths.add(filepath) + } + for (const output of results.outputs) { + const filepath = resolve(dest, output.outputRelname) + if (!pageOwnedPaths.has(filepath)) otherClaims.add(filepath) + } + for (const outputs of templates.values()) { + for (const filepath of outputs) { + if (!pageOwnedPaths.has(filepath)) otherClaims.add(filepath) + } + } + const stale = new Set() + for (const [owner, outputs] of this.#pageOutputMap) { + // Untouched owners retain the same set, so none of their files can be stale. + if (pages.get(owner) === outputs) continue + for (const filepath of outputs) { + if (!pageOwnedPaths.has(filepath) && !otherClaims.has(filepath)) stale.add(filepath) + } + } + for (const filepath of stale) await removeStalePageOutput(dest, filepath) + + for (const filepath of this.#pageOutputCache.keys()) { + if (!pageOwnedPaths.has(filepath)) this.#pageOutputCache.delete(filepath) + } + this.#pageOutputMap = pages + this.#templateOutputMap = templates + } +} + +/** + * @param {string} dest + * @param {Pick[]} pageReports + * @param {Map>} [outputsByOwner] + * @returns {Map>} + */ +function mergePageOutputs (dest, pageReports, outputsByOwner = new Map()) { + for (const report of pageReports) { + const owner = report.pagesFilePath ?? report.sourcePageFilePath + if (!owner) continue + let outputs = outputsByOwner.get(owner) + if (!outputs) { + outputs = new Set() + outputsByOwner.set(owner, outputs) + } + for (const output of report.outputs ?? []) outputs.add(resolve(dest, output.outputRelname)) + } + return outputsByOwner +} + +/** + * Never follow a replaced output directory outside the destination. A symlink + * at the output itself is safe to unlink; directories are never removed. + * @param {string} dest + * @param {string} filepath + */ +async function removeStalePageOutput (dest, filepath) { + assertInsideDest(dest, filepath) + if (filepath === dest) throw new Error('Refusing to remove the build destination') + try { + for (let ancestor = dirname(filepath); ; ancestor = dirname(ancestor)) { + const stats = await lstat(ancestor) + if (stats.isSymbolicLink() || !stats.isDirectory()) return + if (ancestor === dest) break + } + const stats = await lstat(filepath) + if (!stats.isDirectory()) await rm(filepath, { force: true }) + } catch (err) { + if (/** @type {NodeJS.ErrnoException} */ (err).code !== 'ENOENT') throw err + } +} diff --git a/lib/watch/page-output-ledger.test.js b/lib/watch/page-output-ledger.test.js new file mode 100644 index 00000000..bf38cadc --- /dev/null +++ b/lib/watch/page-output-ledger.test.js @@ -0,0 +1,361 @@ +/** + * @import { TestContext } from 'node:test' + * @import { PageReport, WorkerBuildStepResult } from '../build-pages/index.js' + * @import { PageOutputCache } from '../build-pages/page-builders/page-output-writer.js' + * @import { DomstackManifestRecord } from '../domstack-manifest/index.js' + */ +import { test } from 'node:test' +import assert from 'node:assert/strict' +import fs, { lstat, mkdir, mkdtemp, readFile, rm, symlink, writeFile } from 'node:fs/promises' +import { syncBuiltinESMExports } from 'node:module' +import { tmpdir } from 'node:os' +import { dirname, join, resolve } from 'node:path' +import { PageOutputLedger } from './page-output-ledger.js' + +/** @param {TestContext} t */ +async function fixture (t) { + const root = await mkdtemp(join(tmpdir(), 'domstack-output-ledger-')) + t.after(() => rm(root, { recursive: true, force: true })) + const dest = join(root, 'public') + await mkdir(dest) + const ledger = new PageOutputLedger(dest) + + /** @param {string} name @returns {DomstackManifestRecord} */ + const output = name => ({ outputRelname: name, filepath: resolve(dest, name), kind: 'page-output', url: `/${name}` }) + /** @param {string} owner @param {string[]} names @returns {PageReport} */ + const page = (owner, names) => ({ sourcePageFilePath: join(root, owner), pageFilePath: resolve(dest, names[0] ?? 'index.html'), layoutNames: [], outputs: names.map(output) }) + /** @param {string[]} names @returns {PageOutputCache} */ + const cache = names => new Map(names.map(name => [resolve(dest, name), { hash: name, metadata: name }])) + /** @param {string[]} names */ + async function write (names) { + for (const name of names) { + const path = resolve(dest, name) + await mkdir(dirname(path), { recursive: true }) + await writeFile(path, name) + } + } + return { root, dest, ledger, output, page, cache, write } +} + +/** + * @param {PageReport[]} [pages] + * @param {Partial} [report] + * @returns {Pick} + */ +function result (pages = [], report = {}) { + return { report: { pages, templates: [], ...report }, outputs: pages.flatMap(page => page.outputs) } +} + +/** @param {string} filepath */ +async function missing (filepath) { + await assert.rejects(lstat(filepath), { code: 'ENOENT' }) +} + +test('partial writes union ownership without cleanup or report mutation, then recover', async t => { + const { dest, ledger, page, cache, write } = await fixture(t) + assert.ok(ledger.cache instanceof Map) + assert.equal(ledger.cache.size, 0) + await write(['index.html', 'old.json']) + const initial = result([page('page.js', ['index.html', 'old.json'])], { pageOutputCache: cache(['old.json']) }) + ledger.recordWrites(initial) + await ledger.reconcileSuccessfulBuild(initial, { filtered: false }) + + await write(['partial.json', 'second.json', 'unowned.json']) + const partialCache = cache(['old.json', 'partial.json']) + const partial = result([page('page.js', ['partial.json'])], { pageOutputCache: partialCache }) + const before = structuredClone(partial) + Object.freeze(partial.report) + ledger.recordWrites(partial) + assert.equal(ledger.cache, partialCache) + assert.deepEqual(partial, before) + + const unowned = page('ignored.js', ['unowned.json']) + delete unowned.sourcePageFilePath + ledger.recordWrites(result([page('page.js', ['second.json']), unowned])) + assert.equal(ledger.cache, partialCache, 'absent cache does not reset previous writes') + for (const name of ['old.json', 'partial.json', 'second.json']) { + assert.equal(await readFile(join(dest, name), 'utf8'), name) + } + + const recovery = result([page('page.js', ['index.html'])]) + ledger.recordWrites(recovery) + await ledger.reconcileSuccessfulBuild(recovery, { filtered: false }) + for (const name of ['old.json', 'partial.json', 'second.json']) await missing(join(dest, name)) + assert.equal(await readFile(join(dest, 'index.html'), 'utf8'), 'index.html') + assert.equal(await readFile(join(dest, 'unowned.json'), 'utf8'), 'unowned.json') + assert.equal(ledger.cache.size, 0) +}) + +test('targeted builds preserve untouched owners and template claims, deleting only page-owned files', async t => { + const { root, dest, ledger, output, page, cache, write } = await fixture(t) + const names = ['index.html', 'old.json', 'other/index.html', 'feeds/shared.json', 'feeds/template.xml', 'claimed.json', 'static.txt'] + await write(names) + const templatePath = join(root, 'feeds/feed.template.js') + const initial = result([ + page('page.js', ['index.html', 'old.json', 'feeds/shared.json', 'claimed.json']), + page('other/page.js', ['other/index.html']), + ], { + templates: [{ + templateInfo: { + templateFile: { root, filepath: templatePath, relname: 'feeds/feed.template.js', basename: 'feed.template.js', parentName: 'feeds' }, + path: 'feeds', + outputName: 'template.xml', + }, + outputs: ['shared.json', 'template.xml'], + type: 'array', + }], + pageOutputCache: cache(names), + }) + ledger.recordWrites(initial) + await ledger.reconcileSuccessfulBuild(initial, { filtered: false }) + + const targeted = result([page('page.js', ['index.html'])]) + targeted.outputs.push(output('claimed.json')) + ledger.recordWrites(targeted) + await ledger.reconcileSuccessfulBuild(targeted, { filtered: true }) + await missing(join(dest, 'old.json')) + for (const name of names.filter(name => name !== 'old.json')) { + assert.equal(await readFile(join(dest, name), 'utf8'), name) + } + assert.deepEqual([...ledger.cache.keys()], [join(dest, 'index.html'), join(dest, 'other/index.html')]) + + const full = result([page('page.js', ['index.html'])]) + ledger.recordWrites(full) + await ledger.reconcileSuccessfulBuild(full, { filtered: false }) + await missing(join(dest, 'other/index.html')) + for (const name of ['feeds/shared.json', 'feeds/template.xml', 'claimed.json', 'static.txt']) { + assert.equal(await readFile(join(dest, name), 'utf8'), name, 'non-page ownership is not swept') + } + assert.deepEqual([...ledger.cache.keys()], [join(dest, 'index.html')]) +}) + +test('shared page outputs stay owned and cached until the last page releases them', async t => { + const { dest, ledger, page, cache, write } = await fixture(t) + const names = ['one.html', 'two.html', 'shared.json'] + await write(names) + const initial = result([ + page('one/page.js', ['one.html', 'shared.json']), + page('two/page.js', ['two.html', 'shared.json']), + ], { pageOutputCache: cache(names) }) + ledger.recordWrites(initial) + await ledger.reconcileSuccessfulBuild(initial, { filtered: false }) + + const first = result([page('one/page.js', ['one.html'])]) + ledger.recordWrites(first) + await ledger.reconcileSuccessfulBuild(first, { filtered: true }) + assert.equal(await readFile(join(dest, 'shared.json'), 'utf8'), 'shared.json') + assert.deepEqual([...ledger.cache.keys()], names.map(name => join(dest, name))) + + const second = result([page('two/page.js', ['two.html'])]) + ledger.recordWrites(second) + await ledger.reconcileSuccessfulBuild(second, { filtered: true }) + await missing(join(dest, 'shared.json')) + assert.deepEqual([...ledger.cache.keys()], [join(dest, 'one.html'), join(dest, 'two.html')]) +}) + +test('generated pages share factory ownership and a targeted zero-output rebuild clears it', async t => { + const { root, dest, ledger, page, cache, write } = await fixture(t) + const names = ['one/index.html', 'one/data.json', 'two/index.html', 'untouched.html'] + await write(names) + const owner = join(root, 'archive.pages.js') + const initial = result([ + { ...page('one/page.js', ['one/index.html', 'one/data.json']), pagesFilePath: owner }, + { ...page('two/page.js', ['two/index.html']), pagesFilePath: owner }, + page('untouched/page.js', ['untouched.html']), + ], { rebuiltPagesFilePaths: [owner], pageOutputCache: cache(names) }) + ledger.recordWrites(initial) + await ledger.reconcileSuccessfulBuild(initial, { filtered: false }) + + const partialNames = ['partial/one.json', 'partial/two.json'] + await write(partialNames) + const partial = result(partialNames.map(name => ({ + ...page('generated/page.js', [name]), + pagesFilePath: owner, + })), { pageOutputCache: cache([...names, ...partialNames]) }) + ledger.recordWrites(partial) + + const unrelated = result([page('untouched/page.js', ['untouched.html'])]) + ledger.recordWrites(unrelated) + await ledger.reconcileSuccessfulBuild(unrelated, { filtered: true }) + for (const name of [...names, ...partialNames]) { + assert.equal(await readFile(join(dest, name), 'utf8'), name) + assert.ok(ledger.cache.has(join(dest, name))) + } + + const rebuilt = result([ + { ...page('one/page.js', ['one/index.html']), pagesFilePath: owner }, + { ...page('two/page.js', ['two/index.html']), pagesFilePath: owner }, + ], { rebuiltPagesFilePaths: [owner] }) + ledger.recordWrites(rebuilt) + await ledger.reconcileSuccessfulBuild(rebuilt, { filtered: true }) + for (const name of ['one/data.json', ...partialNames]) { + await missing(join(dest, name)) + assert.ok(!ledger.cache.has(join(dest, name))) + } + for (const name of ['one/index.html', 'two/index.html', 'untouched.html']) { + assert.equal(await readFile(join(dest, name), 'utf8'), name) + assert.ok(ledger.cache.has(join(dest, name))) + } + + const empty = result([], { rebuiltPagesFilePaths: [owner] }) + ledger.recordWrites(empty) + await ledger.reconcileSuccessfulBuild(empty, { filtered: true }) + for (const name of names.slice(0, 3)) await missing(join(dest, name)) + assert.equal(await readFile(join(dest, 'untouched.html'), 'utf8'), 'untouched.html') + assert.deepEqual([...ledger.cache.keys()], [join(dest, 'untouched.html')]) +}) + +test('cleanup failure retains old and newly recorded ownership and cache until recovery', async t => { + const { dest, ledger, page, cache, write } = await fixture(t) + await write(['removed.json', 'blocked.json']) + const initial = result([page('page.js', ['removed.json', 'blocked.json'])]) + ledger.recordWrites(initial) + await ledger.reconcileSuccessfulBuild(initial, { filtered: false }) + + await write(['new.json']) + const nextCache = cache(['removed.json', 'blocked.json', 'new.json']) + const next = result([page('page.js', ['new.json'])], { pageOutputCache: nextCache }) + Object.freeze(next.report) + ledger.recordWrites(next) + const cause = Object.assign(new Error('cleanup denied'), { code: 'EACCES' }) + const originalRm = fs.rm + /** @type {typeof fs.rm} */ + const failRemoval = async (path, options) => { + if (path === join(dest, 'blocked.json')) throw cause + return originalRm(path, options) + } + const removal = t.mock.method(fs, 'rm', failRemoval) + syncBuiltinESMExports() + try { + await assert.rejects(ledger.reconcileSuccessfulBuild(next, { filtered: false }), error => error === cause) + } finally { + removal.mock.restore() + syncBuiltinESMExports() + } + await missing(join(dest, 'removed.json')) + assert.equal(await readFile(join(dest, 'blocked.json'), 'utf8'), 'blocked.json') + assert.equal(await readFile(join(dest, 'new.json'), 'utf8'), 'new.json') + assert.equal(ledger.cache, nextCache) + assert.equal(ledger.cache.size, 3, 'cleanup failure must not prune cache') + assert.equal(next.report.pageOutputCache, nextCache) + + const recovery = result() + ledger.recordWrites(recovery) + await ledger.reconcileSuccessfulBuild(recovery, { filtered: false }) + await missing(join(dest, 'blocked.json')) + await missing(join(dest, 'new.json')) + assert.equal(ledger.cache.size, 0) +}) + +test('failed targeted cleanup does not commit replacement template claims', async t => { + const { root, dest, ledger, page, cache, write } = await fixture(t) + const names = ['index.html', 'blocked.json', 'old-claim.json', 'new-claim.json'] + await write(names) + const initial = result([page('page.js', ['index.html', 'blocked.json', 'old-claim.json'])], { + templates: [{ + templateInfo: { + templateFile: { root, filepath: join(root, 'feed.template.js'), relname: 'feed.template.js', basename: 'feed.template.js', parentName: '' }, + path: '', + outputName: 'old-claim.json', + }, + outputs: ['old-claim.json'], + type: 'array', + }], + }) + ledger.recordWrites(initial) + await ledger.reconcileSuccessfulBuild(initial, { filtered: false }) + + const unchanged = result([page('page.js', ['index.html', 'blocked.json', 'old-claim.json'])]) + ledger.recordWrites(unchanged) + await ledger.reconcileSuccessfulBuild(unchanged, { filtered: true }) + + const nextCache = cache(names) + const next = result([page('page.js', ['index.html', 'new-claim.json'])], { + templates: initial.report.templates.map(report => ({ ...report, outputs: ['new-claim.json'] })), + pageOutputCache: nextCache, + }) + ledger.recordWrites(next) + const cause = Object.assign(new Error('cleanup denied'), { code: 'EACCES' }) + const originalRm = fs.rm + /** @type {typeof fs.rm} */ + const failRemoval = async (path, options) => { + if (path === join(dest, 'blocked.json')) throw cause + return originalRm(path, options) + } + const removal = t.mock.method(fs, 'rm', failRemoval) + syncBuiltinESMExports() + try { + await assert.rejects(ledger.reconcileSuccessfulBuild(next, { filtered: true }), error => error === cause) + } finally { + removal.mock.restore() + syncBuiltinESMExports() + } + assert.equal(ledger.cache, nextCache) + assert.equal(ledger.cache.size, names.length) + + const recovery = result([page('page.js', ['index.html'])]) + ledger.recordWrites(recovery) + await ledger.reconcileSuccessfulBuild(recovery, { filtered: true }) + assert.equal(await readFile(join(dest, 'old-claim.json'), 'utf8'), 'old-claim.json') + await missing(join(dest, 'blocked.json')) + await missing(join(dest, 'new-claim.json')) + assert.deepEqual([...ledger.cache.keys()], [join(dest, 'index.html')]) +}) + +test('cleanup never follows symlink ancestors or removes directories, but unlinks output symlinks', async t => { + const { root, dest, ledger, page, write } = await fixture(t) + await write(['nested/data.json', 'leaf.json', 'directory', 'gone/data.json', 'file-parent/data.json']) + const initial = result([page('page.js', ['nested/data.json', 'leaf.json', 'directory', 'gone/data.json', 'file-parent/data.json'])]) + ledger.recordWrites(initial) + await ledger.reconcileSuccessfulBuild(initial, { filtered: false }) + + const outside = join(root, 'outside') + await mkdir(outside) + await writeFile(join(outside, 'data.json'), 'outside') + await rm(join(dest, 'nested'), { recursive: true }) + await symlink(outside, join(dest, 'nested'), 'dir') + await rm(join(dest, 'leaf.json')) + await symlink(join(outside, 'data.json'), join(dest, 'leaf.json')) + await rm(join(dest, 'directory')) + await mkdir(join(dest, 'directory')) + await rm(join(dest, 'gone'), { recursive: true }) + await rm(join(dest, 'file-parent'), { recursive: true }) + await writeFile(join(dest, 'file-parent'), 'not a directory') + + const empty = result() + ledger.recordWrites(empty) + await ledger.reconcileSuccessfulBuild(empty, { filtered: false }) + assert.equal(await readFile(join(outside, 'data.json'), 'utf8'), 'outside') + assert.ok((await lstat(join(dest, 'nested'))).isSymbolicLink()) + assert.ok((await lstat(join(dest, 'directory'))).isDirectory()) + assert.equal(await readFile(join(dest, 'file-parent'), 'utf8'), 'not a directory') + await missing(join(dest, 'leaf.json')) +}) + +test('cleanup also refuses to follow a replaced destination symlink', async t => { + const { root, dest, ledger, page, write } = await fixture(t) + await write(['data.json']) + ledger.recordWrites(result([page('page.js', ['data.json'])])) + const outside = join(root, 'outside') + await mkdir(outside) + await writeFile(join(outside, 'data.json'), 'outside') + await rm(dest, { recursive: true }) + await symlink(outside, dest, 'dir') + await ledger.reconcileSuccessfulBuild(result(), { filtered: false }) + assert.equal(await readFile(join(outside, 'data.json'), 'utf8'), 'outside') + assert.ok((await lstat(dest)).isSymbolicLink()) +}) + +for (const name of ['../outside.json', '.']) { + test(`cleanup rejects unsafe ownership path ${name}`, async t => { + const { root, dest, ledger, page, cache } = await fixture(t) + const outside = join(root, 'outside.json') + await writeFile(outside, 'outside') + const recorded = result([page('page.js', [name])], { pageOutputCache: cache([name]) }) + ledger.recordWrites(recorded) + await assert.rejects(ledger.reconcileSuccessfulBuild(result(), { filtered: false }), /escapes dest|Refusing to remove the build destination/) + assert.equal(await readFile(outside, 'utf8'), 'outside') + assert.ok((await lstat(dest)).isDirectory()) + assert.equal(ledger.cache.size, 1) + }) +} diff --git a/lib/watch-plan.js b/lib/watch/plan.js similarity index 80% rename from lib/watch-plan.js rename to lib/watch/plan.js index 48a7dcdf..9813f818 100644 --- a/lib/watch-plan.js +++ b/lib/watch/plan.js @@ -1,6 +1,6 @@ /** - * @import { SiteData } from './builder.js' - * @import { PageInfo, TemplateInfo, PagesFileInfo } from './identify-pages.js' + * @import { SiteData } from '../builder.js' + * @import { PageInfo, TemplateInfo, PagesFileInfo } from '../identify-pages.js' * @typedef {object} WatchSnapshot * @property {Pick} siteData * @property {ReadonlyMap>} layoutDepMap @@ -34,7 +34,7 @@ * @typedef {{ plan: WatchPlan, inputChanges: WatchInputChanges }} WatchBatchPlan */ import { basename, dirname, relative, resolve } from 'node:path' -import { classifyFile, fileConventions } from './file-conventions.js' +import { classifyFile, fileConventions } from '../file-conventions.js' /** * @param {'change' | 'added' | 'removed'} type @@ -138,18 +138,26 @@ export function planWatchEvent (state, event) { * @returns {WatchBatchPlan} */ export function planWatchBatch (state, events) { - /** @type {WatchPlan} */ - let plan = { kind: 'skip', message: 'No watch events to rebuild.' } + /** @type {WatchPlan[]} */ + const plans = [] /** @type {WatchInputChanges} */ const inputChanges = { upsertedPaths: [], events: [...events] } - if (!events.length) return { plan, inputChanges } + if (!events.length) return { plan: unionWatchPlans(plans), inputChanges } let resetReason = state.pageBuildFailed ? 'page-build-failed' : state.dependencyAnalysisFailed ? 'dependency-analysis-failed' : undefined - const sourcePaths = new Set(/** @type {string[]} */ ([])) - for (const page of state.siteData.pages) { - if (!page.generated) sourcePaths.add(page.pageFile.filepath) + /** @type {Set | undefined} */ + let sourcePaths + // Browser-only and template/owner-only changes need no source membership scan. + const getSourcePaths = () => { + if (!sourcePaths) { + sourcePaths = new Set() + for (const page of state.siteData.pages) { + if (!page.generated) sourcePaths.add(page.pageFile.filepath) + } + } + return sourcePaths } const upsertedPaths = new Set(/** @type {string[]} */ ([])) let allSourcesIncluded = false @@ -158,15 +166,16 @@ export function planWatchBatch (state, events) { const structural = event.type === 'added' || event.type === 'removed' const sourceEvent = structural && (event.name.endsWith('.md') || (event.convention?.change === 'page' && !fileConventions.pageVars.names.includes(event.name)) || - sourcePaths.has(event.filepath)) + getSourcePaths().has(event.filepath)) // Only source membership changes need mapped consumers; other structural // events already invalidate every current source input. const changePlan = sourceEvent ? planWatchEvent(state, { ...event, type: 'change' }) : eventPlan const reason = inputResetReason(state, event, changePlan) resetReason ??= reason - plan = unionWatchPlans(plan, eventPlan) + plans.push(eventPlan) if (reason === 'unknown-event' || reason === 'unreliable-event') { - plan = { kind: 'full', message: `"${event.name}" cannot be routed reliably, triggering full rebuild...` } + plans.length = 0 + plans.push({ kind: 'full', message: `"${event.name}" cannot be routed reliably, triggering full rebuild...` }) } if (!allSourcesIncluded) { @@ -174,22 +183,22 @@ export function planWatchBatch (state, events) { (structural && !sourceEvent) || reason === 'unreliable-event' const selected = changePlan.kind === 'pages' ? changePlan.pageFilterPaths : [] if (allInputs || selected === null) { - for (const path of sourcePaths) upsertedPaths.add(path) + for (const path of getSourcePaths()) upsertedPaths.add(path) allSourcesIncluded = true } else { for (const path of selected) { - if (sourcePaths.has(path)) upsertedPaths.add(path) + if (getSourcePaths().has(path)) upsertedPaths.add(path) } } } if (structural && sourceEvent) upsertedPaths.add(event.filepath) } if (state.pageBuildFailed || state.dependencyAnalysisFailed) { - plan = unionWatchPlans(plan, allPages()) + plans.push(allPages()) } if (resetReason) inputChanges.resetReason = resetReason inputChanges.upsertedPaths = [...upsertedPaths] - return { plan, inputChanges } + return { plan: unionWatchPlans(plans), inputChanges } } /** @@ -219,37 +228,64 @@ function hasWatchRole (state, filepath) { state.siteData.pagesFiles?.some(owner => owner.pagesFile.filepath === filepath) } -/** @param {WatchPlan} left @param {WatchPlan} right @returns {WatchPlan} */ -function unionWatchPlans (left, right) { - if (left.kind === 'full') return left - if (right.kind === 'full') return right - if (left.kind === 'restart' || right.kind === 'restart') { - return { kind: 'full', message: 'Bundle membership changed, triggering full rebuild...' } +/** @param {WatchPlan[]} plans @returns {WatchPlan} */ +function unionWatchPlans (plans) { + /** @type {WatchPlan} */ + let selected = { kind: 'skip', message: 'No watch events to rebuild.' } + let pagePlans = 0 + for (const plan of plans) { + if (plan.kind === 'full') return plan + if (plan.kind === 'restart') return { kind: 'full', message: 'Bundle membership changed, triggering full rebuild...' } + if (plan.kind === 'pages') { + selected = plan + pagePlans++ + } else if (selected.kind === 'skip') selected = plan + } + if (pagePlans < 2) return selected + + // Accumulate once per batch rather than copying the growing union per event. + /** @type {Set | null} */ + let pagePaths = new Set() + /** @type {Set | null} */ + let templatePaths = new Set() + /** @type {Set | null} */ + let ownerPaths = new Set() + /** @type {Map | undefined} */ + let pages + /** @type {Map | undefined} */ + let templates + const messages = new Set() + for (const plan of plans) { + if (plan.kind !== 'pages') continue + pagePaths = addFilterPaths(pagePaths, plan.pageFilterPaths) + templatePaths = addFilterPaths(templatePaths, plan.templateFilterPaths) + ownerPaths = addFilterPaths(ownerPaths, plan.pagesFileFilterPaths) + if (plan.pages) { + pages ??= new Map() + for (const page of plan.pages) pages.set(page.pageFile.filepath, page) + } + if (plan.templates) { + templates ??= new Map() + for (const template of plan.templates) templates.set(template.templateFile.filepath, template) + } + for (const message of plan.message?.split('\n') ?? []) messages.add(message) } - if (right.kind === 'skip') return left.kind === 'skip' ? right : left - if (left.kind === 'skip') return right - const messages = new Set([...(left.message?.split('\n') ?? []), ...(right.message?.split('\n') ?? [])]) return { kind: 'pages', - pageFilterPaths: unionFilterPaths(left.pageFilterPaths, right.pageFilterPaths), - templateFilterPaths: unionFilterPaths(left.templateFilterPaths, right.templateFilterPaths), - pagesFileFilterPaths: unionFilterPaths(left.pagesFileFilterPaths, right.pagesFileFilterPaths), - pages: left.pages || right.pages - ? [...new Map([...(left.pages ?? []), ...(right.pages ?? [])].map(page => [page.pageFile.filepath, page])).values()] - : undefined, - templates: left.templates || right.templates - ? [...new Map([...(left.templates ?? []), ...(right.templates ?? [])].map(template => [template.templateFile.filepath, template])).values()] - : undefined, + pageFilterPaths: pagePaths && [...pagePaths], + templateFilterPaths: templatePaths && [...templatePaths], + pagesFileFilterPaths: ownerPaths && [...ownerPaths], + pages: pages && [...pages.values()], + templates: templates && [...templates.values()], message: messages.size ? [...messages].join('\n') : undefined, } } -/** @param {string[] | null} left @param {string[] | null} right */ -function unionFilterPaths (left, right) { - if (left === null || right === null) return null - const paths = new Set(left) - for (const path of right) paths.add(path) - return [...paths] +/** @param {Set | null} paths @param {string[] | null} additions */ +function addFilterPaths (paths, additions) { + if (paths === null || additions === null) return null + for (const path of additions) paths.add(path) + return paths } /** diff --git a/lib/watch-plan.test.js b/lib/watch/plan.test.js similarity index 89% rename from lib/watch-plan.test.js rename to lib/watch/plan.test.js index 787fa731..f38e66b2 100644 --- a/lib/watch-plan.test.js +++ b/lib/watch/plan.test.js @@ -1,11 +1,11 @@ /** - * @import { WatchSnapshot, WatchPlan } from './watch-plan.js' - * @import { WalkerFile, PageInfo, PageTypes } from './identify-pages.js' + * @import { WatchSnapshot, WatchPlan } from './plan.js' + * @import { WalkerFile, PageInfo, PageTypes } from '../identify-pages.js' */ import { test } from 'node:test' import assert from 'node:assert/strict' import { basename, dirname, join, resolve } from 'node:path' -import { classifyWatchEvent, planWatchEvent, planWatchBatch, planBundleChange } from './watch-plan.js' +import { classifyWatchEvent, planWatchEvent, planWatchBatch, planBundleChange } from './plan.js' const src = '/site' @@ -288,6 +288,39 @@ test('batch unions filtered output scopes and source inputs, retaining event ord assert.deepEqual({ state, events }, before) }) +test('large batches accumulate selections once while retaining order and duplicate events', () => { + const { state } = fixture() + const pages = Array.from({ length: 1000 }, (_, i) => page(`post-${i}`)) + state.siteData.pages = pages + state.pageFileMap = new Map(pages.map(page => [page.pageFile.filepath, page])) + const events = pages.map(page => classifyWatchEvent('change', page.pageFile.filepath)) + events.push(...events.slice().reverse()) + const before = structuredClone({ state, events }) + const batch = planWatchBatch(state, events) + assert.deepEqual(scope(batch.plan), [pages.map(page => page.pageFile.filepath), [], []]) + if (batch.plan.kind !== 'pages') throw new Error('Expected a page plan') + assert.deepEqual(batch.plan.pages, pages) + assert.deepEqual(batch.inputChanges.upsertedPaths, pages.map(page => page.pageFile.filepath)) + assert.deepEqual(batch.inputChanges.events, events) + assert.notEqual(batch.inputChanges.events, events) + assert.deepEqual({ state, events }, before) +}) + +test('full-plan messages preserve dominance and unreliable events replace earlier reasons', () => { + const { state } = fixture() + const pageEvent = classifyWatchEvent('change', '/site/page.md') + const settings = classifyWatchEvent('change', '/site/global.vars.js') + const restart = classifyWatchEvent('added', '/site/client.jsx') + const unknown = classifyWatchEvent('change', '/site/unknown.js') + assert.deepEqual(planWatchBatch(state, [pageEvent, settings, restart]).plan, planWatchEvent(state, settings)) + assert.deepEqual(planWatchBatch(state, [pageEvent, restart, settings]).plan, { + kind: 'full', message: 'Bundle membership changed, triggering full rebuild...', + }) + assert.deepEqual(planWatchBatch(state, [settings, unknown, restart, pageEvent]).plan, { + kind: 'full', message: '"unknown.js" cannot be routed reliably, triggering full rebuild...', + }) +}) + test('single page plans preserve exact logging metadata and messages even when surrounded by skips', () => { const { state } = fixture() const skipped = classifyWatchEvent('change', '/site/client.jsx') @@ -341,6 +374,33 @@ test('known browser-only dependencies skip page rebuilding in both planners with assert.equal(unknown.inputChanges.resetReason, 'unknown-event') }) +test('batches without source input selections do not enumerate source pages', () => { + const { state } = fixture() + Object.defineProperty(state.siteData.pages, Symbol.iterator, { + value: () => assert.fail('source pages must not be iterated'), + }) + const events = ['client.jsx', 'feed.template.js', 'archive.pages.js', 'archive-helper.js', 'global.data.js'] + .map(name => classifyWatchEvent('change', `/site/${name}`)) + for (const event of events) { + assert.deepEqual(planWatchBatch(state, [event]).inputChanges.upsertedPaths, []) + } + assert.deepEqual(planWatchBatch(state, events).inputChanges.upsertedPaths, []) +}) + +test('source membership is enumerated once per batch and excludes generated pages', t => { + const { state, home, other, owner } = fixture() + state.siteData.pages.push({ ...page('generated'), generated: { pagesFile: owner } }) + const iterate = state.siteData.pages[Symbol.iterator].bind(state.siteData.pages) + const scan = t.mock.fn(iterate) + Object.defineProperty(state.siteData.pages, Symbol.iterator, { value: scan }) + const events = ['page-helper.js', 'global.vars.js', 'page.md'] + .map(name => classifyWatchEvent('change', `/site/${name}`)) + const batch = planWatchBatch(state, events) + assert.equal(scan.mock.callCount(), 1) + assert.deepEqual(batch.inputChanges.upsertedPaths, [other.pageFile.filepath, home.pageFile.filepath]) + assert.equal(batch.inputChanges.resetReason, 'global-vars-changed') +}) + test('template and generated-page owner changes do not invent source inputs', () => { const { state, template, owner } = fixture() const batch = planWatchBatch(state, ['feed.template.js', 'archive.pages.js', 'archive-helper.js'] diff --git a/test-cases/page-outputs/watch.test.js b/test-cases/page-outputs/watch.test.js index 6091db54..a789bf0a 100644 --- a/test-cases/page-outputs/watch.test.js +++ b/test-cases/page-outputs/watch.test.js @@ -80,6 +80,24 @@ test('watch reconciles companion addition, output rename, hook removal, companio assert.match(await read('article/index.html'), /Article/) }) +test('watch retains output ownership across sessions to remove sidecars renamed while stopped', { timeout: 15_000 }, async t => { + const { site, src, dest, read } = await setup(t, { + 'article/page.html': '

Article

', + 'article/page.vars.js': 'export default {}; ' + hook('old.txt', 'old sidecar'), + }) + await startWatch(t, site, src) + assert.equal(await read('article/old.txt'), 'old sidecar') + + await site.stopWatching() + await writeFile(join(src, 'article/page.vars.js'), 'export default {}; ' + hook('new.txt', 'new sidecar')) + assert.equal(await read('article/old.txt'), 'old sidecar', 'cleanup waits until the next watch session') + + await startWatch(t, site, src) + assert.equal(await read('article/new.txt'), 'new sidecar') + assert.match(await read('article/index.html'), /Article/) + await assert.rejects(stat(join(dest, 'article/old.txt')), { code: 'ENOENT' }) +}) + test('watch removes sidecars on source rename and draft exclusion', { timeout: 30_000 }, async t => { const { site, src, dest, read, logs } = await setup(t, { 'root.layout.js': `export default ({ children }) => children diff --git a/test-cases/watch-lifecycle/index.test.js b/test-cases/watch-lifecycle/index.test.js index 94c09397..11fb0f68 100644 --- a/test-cases/watch-lifecycle/index.test.js +++ b/test-cases/watch-lifecycle/index.test.js @@ -54,6 +54,35 @@ async function fixture (t, onWatcher) { } } +test('idle facade reports no watch session before startup and after shutdown', { timeout: 15_000 }, async t => { + const site = await fixture(t) + assert.equal(site.dom.watching, false) + await assert.doesNotReject(site.dom.settled()) + await assert.rejects(site.dom.stopWatching(), { message: 'Not watching' }) + + await site.dom.watch({ serve: false }) + await site.dom.stopWatching() + + assert.equal(site.dom.watching, false) + await assert.doesNotReject(site.dom.settled()) + await assert.rejects(site.dom.stopWatching(), { message: 'Not watching' }) +}) + +test('watch uses replaced facade options before startup and across sessions', { timeout: 15_000 }, async t => { + const site = await fixture(t) + await writeFile(join(site.src, 'draft.draft.md'), '# Draft\n') + + for (const buildDrafts of [true, false]) { + site.dom.opts = { ...site.dom.opts, buildDrafts } + const built = await site.dom.build() + const watched = await site.dom.watch({ serve: false }) + for (const report of [built, watched]) { + assert.equal(report.siteData.pages.some(page => page.pageFile.relname === 'draft.draft.md'), buildDrafts) + } + await site.dom.stopWatching() + } +}) + test('shutdown during the initial build drains startup and permits a retry', { timeout: 15_000 }, async t => { const site = await fixture(t) let callbacks = 0 diff --git a/types.ts b/types.ts index 9132d0e8..57e70524 100644 --- a/types.ts +++ b/types.ts @@ -18,7 +18,7 @@ export type { GlobalDataDeltaChanges, GlobalDataResetChanges, } from './lib/build-pages/global-data-state.js' -export type { WatchEvent } from './lib/watch-plan.js' +export type { WatchEvent } from './lib/watch/plan.js' export type { PageOutput, PageOutputProvenance, From 6bd279f4d721942f9ea5a43c3d0ebb6302c9ac78 Mon Sep 17 00:00:00 2001 From: Bret Comnes Date: Wed, 16 Sep 2026 18:23:03 -0700 Subject: [PATCH 05/20] refactor(build-pages): separate orchestration, data, generation, and outputs Extract the direct coordinator and worker protocol, group producer/subscriber helpers and output writers, and isolate streaming factory expansion. Preserve API/type exports and worker error metadata; document module ownership and add protocol regressions. --- docs/implementation/README.md | 19 + lib/build-pages/build.js | 350 ++++++++++ lib/build-pages/{ => data}/data-deps.js | 2 +- .../global-data-state-types.test.ts | 2 +- .../{ => data}/global-data-state.js | 6 +- .../{ => data}/global-data-state.test.js | 8 +- .../{ => data}/watch-dependencies.js | 2 +- .../{ => data}/watch-dependencies.test.js | 0 lib/build-pages/generated-pages/index.js | 197 ++++++ lib/build-pages/index.js | 661 +----------------- .../page-output-writer.js | 0 .../page-output-writer.test.js | 0 .../{ => outputs}/page-outputs-types.test.ts | 6 +- lib/build-pages/{ => outputs}/page-outputs.js | 4 +- .../{ => outputs}/page-outputs.test.js | 0 .../{page-builders => outputs}/page-writer.js | 2 +- lib/build-pages/page-builders/html/index.js | 2 +- lib/build-pages/page-builders/js/index.js | 4 +- lib/build-pages/page-builders/md/index.js | 2 +- .../page-builders/template-builder.js | 4 +- .../page-builders/template-builder.test.js | 2 +- .../page-data-page-outputs.test.js | 4 +- lib/build-pages/page-data.js | 8 +- lib/build-pages/page-data.test.js | 2 +- lib/build-pages/worker-protocol.js | 127 ++++ lib/build-pages/worker-protocol.test.js | 187 +++++ lib/build-pages/worker.js | 3 +- lib/watch/index.js | 4 +- lib/watch/page-output-ledger.js | 2 +- lib/watch/page-output-ledger.test.js | 2 +- .../general-features/src/worker-page/page.js | 2 +- test-cases/nested-layouts/type-checks.ts | 2 +- types.ts | 12 +- 33 files changed, 940 insertions(+), 688 deletions(-) create mode 100644 lib/build-pages/build.js rename lib/build-pages/{ => data}/data-deps.js (97%) rename lib/build-pages/{ => data}/global-data-state-types.test.ts (98%) rename lib/build-pages/{ => data}/global-data-state.js (97%) rename lib/build-pages/{ => data}/global-data-state.test.js (98%) rename lib/build-pages/{ => data}/watch-dependencies.js (98%) rename lib/build-pages/{ => data}/watch-dependencies.test.js (100%) create mode 100644 lib/build-pages/generated-pages/index.js rename lib/build-pages/{page-builders => outputs}/page-output-writer.js (100%) rename lib/build-pages/{page-builders => outputs}/page-output-writer.test.js (100%) rename lib/build-pages/{ => outputs}/page-outputs-types.test.ts (95%) rename lib/build-pages/{ => outputs}/page-outputs.js (96%) rename lib/build-pages/{ => outputs}/page-outputs.test.js (100%) rename lib/build-pages/{page-builders => outputs}/page-writer.js (99%) create mode 100644 lib/build-pages/worker-protocol.js create mode 100644 lib/build-pages/worker-protocol.test.js diff --git a/docs/implementation/README.md b/docs/implementation/README.md index c4c24d88..65504a9b 100644 --- a/docs/implementation/README.md +++ b/docs/implementation/README.md @@ -167,6 +167,25 @@ Layout subscriptions contribute to page invalidation, but each layout still rece Page initialization uses a concurrency limit of `min(CPUs, 24)`. The final page and template rendering queues run in parallel, splitting that concurrency budget between them. +### Page-build module boundaries + +The page-build implementation lives in `lib/build-pages/`: + +- `index.js` launches the worker and preserves the page-build API and type exports. +- `worker.js` invokes the direct build coordinator without importing the worker-launching facade. +- `worker-protocol.js` defines transferable options and error metadata, strips generated rendering functions from error context, and restores domain errors in the parent thread. +- `build.js` coordinates preparation, global-data production, subscription-based selection, rendering, and reporting. +- `data/` keeps producer state, subscription tracking, and provider-specific data access in separate modules. +- `generated-pages/` streams factory definitions, validates output names, filters drafts, and reserves generated-page output paths. +- `outputs/` contains page writing, sidecar normalization, and sidecar persistence. +- `page-builders/` contains format adapters and the template builder. +- `page-data.js` retains the page-facing vars, layout, subscription, rendering, and output-hook interface. + +Output filters do not restrict the source-page collection supplied to the global-data producer. +Generated pages remain downstream consumers, and the worker returns candidate watch state rather than committing it. +The watch coordinator accepts that state only after a successful page build and output reconciliation. +Writes remain non-transactional, and successful page emissions are reported even when a later output hook fails. + Variable Resolution Layers, from lowest to highest precedence: - **Domstack defaults** - Internal defaults such as the default `layout: 'root'`. - **Global vars** - Site-wide variables from `global.vars.js` (resolved once). diff --git a/lib/build-pages/build.js b/lib/build-pages/build.js new file mode 100644 index 00000000..b0d6af77 --- /dev/null +++ b/lib/build-pages/build.js @@ -0,0 +1,350 @@ +/** + * @import { BuilderOptions } from './outputs/page-writer.js' + * @import { SiteData } from '../builder.js' + * @import { PageInfo, PagesFileInfo } from '../identify-pages.js' + * @import { ResolvedLayout } from './page-data.js' + * @import { WatchConsumer } from './data/watch-dependencies.js' + * @import { BuildPagesFilterOptions, WorkerBuildStepResult } from './worker-protocol.js' + */ + +import { join, resolve } from 'path' +import pMap from 'p-map' +import { cpus } from 'os' +import { keyBy } from '../helpers/key-by.js' +import { resolveVars, resolveGlobalData } from './resolve-vars.js' +import { templateBuilder } from './page-builders/index.js' +import { PageData, resolveLayout } from './page-data.js' +import { resolveLayoutChain } from './resolve-layout-chain.js' +import { pageWriter } from './outputs/page-writer.js' +import { DomStackDataError } from '../helpers/domstack-error.js' +import { WatchDependencyTracker as WatchDependencyTrackerClass } from './data/watch-dependencies.js' +import { outputWarnings } from '../helpers/output-warnings.js' +import { createGlobalDataState } from './data/global-data-state.js' +import { resolveGeneratedPageInfos } from './generated-pages/index.js' +import { pageInfoForWorker, serializeBuildError } from './worker-protocol.js' + +const MAX_CONCURRENCY = Math.min(cpus().length, 24) + +const __dirname = import.meta.dirname + +/** + * Directly build pages. Normally you run this in a worker. + * All layouts, variables and page builders need to resolve in here + * so that it can be run more than once, after the source files change. + * + * @param {string} _src + * @param {string} dest + * @param {SiteData} siteData + * @param {BuildPagesFilterOptions} [opts] + * @returns {Promise} + */ +export async function buildPagesDirect (_src, dest, siteData, opts) { + /** @type {WorkerBuildStepResult} */ + const result = { + type: 'page', + report: { + pages: [], + templates: [], + }, + outputs: [], + errors: [], + warnings: [], + } + + const outputCache = opts?.trackWatchDependencies ? new Map(opts.previousPageOutputCache) : undefined + result.report.pageOutputCache = outputCache + + const pageFilterSet = opts?.pageFilterPaths ? new Set(opts.pageFilterPaths) : null + const templateFilterSet = opts?.templateFilterPaths ? new Set(opts.templateFilterPaths) : null + const pagesFileFilterSet = opts?.pagesFileFilterPaths ? new Set(opts.pagesFileFilterPaths) : null + const fullBuild = pageFilterSet === null && templateFilterSet === null && pagesFileFilterSet === null + const watchDependencyTracker = new WatchDependencyTrackerClass( + opts?.previousWatchDependencies, + { + fullBuild, + enabled: opts?.trackWatchDependencies === true, + } + ) + + // Note: markdown-it settings are now passed directly to builders through builderOptions + + const [ + defaultVars, + bareGlobalVars, + ] = await Promise.all([ + resolveVars({ + varsPath: join(__dirname, '../defaults/default.vars.js'), + }), + resolveVars({ + varsPath: siteData?.globalVars?.filepath, + }), + ]) + + /** @type {ResolvedLayout[]} */ + const resolvedLayoutResults = await pMap(Object.values(siteData.layouts), async (layout) => { + const resolvedLayout = await resolveLayout(layout.filepath) + return { + ...resolvedLayout, + name: layout.layoutName, + layoutStylePath: layout.layoutStyle ? `/${layout.layoutStyle.outputRelname}` : null, + layoutClientPath: layout.layoutClient ? `/${layout.layoutClient.outputRelname}` : null, + } + }, { 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} */ + const globalVars = { + ...defaultVars, + ...(siteData.defaultStyle ? { defaultStyle: true } : {}), + ...bareGlobalVars, + } + if (Object.hasOwn(globalVars, 'dataDeps')) { + throw new DomStackDataError('dataDeps is page and layout metadata and cannot be declared in global vars', { + reason: 'INVALID_DECLARATION', consumer: 'Global vars', + }) + } + + // Create builder options from siteData + /** @type {BuilderOptions} */ + const builderOptions = { + markdownItSettingsPath: siteData.markdownItSettings?.filepath || null + } + + /** + * @param {PageInfo} pageInfo + */ + const initPageData = async (pageInfo) => { + const pageData = new PageData({ + pageInfo, + globalVars, + globalStyle: siteData?.globalStyle?.outputRelname, + globalClient: siteData?.globalClient?.outputRelname, + defaultStyle: siteData?.defaultStyle, + defaultClient: siteData?.defaultClient, + builderOptions, + }) + try { + // Resolves async vars and binds the page to a reference to its layout fn + await pageData.init({ layouts: resolvedLayouts }) + } catch (err) { + result.errors.push(serializeBuildError(err, { page: pageInfoForWorker(pageInfo) }, 'Error resolving page vars')) + } + result.warnings.push(...pageData.warnings) + return pageData + } + + // Mix in resolveVars, renderInnerPage and renderFullPage methods for concrete pages. + const concretePages = await pMap(siteData.pages, pageInfo => { + const filepath = resolve(pageInfo.pageFile.filepath) + return initPageData(filepath === pageInfo.pageFile.filepath + ? pageInfo + : { ...pageInfo, pageFile: { ...pageInfo.pageFile, filepath } }) + }, { concurrency: MAX_CONCURRENCY }) + + if (result.errors.length > 0) return result + + // Derive collection data from source-backed pages before generated-page factories run. + // This keeps generated pages downstream while making shared data available to them. + const globalDataState = siteData.globalData + ? createGlobalDataState({ + pages: concretePages, + previousGlobalDataBaseline: opts?.previousGlobalDataBaseline, + globalDataInputChanges: opts?.globalDataInputChanges, + }) + : null + const globalData = /** @type {Record} */ (globalDataState + ? await resolveGlobalData({ + globalDataPath: siteData.globalData?.filepath, + context: globalDataState.context, + }) + : {}) + const changedGlobalDataKeys = watchDependencyTracker.updateGlobalDataFingerprints( + globalData, + opts?.previousWatchDependencies?.globalDataFingerprints + ) + + for (const page of concretePages) { + page.setGlobalData(globalData) + watchDependencyTracker.registerConsumer( + 'page', + page.pageInfo.pageFile.filepath, + page.dataDeps + ) + } + + if (!fullBuild) { + applyInvalidatedConsumerFilters({ + consumers: watchDependencyTracker.getInvalidatedConsumers( + opts?.previousWatchDependencies, + changedGlobalDataKeys + ), + pageFilterSet, + templateFilterSet, + pagesFileFilterSet, + }) + } + + /** @type {PageData[]} */ + const pagesToWrite = [] + + for (const page of concretePages) { + if (!pageFilterSet || pageFilterSet.has(page.pageInfo.pageFile.filepath)) { + pagesToWrite.push(page) + } + } + + /** @type {[number, number]} Divided concurrency values */ + const dividedConcurrency = MAX_CONCURRENCY % 2 + ? [((MAX_CONCURRENCY - 1) / 2) + 1, (MAX_CONCURRENCY - 1) / 2] // odd + : [MAX_CONCURRENCY / 2, MAX_CONCURRENCY / 2] // even + + const templatesToRender = templateFilterSet + ? siteData.templates.filter(t => templateFilterSet.has(t.templateFile.filepath)) + : siteData.templates + if (opts?.trackWatchDependencies) { + result.report.rebuiltPagesFilePaths = pagesFileFilterSet + ? Array.from(pagesFileFilterSet) + : (siteData.pagesFiles ?? []).map(pagesFile => pagesFile.pagesFile.filepath) + } + + /** @param {PageData} page */ + const writePage = async (page) => { + try { + const buildResult = await pageWriter({ + dest, + page, + outputCache, + }) + + result.report.pages.push({ + pageFilePath: buildResult.pageFilePath, + sourcePageFilePath: page.pageInfo.generated ? undefined : page.pageInfo.pageFile.filepath, + pagesFilePath: page.pageInfo.generated?.pagesFile.pagesFile.filepath, + layoutName: page.layout?.name, + layoutNames: page.layoutChain.map(layout => layout.name), + outputs: buildResult.outputs, + }) + result.outputs.push(...buildResult.outputs) + return true + } catch (err) { + // Direct writes already emitted by a failed iterator still need ownership + // so a later successful watch rebuild can remove them. + if (page.outputRecords.length > 0) { + result.report.pages.push({ + pageFilePath: join(dest, page.pageInfo.outputRelname), + sourcePageFilePath: page.pageInfo.generated ? undefined : page.pageInfo.pageFile.filepath, + pagesFilePath: page.pageInfo.generated?.pagesFile.pagesFile.filepath, + layoutName: page.layout?.name, + layoutNames: page.layoutChain.map(layout => layout.name), + outputs: page.outputRecords, + }) + result.outputs.push(...page.outputRecords) + } + result.errors.push(serializeBuildError(err, { page: pageInfoForWorker(page.pageInfo) }, `Error building page "${page.pageInfo.pageFile.relname}"`)) + return false + } + } + + // Keep output names for dependency pruning, not generated definitions or PageData instances. + const generatedOutputRelnames = new Set() + const writeGeneratedPages = async () => { + try { + for await (const pageInfo of resolveGeneratedPageInfos({ + siteData, + factoryVars: globalVars, + globalData, + pagesFileFilterSet, + buildDrafts: opts?.buildDrafts, + watchDependencyTracker, + })) { + const errorCount = result.errors.length + const page = await initPageData(pageInfo) + if (result.errors.length > errorCount) break + page.setGlobalData(globalData) + watchDependencyTracker.registerConsumer( + 'page', + pageInfo.outputRelname, + page.dataDeps, + { ownerPath: pageInfo.pageFile.filepath } + ) + if (!await writePage(page)) break + generatedOutputRelnames.add(pageInfo.outputRelname) + } + } catch (err) { + const pagesFile = /** @type {PagesFileInfo | undefined} */ (err instanceof Error && 'pagesFile' in err ? err.pagesFile : undefined) + result.errors.push(serializeBuildError(err, { pagesFile }, `Error building generated pages: ${err instanceof Error ? err.message : String(err)}`)) + } + } + + await Promise.all([ + pMap(pagesToWrite, writePage, { concurrency: dividedConcurrency[0] }), + writeGeneratedPages(), + pMap(templatesToRender, async (template) => { + try { + const buildResult = await templateBuilder({ + dest, + globalVars, + globalData, + template, + watchDependencyTracker, + }) + + result.report.templates.push(buildResult.report) + result.outputs.push(...buildResult.outputs) + } catch (err) { + result.errors.push(serializeBuildError(err, { template }, 'Error building template')) + } + }, { concurrency: dividedConcurrency[1] }), + ]) + + if (opts?.trackWatchDependencies) { + result.warnings.push(...outputWarnings(result.outputs)) + watchDependencyTracker.pruneGeneratedPages( + generatedOutputRelnames, + pagesFileFilterSet + ) + result.report.watchDependencies = watchDependencyTracker.state + } + if (opts?.trackWatchDependencies && result.errors.length === 0 && globalDataState) { + result.report.globalDataBaseline = globalDataState.getBaseline() + } + return result +} + +/** + * Add invalidated consumers to the mutable filters for a targeted build. + * + * @param {object} params + * @param {WatchConsumer[]} params.consumers + * @param {Set | null} params.pageFilterSet + * @param {Set | null} params.templateFilterSet + * @param {Set | null} params.pagesFileFilterSet + */ +function applyInvalidatedConsumerFilters ({ + consumers, + pageFilterSet, + templateFilterSet, + pagesFileFilterSet, +}) { + for (const consumer of consumers) { + if (consumer.type === 'template') { + templateFilterSet?.add(consumer.key) + continue + } + if (consumer.type === 'pages-file') { + pagesFileFilterSet?.add(consumer.key) + continue + } + if (consumer.type !== 'page') continue + + if (consumer.ownerPath) { + pagesFileFilterSet?.add(consumer.ownerPath) + continue + } + + pageFilterSet?.add(consumer.key) + } +} diff --git a/lib/build-pages/data-deps.js b/lib/build-pages/data/data-deps.js similarity index 97% rename from lib/build-pages/data-deps.js rename to lib/build-pages/data/data-deps.js index d33802d1..1bc1fa91 100644 --- a/lib/build-pages/data-deps.js +++ b/lib/build-pages/data/data-deps.js @@ -1,4 +1,4 @@ -import { DomStackDataError } from '../helpers/domstack-error.js' +import { DomStackDataError } from '../../helpers/domstack-error.js' /** * A readonly subscription list checked against a consumer's data contract. diff --git a/lib/build-pages/global-data-state-types.test.ts b/lib/build-pages/data/global-data-state-types.test.ts similarity index 98% rename from lib/build-pages/global-data-state-types.test.ts rename to lib/build-pages/data/global-data-state-types.test.ts index 1ce600f9..1ac6fe20 100644 --- a/lib/build-pages/global-data-state-types.test.ts +++ b/lib/build-pages/data/global-data-state-types.test.ts @@ -1,4 +1,4 @@ -import type { AsyncGlobalDataFunction, GlobalDataFunction } from '../../types.ts' +import type { AsyncGlobalDataFunction, GlobalDataFunction } from '../../../types.ts' type Vars = { title: string } type Data = { titles: string[] } diff --git a/lib/build-pages/global-data-state.js b/lib/build-pages/data/global-data-state.js similarity index 97% rename from lib/build-pages/global-data-state.js rename to lib/build-pages/data/global-data-state.js index f4e6a952..bd3fed78 100644 --- a/lib/build-pages/global-data-state.js +++ b/lib/build-pages/data/global-data-state.js @@ -1,7 +1,7 @@ /** - * @import { PageData } from './page-data.js' - * @import { GlobalDataFunctionParams } from './index.js' - * @import { WatchEvent } from '../watch/plan.js' + * @import { PageData } from '../page-data.js' + * @import { GlobalDataFunctionParams } from '../index.js' + * @import { WatchEvent } from '../../watch/plan.js' */ import { resolve } from 'node:path' import { BlockList } from 'node:net' diff --git a/lib/build-pages/global-data-state.test.js b/lib/build-pages/data/global-data-state.test.js similarity index 98% rename from lib/build-pages/global-data-state.test.js rename to lib/build-pages/data/global-data-state.test.js index ef59a7b9..e969626a 100644 --- a/lib/build-pages/global-data-state.test.js +++ b/lib/build-pages/data/global-data-state.test.js @@ -9,10 +9,10 @@ import { join } from 'node:path' import { BlockList } from 'node:net' import { createHistogram, monitorEventLoopDelay } from 'node:perf_hooks' import { createGlobalDataState } from './global-data-state.js' -import { buildPages, buildPagesDirect } from './index.js' -import { identifyPages } from '../identify-pages.js' -import { classifyWatchEvent } from '../watch/plan.js' -import { resolveGlobalData } from './resolve-vars.js' +import { buildPages, buildPagesDirect } from '../index.js' +import { identifyPages } from '../../identify-pages.js' +import { classifyWatchEvent } from '../../watch/plan.js' +import { resolveGlobalData } from '../resolve-vars.js' /** @param {TestContext} t @param {string} producer */ async function fixture (t, producer) { diff --git a/lib/build-pages/watch-dependencies.js b/lib/build-pages/data/watch-dependencies.js similarity index 98% rename from lib/build-pages/watch-dependencies.js rename to lib/build-pages/data/watch-dependencies.js index 91c5eb29..bb26578a 100644 --- a/lib/build-pages/watch-dependencies.js +++ b/lib/build-pages/data/watch-dependencies.js @@ -1,5 +1,5 @@ import { createHash } from 'node:crypto' -import { stableJsonStringify } from '../helpers/stable-json-stringify.js' +import { stableJsonStringify } from '../../helpers/stable-json-stringify.js' /** * @typedef {'page' | 'template' | 'pages-file'} WatchConsumerType diff --git a/lib/build-pages/watch-dependencies.test.js b/lib/build-pages/data/watch-dependencies.test.js similarity index 100% rename from lib/build-pages/watch-dependencies.test.js rename to lib/build-pages/data/watch-dependencies.test.js diff --git a/lib/build-pages/generated-pages/index.js b/lib/build-pages/generated-pages/index.js new file mode 100644 index 00000000..19eb7985 --- /dev/null +++ b/lib/build-pages/generated-pages/index.js @@ -0,0 +1,197 @@ +/** + * @import { SiteData } from '../../builder.js' + * @import { PageInfo, PagesFileInfo } from '../../identify-pages.js' + * @import { GeneratedPageDefinition } from '../index.js' + * @import { WatchDependencyTracker } from '../data/watch-dependencies.js' + */ + +import { basename, dirname, isAbsolute, join, normalize, resolve } from 'path' +import { computePageUrl } from '../compute-page-url.js' +import { DomStackOutputConflictError } from '../../helpers/domstack-error.js' +import { isAsyncIterable, isPlainObject } from '../../helpers/type-guards.js' +import { createSubscribedData, resolveDataDeps } from '../data/data-deps.js' + +/** + * @param {unknown} value + * @returns {GeneratedPageDefinition} + */ +function validateGeneratedPageDefinition (value) { + if (!isPlainObject(value)) { + throw new TypeError('Generated page definition must be an object') + } + + if ('outputName' in value && value['outputName'] !== undefined && typeof value['outputName'] !== 'string') { + throw new TypeError('Generated page outputName must be a string') + } + if ('vars' in value && value['vars'] !== undefined && !isPlainObject(value['vars'])) { + throw new TypeError('Generated page vars must be an object') + } + if ('draft' in value && value['draft'] !== undefined && typeof value['draft'] !== 'boolean') { + throw new TypeError('Generated page draft must be a boolean') + } + + return /** @type {GeneratedPageDefinition} */ (value) +} + +/** + * @param {unknown} value + * @returns {AsyncGenerator} + */ +async function * iterateGeneratedPageDefinitions (value) { + if (value == null) return + + if (Array.isArray(value) || isAsyncIterable(value)) { + for await (const definition of value) yield validateGeneratedPageDefinition(definition) + } else { + yield validateGeneratedPageDefinition(value) + } +} + +/** + * @param {string} value + * @param {object} opts + * @param {string} opts.field + * @param {boolean} [opts.allowEmpty] + * @returns {string} + */ +function normalizeGeneratedOutputPart (value, { field, allowEmpty = false }) { + if (typeof value !== 'string') throw new TypeError(`Generated page ${field} must be a string`) + if (!allowEmpty && value.length === 0) throw new Error(`Generated page ${field} must not be empty`) + if (isAbsolute(value) || /^[A-Za-z]:[\\/]/.test(value)) throw new Error(`Generated page ${field} must be relative: ${value}`) + if (value.split(/[\\/]+/).includes('..')) throw new Error(`Generated page ${field} must not contain ".." segments: ${value}`) + if (/[\\/]$/.test(value)) throw new Error(`Generated page ${field} must name a file: ${value}`) + + const normalized = normalize(value) + if (!allowEmpty && normalized === '.') throw new Error(`Generated page ${field} must not be empty`) + return normalized === '.' ? '' : normalized +} + +/** + * @param {object} params + * @param {GeneratedPageDefinition} params.definition + * @param {PagesFileInfo} params.pagesFile + * @param {number} params.index + * @returns {PageInfo} + */ +function generatedDefinitionToPageInfo ({ definition, pagesFile, index }) { + const relativeOutputName = normalizeGeneratedOutputPart(definition.outputName ?? `${pagesFile.name}/index.html`, { field: 'outputName' }) + const outputRelname = join(pagesFile.path, relativeOutputName) + const generatedPath = dirname(outputRelname) === '.' ? '' : dirname(outputRelname) + const outputName = basename(outputRelname) + + return { + pageFile: { + ...pagesFile.pagesFile, + basename: `${pagesFile.pagesFile.basename}#${index}`, + relname: `${pagesFile.pagesFile.relname}#${index}`, + type: 'js', + }, + type: 'js', + path: generatedPath, + url: computePageUrl({ path: generatedPath, outputName }), + outputName, + outputRelname, + draft: Boolean(definition.draft), + generated: { + pagesFile, + vars: definition.vars ?? {}, + children: definition.children, + }, + } +} + +/** + * @param {object} params + * @param {SiteData} params.siteData + * @param {Record} params.factoryVars + * @param {Record} params.globalData + * @param {Set | null} params.pagesFileFilterSet + * @param {boolean | undefined} params.buildDrafts + * @param {WatchDependencyTracker} params.watchDependencyTracker + * @returns {AsyncGenerator} + */ +export async function * resolveGeneratedPageInfos ({ siteData, factoryVars, globalData, pagesFileFilterSet, buildDrafts, watchDependencyTracker }) { + /** @type {Map} */ + const pageOutputClaims = new Map() + + for (const pageInfo of siteData.pages) { + pageOutputClaims.set(resolve(pageInfo.outputRelname), { + type: 'page', + path: pageInfo.pageFile.relname, + }) + } + + // Unselected factories keep their outputs. Reserve those paths without + // rerunning the owners, so a targeted build cannot silently overwrite them. + if (pagesFileFilterSet) { + const ownerRelnames = new Map((siteData.pagesFiles ?? []).map(({ pagesFile }) => [pagesFile.filepath, pagesFile.relname])) + for (const consumer of Object.values(watchDependencyTracker.state.consumers)) { + if (consumer.type === 'page' && consumer.ownerPath && !pagesFileFilterSet.has(consumer.ownerPath)) { + pageOutputClaims.set(resolve(consumer.key), { type: 'page', path: ownerRelnames.get(consumer.ownerPath) ?? consumer.key }) + } + } + } + + for (const pagesFile of siteData.pagesFiles ?? []) { + if (pagesFileFilterSet && !pagesFileFilterSet.has(pagesFile.pagesFile.filepath)) continue + + try { + const importResults = await import(pagesFile.pagesFile.filepath) + if (!('default' in importResults)) throw new Error(`Missing default export from pages file: ${pagesFile.pagesFile.relname}`) + + const pagesExport = importResults.default + const dataDeps = resolveDataDeps( + importResults.dataDeps, + `Pages file "${pagesFile.pagesFile.relname}"` + ) + watchDependencyTracker.registerConsumer( + 'pages-file', + pagesFile.pagesFile.filepath, + dataDeps + ) + const pagesResults = typeof pagesExport === 'function' + ? await pagesExport({ + vars: factoryVars, + data: createSubscribedData( + globalData, + dataDeps, + `Pages file "${pagesFile.pagesFile.relname}"` + ), + pagesFile, + }) + : pagesExport + + let index = 0 + for await (const definition of iterateGeneratedPageDefinitions(pagesResults)) { + const generatedPageInfo = generatedDefinitionToPageInfo({ definition, pagesFile, index: index++ }) + if (generatedPageInfo.draft && !buildDrafts) continue + + const outputKey = resolve(generatedPageInfo.outputRelname) + const existingClaim = pageOutputClaims.get(outputKey) + const generatedClaim = { + type: /** @type {const} */ ('page'), + path: generatedPageInfo.pageFile.relname, + } + if (existingClaim) { + throw new DomStackOutputConflictError( + `Output path conflict: ${generatedPageInfo.outputRelname} is produced by both ${existingClaim.path} and ${generatedClaim.path}.`, + { + outputPath: generatedPageInfo.outputRelname, + a: existingClaim, + b: generatedClaim, + } + ) + } + + pageOutputClaims.set(outputKey, generatedClaim) + yield generatedPageInfo + } + } catch (err) { + const error = err instanceof Error + ? err + : new Error('Non-error thrown while resolving generated pages', { cause: err }) + Object.assign(error, { pagesFile }) + throw error + } + } +} diff --git a/lib/build-pages/index.js b/lib/build-pages/index.js index 29351b90..58ca1fe1 100644 --- a/lib/build-pages/index.js +++ b/lib/build-pages/index.js @@ -1,34 +1,23 @@ /** - * @import { BuilderOptions, PageFunction } from './page-builders/page-writer.js' + * @import { PageFunction } from './outputs/page-writer.js' * @import { TemplateReport } from './page-builders/template-builder.js' - * @import { BuildStep, SiteData, DomStackOpts } from '../builder.js' - * @import { PageInfo, TemplateInfo, PagesFileInfo } from '../identify-pages.js' - * @import { ResolvedLayout } from './page-data.js' + * @import { BuildStep, DomStackOpts } from '../builder.js' + * @import { PagesFileInfo } from '../identify-pages.js' + * @import { PageData } from './page-data.js' * @import { DomstackManifestRecord } from '../domstack-manifest/index.js' - * @import { WatchDependencyState, WatchConsumer, WatchDependencyTracker } from './watch-dependencies.js' - * @import { PageOutputCache } from './page-builders/page-output-writer.js' - * @import { GlobalDataBaseline, GlobalDataInputChanges, GlobalDataChanges } from './global-data-state.js' + * @import { WatchDependencyState } from './data/watch-dependencies.js' + * @import { PageOutputCache } from './outputs/page-output-writer.js' + * @import { GlobalDataBaseline, GlobalDataChanges } from './data/global-data-state.js' + * @import { BuildPagesFilterOptions as WorkerBuildPagesFilterOptions, WorkerErrorData as ProtocolWorkerErrorData, WorkerBuildStepResult as ProtocolWorkerBuildStepResult } from './worker-protocol.js' */ import { Worker } from 'worker_threads' -import { basename, dirname, isAbsolute, join, normalize, resolve } from 'path' -import pMap from 'p-map' -import { cpus } from 'os' -import { keyBy } from '../helpers/key-by.js' -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 { DomStackDataError, DomStackOutputConflictError } from '../helpers/domstack-error.js' -import { isAsyncIterable, isPlainObject } from '../helpers/type-guards.js' -import { createSubscribedData, resolveDataDeps } from './data-deps.js' -import { WatchDependencyTracker as WatchDependencyTrackerClass } from './watch-dependencies.js' -import { outputWarnings } from '../helpers/output-warnings.js' -import { createGlobalDataState } from './global-data-state.js' +import { join } from 'path' +import { restoreWorkerError } from './worker-protocol.js' -const MAX_CONCURRENCY = Math.min(cpus().length, 24) +export { buildPagesDirect } from './build.js' +export { serializeBuildError } from './worker-protocol.js' +export { pageBuilders } from './page-builders/index.js' const __dirname = import.meta.dirname @@ -91,19 +80,7 @@ const __dirname = import.meta.dirname */ /** - * Internal options sent to the page worker. - * Uses arrays (not Sets) so the values can be copied to the worker. - * - * @typedef {object} BuildPagesFilterOptions - * @property {string[] | null | undefined} [pageFilterPaths] - If set, only rebuild pages whose pageFile.filepath is in this list. - * @property {string[] | null | undefined} [templateFilterPaths] - If set, only rebuild templates whose templateFile.filepath is in this list. - * @property {string[] | null | undefined} [pagesFileFilterPaths] - If set, only rebuild generated pages owned by these *.pages.* filepaths. - * @property {boolean | undefined} [buildDrafts] - Include generated page definitions marked as drafts. - * @property {WatchDependencyState | null | undefined} [previousWatchDependencies] - Dependency state from the previous successful watch build. - * @property {boolean | undefined} [trackWatchDependencies] - Collect subscriptions for incremental watch builds. - * @property {PageOutputCache | undefined} [previousPageOutputCache] - Successful output hashes and metadata retained across watch workers. - * @property {GlobalDataBaseline | null | undefined} [previousGlobalDataBaseline] - * @property {GlobalDataInputChanges | undefined} [globalDataInputChanges] + * @typedef {WorkerBuildPagesFilterOptions} BuildPagesFilterOptions */ /** @@ -170,293 +147,9 @@ const __dirname = import.meta.dirname */ /** - * Error metadata sent back from the page build worker. - * @typedef {object} WorkerErrorData - * @property {PageInfo | undefined} [page] - Page context for page var/rendering errors. - * @property {TemplateInfo | undefined} [template] - Template context for template rendering errors. - * @property {PagesFileInfo | undefined} [pagesFile] - Pages-file context for generated page resolution errors. - * @property {DomStackOutputConflictError['code'] | DomStackDataError['code'] | undefined} [code] - Stable domain error code. - * @property {DomStackOutputConflictError['conflict'] | undefined} [conflict] - Generated-page conflict details. - * @property {DomStackDataError['dataDependency'] | undefined} [dataDependency] - Subscription error details. - */ - -/** - * @typedef {Omit & { errors: {error: Error, errorData?: WorkerErrorData}[] }} WorkerBuildStepResult - */ - -export { pageBuilders } - -/** - * Remove generated vars and rendering functions before returning page error - * information from the worker. Concrete PageInfo objects are already copyable. - * - * @param {PageInfo} pageInfo - * @returns {PageInfo} - */ -function pageInfoForWorker (pageInfo) { - if (!pageInfo.generated) return pageInfo - return { - ...pageInfo, - generated: { pagesFile: pageInfo.generated.pagesFile }, - } -} - -/** - * @param {WorkerErrorData} errorData - * @returns {{ type: 'page' | 'template' | 'pages file', path: string } | null} - */ -function getWorkerErrorContext (errorData) { - if (errorData.page) { - const pagePath = errorData.page.path || errorData.page.url || errorData.page.pageFile.relname - return { type: 'page', path: pagePath } - } - - if (errorData.template) { - const templatePath = errorData.template.path || errorData.template.templateFile.relname - return { type: 'template', path: templatePath } - } - - if (errorData.pagesFile) { - return { type: 'pages file', path: errorData.pagesFile.pagesFile.relname } - } - - return null -} - -/** - * @param {Error} error - * @param {WorkerErrorData} errorData - * @returns {Error} - */ -function restoreWorkerError (error, errorData) { - const context = getWorkerErrorContext(errorData) - const message = context - ? `${error.message} (${context.type}: "${context.path}")` - : error.message - const restoredError = errorData.dataDependency - ? new DomStackDataError(message, errorData.dataDependency, { cause: error.cause }) - : new Error(message, { cause: error.cause }) - if (!(restoredError instanceof DomStackDataError)) restoredError.name = error.name - - if (error.stack) { - restoredError.stack = error.stack.replace(error.message, restoredError.message) - } - - const { code, ...contextData } = errorData - Object.assign(restoredError, contextData) - if (!(restoredError instanceof DomStackDataError) && code) Object.assign(restoredError, { code }) - - return restoredError -} - -/** - * Preserve domain metadata separately because worker cloning strips Error fields. - * @param {unknown} err - * @param {WorkerErrorData} [context] - * @param {string} [message] - * @returns {WorkerBuildStepResult['errors'][number]} - */ -export function serializeBuildError (err, context = {}, message) { - const error = err instanceof Error ? err : new Error('Non-error thrown during page build', { cause: err }) - const errorData = { ...context } - if (error instanceof DomStackDataError) { - errorData.code = error.code - errorData.dataDependency = error.dataDependency - } else if (error instanceof DomStackOutputConflictError) { - errorData.code = error.code - errorData.conflict = error.conflict - } - const reportedError = message && !errorData.code - ? new Error(message, { cause: { message: error.message, stack: error.stack } }) - : error - reportedError.name = error.name - return { error: reportedError, errorData } -} - -/** - * @param {unknown} value - * @returns {GeneratedPageDefinition} - */ -function validateGeneratedPageDefinition (value) { - if (!isPlainObject(value)) { - throw new TypeError('Generated page definition must be an object') - } - - if ('outputName' in value && value['outputName'] !== undefined && typeof value['outputName'] !== 'string') { - throw new TypeError('Generated page outputName must be a string') - } - if ('vars' in value && value['vars'] !== undefined && !isPlainObject(value['vars'])) { - throw new TypeError('Generated page vars must be an object') - } - if ('draft' in value && value['draft'] !== undefined && typeof value['draft'] !== 'boolean') { - throw new TypeError('Generated page draft must be a boolean') - } - - return /** @type {GeneratedPageDefinition} */ (value) -} - -/** - * @param {unknown} value - * @returns {AsyncGenerator} - */ -async function * iterateGeneratedPageDefinitions (value) { - if (value == null) return - - if (Array.isArray(value) || isAsyncIterable(value)) { - for await (const definition of value) yield validateGeneratedPageDefinition(definition) - } else { - yield validateGeneratedPageDefinition(value) - } -} - -/** - * @param {string} value - * @param {object} opts - * @param {string} opts.field - * @param {boolean} [opts.allowEmpty] - * @returns {string} + * @typedef {ProtocolWorkerErrorData} WorkerErrorData + * @typedef {ProtocolWorkerBuildStepResult} WorkerBuildStepResult */ -function normalizeGeneratedOutputPart (value, { field, allowEmpty = false }) { - if (typeof value !== 'string') throw new TypeError(`Generated page ${field} must be a string`) - if (!allowEmpty && value.length === 0) throw new Error(`Generated page ${field} must not be empty`) - if (isAbsolute(value) || /^[A-Za-z]:[\\/]/.test(value)) throw new Error(`Generated page ${field} must be relative: ${value}`) - if (value.split(/[\\/]+/).includes('..')) throw new Error(`Generated page ${field} must not contain ".." segments: ${value}`) - if (/[\\/]$/.test(value)) throw new Error(`Generated page ${field} must name a file: ${value}`) - - const normalized = normalize(value) - if (!allowEmpty && normalized === '.') throw new Error(`Generated page ${field} must not be empty`) - return normalized === '.' ? '' : normalized -} - -/** - * @param {object} params - * @param {GeneratedPageDefinition} params.definition - * @param {PagesFileInfo} params.pagesFile - * @param {number} params.index - * @returns {PageInfo} - */ -function generatedDefinitionToPageInfo ({ definition, pagesFile, index }) { - const relativeOutputName = normalizeGeneratedOutputPart(definition.outputName ?? `${pagesFile.name}/index.html`, { field: 'outputName' }) - const outputRelname = join(pagesFile.path, relativeOutputName) - const generatedPath = dirname(outputRelname) === '.' ? '' : dirname(outputRelname) - const outputName = basename(outputRelname) - - return { - pageFile: { - ...pagesFile.pagesFile, - basename: `${pagesFile.pagesFile.basename}#${index}`, - relname: `${pagesFile.pagesFile.relname}#${index}`, - type: 'js', - }, - type: 'js', - path: generatedPath, - url: computePageUrl({ path: generatedPath, outputName }), - outputName, - outputRelname, - draft: Boolean(definition.draft), - generated: { - pagesFile, - vars: definition.vars ?? {}, - children: definition.children, - }, - } -} - -/** - * @param {object} params - * @param {SiteData} params.siteData - * @param {Record} params.factoryVars - * @param {Record} params.globalData - * @param {Set | null} params.pagesFileFilterSet - * @param {boolean | undefined} params.buildDrafts - * @param {WatchDependencyTracker} params.watchDependencyTracker - * @returns {AsyncGenerator} - */ -async function * resolveGeneratedPageInfos ({ siteData, factoryVars, globalData, pagesFileFilterSet, buildDrafts, watchDependencyTracker }) { - /** @type {Map} */ - const pageOutputClaims = new Map() - - for (const pageInfo of siteData.pages) { - pageOutputClaims.set(resolve(pageInfo.outputRelname), { - type: 'page', - path: pageInfo.pageFile.relname, - }) - } - - // Unselected factories keep their outputs. Reserve those paths without - // rerunning the owners, so a targeted build cannot silently overwrite them. - if (pagesFileFilterSet) { - const ownerRelnames = new Map((siteData.pagesFiles ?? []).map(({ pagesFile }) => [pagesFile.filepath, pagesFile.relname])) - for (const consumer of Object.values(watchDependencyTracker.state.consumers)) { - if (consumer.type === 'page' && consumer.ownerPath && !pagesFileFilterSet.has(consumer.ownerPath)) { - pageOutputClaims.set(resolve(consumer.key), { type: 'page', path: ownerRelnames.get(consumer.ownerPath) ?? consumer.key }) - } - } - } - - for (const pagesFile of siteData.pagesFiles ?? []) { - if (pagesFileFilterSet && !pagesFileFilterSet.has(pagesFile.pagesFile.filepath)) continue - - try { - const importResults = await import(pagesFile.pagesFile.filepath) - if (!('default' in importResults)) throw new Error(`Missing default export from pages file: ${pagesFile.pagesFile.relname}`) - - const pagesExport = importResults.default - const dataDeps = resolveDataDeps( - importResults.dataDeps, - `Pages file "${pagesFile.pagesFile.relname}"` - ) - watchDependencyTracker.registerConsumer( - 'pages-file', - pagesFile.pagesFile.filepath, - dataDeps - ) - const pagesResults = typeof pagesExport === 'function' - ? await pagesExport({ - vars: factoryVars, - data: createSubscribedData( - globalData, - dataDeps, - `Pages file "${pagesFile.pagesFile.relname}"` - ), - pagesFile, - }) - : pagesExport - - let index = 0 - for await (const definition of iterateGeneratedPageDefinitions(pagesResults)) { - const generatedPageInfo = generatedDefinitionToPageInfo({ definition, pagesFile, index: index++ }) - if (generatedPageInfo.draft && !buildDrafts) continue - - const outputKey = resolve(generatedPageInfo.outputRelname) - const existingClaim = pageOutputClaims.get(outputKey) - const generatedClaim = { - type: /** @type {const} */ ('page'), - path: generatedPageInfo.pageFile.relname, - } - if (existingClaim) { - throw new DomStackOutputConflictError( - `Output path conflict: ${generatedPageInfo.outputRelname} is produced by both ${existingClaim.path} and ${generatedClaim.path}.`, - { - outputPath: generatedPageInfo.outputRelname, - a: existingClaim, - b: generatedClaim, - } - ) - } - - pageOutputClaims.set(outputKey, generatedClaim) - yield generatedPageInfo - } - } catch (err) { - const error = err instanceof Error - ? err - : new Error('Non-error thrown while resolving generated pages', { cause: err }) - Object.assign(error, { pagesFile }) - throw error - } - } -} /** * Page builder glue. Most of the magic happens in the builders. @@ -511,325 +204,3 @@ export function buildPages (src, dest, siteData, opts) { }) }) } - -/** - * Directly build pages. Normally you run this in a worker. - * All layouts, variables and page builders need to resolve in here - * so that it can be run more than once, after the source files change. - * - * @param {string} _src - * @param {string} dest - * @param {SiteData} siteData - * @param {BuildPagesFilterOptions} [opts] - * @returns {Promise} - */ -export async function buildPagesDirect (_src, dest, siteData, opts) { - /** @type {WorkerBuildStepResult} */ - const result = { - type: 'page', - report: { - pages: [], - templates: [], - }, - outputs: [], - errors: [], - warnings: [], - } - - const outputCache = opts?.trackWatchDependencies ? new Map(opts.previousPageOutputCache) : undefined - result.report.pageOutputCache = outputCache - - const pageFilterSet = opts?.pageFilterPaths ? new Set(opts.pageFilterPaths) : null - const templateFilterSet = opts?.templateFilterPaths ? new Set(opts.templateFilterPaths) : null - const pagesFileFilterSet = opts?.pagesFileFilterPaths ? new Set(opts.pagesFileFilterPaths) : null - const fullBuild = pageFilterSet === null && templateFilterSet === null && pagesFileFilterSet === null - const watchDependencyTracker = new WatchDependencyTrackerClass( - opts?.previousWatchDependencies, - { - fullBuild, - enabled: opts?.trackWatchDependencies === true, - } - ) - - // Note: markdown-it settings are now passed directly to builders through builderOptions - - const [ - defaultVars, - bareGlobalVars, - ] = await Promise.all([ - resolveVars({ - varsPath: join(__dirname, '../defaults/default.vars.js'), - }), - resolveVars({ - varsPath: siteData?.globalVars?.filepath, - }), - ]) - - /** @type {ResolvedLayout[]} */ - const resolvedLayoutResults = await pMap(Object.values(siteData.layouts), async (layout) => { - const resolvedLayout = await resolveLayout(layout.filepath) - return { - ...resolvedLayout, - name: layout.layoutName, - layoutStylePath: layout.layoutStyle ? `/${layout.layoutStyle.outputRelname}` : null, - layoutClientPath: layout.layoutClient ? `/${layout.layoutClient.outputRelname}` : null, - } - }, { 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} */ - const globalVars = { - ...defaultVars, - ...(siteData.defaultStyle ? { defaultStyle: true } : {}), - ...bareGlobalVars, - } - if (Object.hasOwn(globalVars, 'dataDeps')) { - throw new DomStackDataError('dataDeps is page and layout metadata and cannot be declared in global vars', { - reason: 'INVALID_DECLARATION', consumer: 'Global vars', - }) - } - - // Create builder options from siteData - /** @type {BuilderOptions} */ - const builderOptions = { - markdownItSettingsPath: siteData.markdownItSettings?.filepath || null - } - - /** - * @param {PageInfo} pageInfo - */ - const initPageData = async (pageInfo) => { - const pageData = new PageData({ - pageInfo, - globalVars, - globalStyle: siteData?.globalStyle?.outputRelname, - globalClient: siteData?.globalClient?.outputRelname, - defaultStyle: siteData?.defaultStyle, - defaultClient: siteData?.defaultClient, - builderOptions, - }) - try { - // Resolves async vars and binds the page to a reference to its layout fn - await pageData.init({ layouts: resolvedLayouts }) - } catch (err) { - result.errors.push(serializeBuildError(err, { page: pageInfoForWorker(pageInfo) }, 'Error resolving page vars')) - } - result.warnings.push(...pageData.warnings) - return pageData - } - - // Mix in resolveVars, renderInnerPage and renderFullPage methods for concrete pages. - const concretePages = await pMap(siteData.pages, pageInfo => { - const filepath = resolve(pageInfo.pageFile.filepath) - return initPageData(filepath === pageInfo.pageFile.filepath - ? pageInfo - : { ...pageInfo, pageFile: { ...pageInfo.pageFile, filepath } }) - }, { concurrency: MAX_CONCURRENCY }) - - if (result.errors.length > 0) return result - - // Derive collection data from source-backed pages before generated-page factories run. - // This keeps generated pages downstream while making shared data available to them. - const globalDataState = siteData.globalData - ? createGlobalDataState({ - pages: concretePages, - previousGlobalDataBaseline: opts?.previousGlobalDataBaseline, - globalDataInputChanges: opts?.globalDataInputChanges, - }) - : null - const globalData = /** @type {Record} */ (globalDataState - ? await resolveGlobalData({ - globalDataPath: siteData.globalData?.filepath, - context: globalDataState.context, - }) - : {}) - const changedGlobalDataKeys = watchDependencyTracker.updateGlobalDataFingerprints( - globalData, - opts?.previousWatchDependencies?.globalDataFingerprints - ) - - for (const page of concretePages) { - page.setGlobalData(globalData) - watchDependencyTracker.registerConsumer( - 'page', - page.pageInfo.pageFile.filepath, - page.dataDeps - ) - } - - if (!fullBuild) { - applyInvalidatedConsumerFilters({ - consumers: watchDependencyTracker.getInvalidatedConsumers( - opts?.previousWatchDependencies, - changedGlobalDataKeys - ), - pageFilterSet, - templateFilterSet, - pagesFileFilterSet, - }) - } - - /** @type {PageData[]} */ - const pagesToWrite = [] - - for (const page of concretePages) { - if (!pageFilterSet || pageFilterSet.has(page.pageInfo.pageFile.filepath)) { - pagesToWrite.push(page) - } - } - - /** @type {[number, number]} Divided concurrency values */ - const dividedConcurrency = MAX_CONCURRENCY % 2 - ? [((MAX_CONCURRENCY - 1) / 2) + 1, (MAX_CONCURRENCY - 1) / 2] // odd - : [MAX_CONCURRENCY / 2, MAX_CONCURRENCY / 2] // even - - const templatesToRender = templateFilterSet - ? siteData.templates.filter(t => templateFilterSet.has(t.templateFile.filepath)) - : siteData.templates - if (opts?.trackWatchDependencies) { - result.report.rebuiltPagesFilePaths = pagesFileFilterSet - ? Array.from(pagesFileFilterSet) - : (siteData.pagesFiles ?? []).map(pagesFile => pagesFile.pagesFile.filepath) - } - - /** @param {PageData} page */ - const writePage = async (page) => { - try { - const buildResult = await pageWriter({ - dest, - page, - outputCache, - }) - - result.report.pages.push({ - pageFilePath: buildResult.pageFilePath, - sourcePageFilePath: page.pageInfo.generated ? undefined : page.pageInfo.pageFile.filepath, - pagesFilePath: page.pageInfo.generated?.pagesFile.pagesFile.filepath, - layoutName: page.layout?.name, - layoutNames: page.layoutChain.map(layout => layout.name), - outputs: buildResult.outputs, - }) - result.outputs.push(...buildResult.outputs) - return true - } catch (err) { - // Direct writes already emitted by a failed iterator still need ownership - // so a later successful watch rebuild can remove them. - if (page.outputRecords.length > 0) { - result.report.pages.push({ - pageFilePath: join(dest, page.pageInfo.outputRelname), - sourcePageFilePath: page.pageInfo.generated ? undefined : page.pageInfo.pageFile.filepath, - pagesFilePath: page.pageInfo.generated?.pagesFile.pagesFile.filepath, - layoutName: page.layout?.name, - layoutNames: page.layoutChain.map(layout => layout.name), - outputs: page.outputRecords, - }) - result.outputs.push(...page.outputRecords) - } - result.errors.push(serializeBuildError(err, { page: pageInfoForWorker(page.pageInfo) }, `Error building page "${page.pageInfo.pageFile.relname}"`)) - return false - } - } - - // Keep output names for dependency pruning, not generated definitions or PageData instances. - const generatedOutputRelnames = new Set() - const writeGeneratedPages = async () => { - try { - for await (const pageInfo of resolveGeneratedPageInfos({ - siteData, - factoryVars: globalVars, - globalData, - pagesFileFilterSet, - buildDrafts: opts?.buildDrafts, - watchDependencyTracker, - })) { - const errorCount = result.errors.length - const page = await initPageData(pageInfo) - if (result.errors.length > errorCount) break - page.setGlobalData(globalData) - watchDependencyTracker.registerConsumer( - 'page', - pageInfo.outputRelname, - page.dataDeps, - { ownerPath: pageInfo.pageFile.filepath } - ) - if (!await writePage(page)) break - generatedOutputRelnames.add(pageInfo.outputRelname) - } - } catch (err) { - const pagesFile = /** @type {PagesFileInfo | undefined} */ (err instanceof Error && 'pagesFile' in err ? err.pagesFile : undefined) - result.errors.push(serializeBuildError(err, { pagesFile }, `Error building generated pages: ${err instanceof Error ? err.message : String(err)}`)) - } - } - - await Promise.all([ - pMap(pagesToWrite, writePage, { concurrency: dividedConcurrency[0] }), - writeGeneratedPages(), - pMap(templatesToRender, async (template) => { - try { - const buildResult = await templateBuilder({ - dest, - globalVars, - globalData, - template, - watchDependencyTracker, - }) - - result.report.templates.push(buildResult.report) - result.outputs.push(...buildResult.outputs) - } catch (err) { - result.errors.push(serializeBuildError(err, { template }, 'Error building template')) - } - }, { concurrency: dividedConcurrency[1] }), - ]) - - if (opts?.trackWatchDependencies) { - result.warnings.push(...outputWarnings(result.outputs)) - watchDependencyTracker.pruneGeneratedPages( - generatedOutputRelnames, - pagesFileFilterSet - ) - result.report.watchDependencies = watchDependencyTracker.state - } - if (opts?.trackWatchDependencies && result.errors.length === 0 && globalDataState) { - result.report.globalDataBaseline = globalDataState.getBaseline() - } - return result -} - -/** - * Add invalidated consumers to the mutable filters for a targeted build. - * - * @param {object} params - * @param {WatchConsumer[]} params.consumers - * @param {Set | null} params.pageFilterSet - * @param {Set | null} params.templateFilterSet - * @param {Set | null} params.pagesFileFilterSet - */ -function applyInvalidatedConsumerFilters ({ - consumers, - pageFilterSet, - templateFilterSet, - pagesFileFilterSet, -}) { - for (const consumer of consumers) { - if (consumer.type === 'template') { - templateFilterSet?.add(consumer.key) - continue - } - if (consumer.type === 'pages-file') { - pagesFileFilterSet?.add(consumer.key) - continue - } - if (consumer.type !== 'page') continue - - if (consumer.ownerPath) { - pagesFileFilterSet?.add(consumer.ownerPath) - continue - } - - pageFilterSet?.add(consumer.key) - } -} diff --git a/lib/build-pages/page-builders/page-output-writer.js b/lib/build-pages/outputs/page-output-writer.js similarity index 100% rename from lib/build-pages/page-builders/page-output-writer.js rename to lib/build-pages/outputs/page-output-writer.js diff --git a/lib/build-pages/page-builders/page-output-writer.test.js b/lib/build-pages/outputs/page-output-writer.test.js similarity index 100% rename from lib/build-pages/page-builders/page-output-writer.test.js rename to lib/build-pages/outputs/page-output-writer.test.js diff --git a/lib/build-pages/page-outputs-types.test.ts b/lib/build-pages/outputs/page-outputs-types.test.ts similarity index 95% rename from lib/build-pages/page-outputs-types.test.ts rename to lib/build-pages/outputs/page-outputs-types.test.ts index 4b82d6b3..597549fe 100644 --- a/lib/build-pages/page-outputs-types.test.ts +++ b/lib/build-pages/outputs/page-outputs-types.test.ts @@ -8,10 +8,10 @@ import type { CollectedPageOutput, PageData, DomstackManifestRecord, -} from '../../types.ts' +} from '../../../types.ts' import { normalizePageOutputs } from './page-outputs.js' -import { writePageOutputs } from './page-builders/page-output-writer.js' -import type { PageOutputCache } from './page-builders/page-output-writer.js' +import { writePageOutputs } from './page-output-writer.js' +import type { PageOutputCache } from './page-output-writer.js' // Compile-only assertions for the public type entry and the narrow hook contract. export function checkPageOutputsTypes (pageData: PageData<{ title: string }>, page: PageOutputsPage) { diff --git a/lib/build-pages/page-outputs.js b/lib/build-pages/outputs/page-outputs.js similarity index 96% rename from lib/build-pages/page-outputs.js rename to lib/build-pages/outputs/page-outputs.js index a22d4576..3039b7ca 100644 --- a/lib/build-pages/page-outputs.js +++ b/lib/build-pages/outputs/page-outputs.js @@ -1,5 +1,5 @@ /** - * @import { PageInfo } from '../identify-pages.js' + * @import { PageInfo } from '../../identify-pages.js' * * @typedef {object} PageOutput * @property {string} outputName @@ -15,7 +15,7 @@ * @typedef {Readonly> & { readonly pageFile: Readonly, readonly readMarkdownContent: () => Promise }} PageOutputsPage */ -import { DomStackDataError } from '../helpers/domstack-error.js' +import { DomStackDataError } from '../../helpers/domstack-error.js' /** * @template {Record} [T=Record] diff --git a/lib/build-pages/page-outputs.test.js b/lib/build-pages/outputs/page-outputs.test.js similarity index 100% rename from lib/build-pages/page-outputs.test.js rename to lib/build-pages/outputs/page-outputs.test.js diff --git a/lib/build-pages/page-builders/page-writer.js b/lib/build-pages/outputs/page-writer.js similarity index 99% rename from lib/build-pages/page-builders/page-writer.js rename to lib/build-pages/outputs/page-writer.js index 99c8f1b7..7d139a7a 100644 --- a/lib/build-pages/page-builders/page-writer.js +++ b/lib/build-pages/outputs/page-writer.js @@ -2,7 +2,7 @@ * @import { PageInfo } from '../../identify-pages.js' * @import { PageData as PageDataClass } from '../page-data.js' * @import { DomstackManifestRecord } from '../../domstack-manifest/index.js' - * @import { PageOutputsFunction } from '../page-outputs.js' + * @import { PageOutputsFunction } from './page-outputs.js' * @import { PageOutputCache } from './page-output-writer.js' */ diff --git a/lib/build-pages/page-builders/html/index.js b/lib/build-pages/page-builders/html/index.js index 2cc9355e..8721efc7 100644 --- a/lib/build-pages/page-builders/html/index.js +++ b/lib/build-pages/page-builders/html/index.js @@ -1,5 +1,5 @@ /** - * @import { PageBuilderType } from '../page-writer.js' + * @import { PageBuilderType } from '../../outputs/page-writer.js' */ import assert from 'node:assert' diff --git a/lib/build-pages/page-builders/js/index.js b/lib/build-pages/page-builders/js/index.js index 2eb2a02f..ce91cf62 100644 --- a/lib/build-pages/page-builders/js/index.js +++ b/lib/build-pages/page-builders/js/index.js @@ -1,10 +1,10 @@ /** * @import { PageInfo } from '../../../identify-pages.js' - * @import { PageBuilderResult } from '../page-writer.js' + * @import { PageBuilderResult } from '../../outputs/page-writer.js' */ import assert from 'node:assert' -import { validatePageOutputsHook } from '../../page-outputs.js' +import { validatePageOutputsHook } from '../../outputs/page-outputs.js' /** * Resolve a JavaScript page module. diff --git a/lib/build-pages/page-builders/md/index.js b/lib/build-pages/page-builders/md/index.js index b5318222..4da55646 100644 --- a/lib/build-pages/page-builders/md/index.js +++ b/lib/build-pages/page-builders/md/index.js @@ -1,6 +1,6 @@ /** * @import markdownIt from 'markdown-it' - * @import { PageBuilderType } from '../page-writer.js' + * @import { PageBuilderType } from '../../outputs/page-writer.js' */ import assert from 'node:assert' import { readFile } from 'fs/promises' diff --git a/lib/build-pages/page-builders/template-builder.js b/lib/build-pages/page-builders/template-builder.js index f5d91efb..62a87973 100644 --- a/lib/build-pages/page-builders/template-builder.js +++ b/lib/build-pages/page-builders/template-builder.js @@ -1,7 +1,7 @@ /** * @import { TemplateInfo } from '../../identify-pages.js' * @import { DomstackManifestRecord } from '../../domstack-manifest/index.js' - * @import { WatchDependencyTracker } from '../watch-dependencies.js' + * @import { WatchDependencyTracker } from '../data/watch-dependencies.js' */ import { dirname, join, relative, resolve } from 'node:path' @@ -9,7 +9,7 @@ import { writeFile, mkdir } from 'fs/promises' import { createDomstackManifestRecord } from '../../domstack-manifest/index.js' import { assertInsideDest, toPosix } from '../../helpers/path.js' import { isAsyncIterable, isPlainObject } from '../../helpers/type-guards.js' -import { createSubscribedData, resolveDataDeps } from '../data-deps.js' +import { createSubscribedData, resolveDataDeps } from '../data/data-deps.js' /** @typedef {{ * outputName: string, diff --git a/lib/build-pages/page-builders/template-builder.test.js b/lib/build-pages/page-builders/template-builder.test.js index 71a72d8f..b0bab269 100644 --- a/lib/build-pages/page-builders/template-builder.test.js +++ b/lib/build-pages/page-builders/template-builder.test.js @@ -8,7 +8,7 @@ import { mkdtemp, rm, writeFile } from 'node:fs/promises' import { join } from 'node:path' import { tmpdir } from 'node:os' import { templateBuilder } from './template-builder.js' -import { WatchDependencyTracker } from '../watch-dependencies.js' +import { WatchDependencyTracker } from '../data/watch-dependencies.js' test('template builder rejects malformed output shapes', async (t) => { const root = await mkdtemp(join(tmpdir(), 'domstack-template-builder-')) diff --git a/lib/build-pages/page-data-page-outputs.test.js b/lib/build-pages/page-data-page-outputs.test.js index 3cf8121b..8668c357 100644 --- a/lib/build-pages/page-data-page-outputs.test.js +++ b/lib/build-pages/page-data-page-outputs.test.js @@ -2,7 +2,7 @@ * @import { PageInfo } from '../identify-pages.js' * @import { ResolvedLayout } from './page-data.js' * @import { TestContext } from 'node:test' - * @import { PageOutputCache } from './page-builders/page-output-writer.js' + * @import { PageOutputCache } from './outputs/page-output-writer.js' */ import { test } from 'node:test' import assert from 'node:assert/strict' @@ -11,7 +11,7 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { PageData, resolveLayout } from './page-data.js' import { identifyPages } from '../identify-pages.js' -import { pageWriter } from './page-builders/page-writer.js' +import { pageWriter } from './outputs/page-writer.js' /** * @param {TestContext} t diff --git a/lib/build-pages/page-data.js b/lib/build-pages/page-data.js index 332b5da3..274b9f23 100644 --- a/lib/build-pages/page-data.js +++ b/lib/build-pages/page-data.js @@ -2,8 +2,8 @@ * @import { PageInfo } from '../identify-pages.js' * @import { DomstackManifestRecord } from '../domstack-manifest/index.js' * @import { DomStackWarning } from '../helpers/domstack-warning.js' - * @import { BuilderOptions, InternalPageFunction } from './page-builders/page-writer.js' - * @import { PageOutputsFunction, PageOutputProvenance, CollectedPageOutput } from './page-outputs.js' + * @import { BuilderOptions, InternalPageFunction } from './outputs/page-writer.js' + * @import { PageOutputsFunction, PageOutputProvenance, CollectedPageOutput } from './outputs/page-outputs.js' */ import { readFile } from 'node:fs/promises' @@ -12,11 +12,11 @@ import { toPosix } from '../helpers/path.js' import { resolveVars, resolvePostVars, resolveVarsExport } from './resolve-vars.js' import { pageBuilders } from './page-builders/index.js' import { parseMdFileContents } from './page-builders/md/parse-md.js' -import { createSubscribedData, extractDataDeps } from './data-deps.js' +import { createSubscribedData, extractDataDeps } from './data/data-deps.js' import { DomStackDataError } from '../helpers/domstack-error.js' import pretty from 'pretty' import { resolveLayoutChain } from './resolve-layout-chain.js' -import { createPageOutputsPage, normalizePageOutputs, validatePageOutputsHook } from './page-outputs.js' +import { createPageOutputsPage, normalizePageOutputs, validatePageOutputsHook } from './outputs/page-outputs.js' import { pathToFileURL } from 'node:url' /** diff --git a/lib/build-pages/page-data.test.js b/lib/build-pages/page-data.test.js index a769bc45..b25e5f80 100644 --- a/lib/build-pages/page-data.test.js +++ b/lib/build-pages/page-data.test.js @@ -1,7 +1,7 @@ /** * @import { PageInfo } from '../identify-pages.js' * @import { ResolvedLayout } from './page-data.js' - * @import { BuilderOptions } from './page-builders/page-writer.js' + * @import { BuilderOptions } from './outputs/page-writer.js' */ import { test } from 'node:test' diff --git a/lib/build-pages/worker-protocol.js b/lib/build-pages/worker-protocol.js new file mode 100644 index 00000000..7300f3fd --- /dev/null +++ b/lib/build-pages/worker-protocol.js @@ -0,0 +1,127 @@ +/** + * @import { PageInfo, TemplateInfo, PagesFileInfo } from '../identify-pages.js' + * @import { PageBuildStepResult } from './index.js' + * @import { WatchDependencyState } from './data/watch-dependencies.js' + * @import { PageOutputCache } from './outputs/page-output-writer.js' + * @import { GlobalDataBaseline, GlobalDataInputChanges } from './data/global-data-state.js' + */ + +import { DomStackDataError, DomStackOutputConflictError } from '../helpers/domstack-error.js' + +/** + * Internal options sent to the page worker. + * Uses arrays (not Sets) so the values can be copied to the worker. + * + * @typedef {object} BuildPagesFilterOptions + * @property {string[] | null | undefined} [pageFilterPaths] - If set, only rebuild pages whose pageFile.filepath is in this list. + * @property {string[] | null | undefined} [templateFilterPaths] - If set, only rebuild templates whose templateFile.filepath is in this list. + * @property {string[] | null | undefined} [pagesFileFilterPaths] - If set, only rebuild generated pages owned by these *.pages.* filepaths. + * @property {boolean | undefined} [buildDrafts] - Include generated page definitions marked as drafts. + * @property {WatchDependencyState | null | undefined} [previousWatchDependencies] - Dependency state from the previous successful watch build. + * @property {boolean | undefined} [trackWatchDependencies] - Collect subscriptions for incremental watch builds. + * @property {PageOutputCache | undefined} [previousPageOutputCache] - Successful output hashes and metadata retained across watch workers. + * @property {GlobalDataBaseline | null | undefined} [previousGlobalDataBaseline] + * @property {GlobalDataInputChanges | undefined} [globalDataInputChanges] + */ + +/** + * Error metadata sent back from the page build worker. + * @typedef {object} WorkerErrorData + * @property {PageInfo | undefined} [page] - Page context for page var/rendering errors. + * @property {TemplateInfo | undefined} [template] - Template context for template rendering errors. + * @property {PagesFileInfo | undefined} [pagesFile] - Pages-file context for generated page resolution errors. + * @property {DomStackOutputConflictError['code'] | DomStackDataError['code'] | undefined} [code] - Stable domain error code. + * @property {DomStackOutputConflictError['conflict'] | undefined} [conflict] - Generated-page conflict details. + * @property {DomStackDataError['dataDependency'] | undefined} [dataDependency] - Subscription error details. + */ + +/** + * @typedef {Omit & { errors: {error: Error, errorData?: WorkerErrorData}[] }} WorkerBuildStepResult + */ + +/** + * Remove generated vars and rendering functions before returning page error + * information from the worker. Concrete PageInfo objects are already copyable. + * + * @param {PageInfo} pageInfo + * @returns {PageInfo} + */ +export function pageInfoForWorker (pageInfo) { + if (!pageInfo.generated) return pageInfo + return { + ...pageInfo, + generated: { pagesFile: pageInfo.generated.pagesFile }, + } +} + +/** + * @param {WorkerErrorData} errorData + * @returns {{ type: 'page' | 'template' | 'pages file', path: string } | null} + */ +function getWorkerErrorContext (errorData) { + if (errorData.page) { + const pagePath = errorData.page.path || errorData.page.url || errorData.page.pageFile.relname + return { type: 'page', path: pagePath } + } + + if (errorData.template) { + const templatePath = errorData.template.path || errorData.template.templateFile.relname + return { type: 'template', path: templatePath } + } + + if (errorData.pagesFile) { + return { type: 'pages file', path: errorData.pagesFile.pagesFile.relname } + } + + return null +} + +/** + * @param {Error} error + * @param {WorkerErrorData} errorData + * @returns {Error} + */ +export function restoreWorkerError (error, errorData) { + const context = getWorkerErrorContext(errorData) + const message = context + ? `${error.message} (${context.type}: "${context.path}")` + : error.message + const restoredError = errorData.dataDependency + ? new DomStackDataError(message, errorData.dataDependency, { cause: error.cause }) + : new Error(message, { cause: error.cause }) + if (!(restoredError instanceof DomStackDataError)) restoredError.name = error.name + + if (error.stack) { + restoredError.stack = error.stack.replace(error.message, restoredError.message) + } + + const { code, ...contextData } = errorData + Object.assign(restoredError, contextData) + if (!(restoredError instanceof DomStackDataError) && code) Object.assign(restoredError, { code }) + + return restoredError +} + +/** + * Preserve domain metadata separately because worker cloning strips Error fields. + * @param {unknown} err + * @param {WorkerErrorData} [context] + * @param {string} [message] + * @returns {WorkerBuildStepResult['errors'][number]} + */ +export function serializeBuildError (err, context = {}, message) { + const error = err instanceof Error ? err : new Error('Non-error thrown during page build', { cause: err }) + const errorData = { ...context } + if (error instanceof DomStackDataError) { + errorData.code = error.code + errorData.dataDependency = error.dataDependency + } else if (error instanceof DomStackOutputConflictError) { + errorData.code = error.code + errorData.conflict = error.conflict + } + const reportedError = message && !errorData.code + ? new Error(message, { cause: { message: error.message, stack: error.stack } }) + : error + reportedError.name = error.name + return { error: reportedError, errorData } +} diff --git a/lib/build-pages/worker-protocol.test.js b/lib/build-pages/worker-protocol.test.js new file mode 100644 index 00000000..49cd85c7 --- /dev/null +++ b/lib/build-pages/worker-protocol.test.js @@ -0,0 +1,187 @@ +/** + * @import { PageInfo, PagesFileInfo, TemplateInfo, WalkerFile } from '../identify-pages.js' + * @import { WorkerErrorData } from './worker-protocol.js' + */ +import assert from 'node:assert/strict' +import { test } from 'node:test' +import { posix } from 'node:path' +import * as facade from './index.js' +import { buildPagesDirect } from './build.js' +import { pageBuilders } from './page-builders/index.js' +import { pageInfoForWorker, restoreWorkerError, serializeBuildError } from './worker-protocol.js' +import { DomStackDataError, DomStackOutputConflictError } from '../helpers/domstack-error.js' + +/** @param {string} relname @returns {WalkerFile} */ +function file (relname) { + return { + root: '/src', + filepath: `/src/${relname}`, + relname, + basename: posix.basename(relname), + parentName: posix.dirname(relname) === '.' ? '' : posix.dirname(relname), + } +} + +/** @type {PageInfo} */ +const page = { + pageFile: { ...file('blog/page.js'), type: 'js' }, + type: 'js', + path: 'blog', + url: '/blog/', + outputName: 'index.html', + outputRelname: 'blog/index.html', + draft: false, +} + +/** @type {TemplateInfo} */ +const template = { + templateFile: file('feeds/rss.template.js'), + path: 'feeds', + outputName: 'rss.xml', +} + +/** @type {PagesFileInfo} */ +const pagesFile = { + pagesFile: file('blog/posts.pages.js'), + path: 'blog', + name: 'posts', +} + +/** + * @param {unknown} error + * @param {WorkerErrorData} [context] + * @param {string} [message] + */ +function roundTrip (error, context, message) { + const cloned = structuredClone(serializeBuildError(error, context, message)) + return restoreWorkerError(cloned.error, cloned.errorData ?? {}) +} + +test('build-pages facade preserves runtime export identity after the split', () => { + assert.equal(facade.buildPagesDirect, buildPagesDirect) + assert.equal(facade.serializeBuildError, serializeBuildError) + assert.equal(facade.pageBuilders, pageBuilders) +}) + +test('data errors retain their class, metadata, cause, stack and page context across cloning', () => { + const original = new DomStackDataError('Missing global data key "posts"', { + reason: 'MISSING_KEY', + consumer: 'Page "blog/page.js"', + key: 'posts', + }, { cause: new Error('Global data failed') }) + const context = { page } + const restored = roundTrip(original, context, 'Generic page build failure') + + assert.ok(restored instanceof DomStackDataError) + assert.equal(restored.name, 'DomStackDataError') + assert.equal(restored.code, 'DOM_STACK_ERROR_DATA') + assert.deepEqual(restored.dataDependency, original.dataDependency) + assert.notEqual(restored.dataDependency, original.dataDependency) + assert.equal(restored.message, 'Missing global data key "posts" (page: "blog")') + assert.ok('page' in restored) + assert.deepEqual(restored.page, page) + assert.notEqual(restored.page, page) + assert.deepEqual(restored.cause, original.cause) + assert.ok(original.stack) + assert.equal(restored.stack, original.stack.replace(original.message, restored.message)) + assert.deepEqual(context, { page }) + assert.equal(original.message, 'Missing global data key "posts"') +}) + +test('output conflicts retain both claims, code, cause and pages-file context across cloning', () => { + const original = new DomStackOutputConflictError('Output path conflict: blog/index.html', { + outputPath: 'blog/index.html', + a: { type: 'page', path: 'blog/page.js' }, + b: { type: 'page', path: 'blog/posts.pages.js#0' }, + }, { cause: new Error('Output already claimed') }) + const restored = roundTrip(original, { pagesFile }, 'Generic factory failure') + + assert.ok('code' in restored) + assert.equal(restored.code, 'DOM_STACK_ERROR_OUTPUT_CONFLICT') + assert.ok('conflict' in restored) + assert.deepEqual(restored.conflict, original.conflict) + assert.notEqual(restored.conflict, original.conflict) + assert.ok('pagesFile' in restored) + assert.deepEqual(restored.pagesFile, pagesFile) + assert.equal(restored.message, 'Output path conflict: blog/index.html (pages file: "blog/posts.pages.js")') + assert.deepEqual(restored.cause, original.cause) + assert.ok(original.stack) + assert.equal(restored.stack, original.stack.replace(original.message, restored.message)) +}) + +/** @type {{ name: string, context: WorkerErrorData, suffix: string }[]} */ +const contextualCases = [ + { name: 'page path takes precedence', context: { page, template, pagesFile }, suffix: ' (page: "blog")' }, + { name: 'page URL fallback', context: { page: { ...page, path: '' } }, suffix: ' (page: "/blog/")' }, + { name: 'page filename fallback', context: { page: { ...page, path: '', url: '' } }, suffix: ' (page: "blog/page.js")' }, + { name: 'template path takes precedence', context: { template, pagesFile }, suffix: ' (template: "feeds")' }, + { name: 'template filename fallback', context: { template: { ...template, path: '' } }, suffix: ' (template: "feeds/rss.template.js")' }, + { name: 'pages-file filename', context: { pagesFile }, suffix: ' (pages file: "blog/posts.pages.js")' }, + { name: 'no context', context: {}, suffix: '' }, +] + +for (const { name, context, suffix } of contextualCases) { + test(`ordinary errors survive cloning with ${name}`, () => { + const original = new TypeError('Render failed', { cause: new Error('Invalid value') }) + const restored = roundTrip(original, context) + + assert.equal(restored.name, 'TypeError') + assert.equal(restored.message, `Render failed${suffix}`) + assert.deepEqual(restored.cause, original.cause) + assert.ok(original.stack) + assert.equal(restored.stack, original.stack.replace(original.message, restored.message)) + for (const [key, value] of Object.entries(context)) { + assert.deepEqual(Reflect.get(restored, key), value) + } + assert.equal('code' in restored, false) + }) +} + +test('ordinary error wrappers retain the original message and stack as their cause', () => { + const original = new Error('Invalid template output') + const restored = roundTrip(original, { template }, 'Template build failed') + + assert.equal(restored.message, 'Template build failed (template: "feeds")') + assert.deepEqual(restored.cause, { message: original.message, stack: original.stack }) + assert.ok('template' in restored) + assert.deepEqual(restored.template, template) + assert.equal(original.message, 'Invalid template output') +}) + +test('generated page context is sanitized and cloneable without mutating the input', () => { + const vars = Object.freeze({ title: 'Generated post', format: () => 'formatted' }) + const children = () => 'Rendered post' + const generated = Object.freeze({ pagesFile, vars, children }) + /** @type {PageInfo} */ + const original = Object.freeze({ + ...page, + pageFile: { + ...pagesFile.pagesFile, + basename: 'posts.pages.js#0', + relname: 'blog/posts.pages.js#0', + type: /** @type {const} */ ('js'), + }, + generated, + }) + + assert.throws(() => structuredClone(original), { name: 'DataCloneError' }) + const sanitized = pageInfoForWorker(original) + assert.notEqual(sanitized, original) + assert.notEqual(sanitized.generated, generated) + assert.deepEqual(sanitized, { ...original, generated: { pagesFile } }) + assert.deepEqual(structuredClone(sanitized), sanitized) + + const restored = roundTrip(new Error('Generated render failed'), { page: sanitized }) + assert.ok('page' in restored) + assert.deepEqual(restored.page, sanitized) + assert.equal(restored.message, 'Generated render failed (page: "blog")') + assert.equal(original.generated, generated) + assert.equal(original.generated.vars, vars) + assert.equal(original.generated.children, children) + assert.deepEqual(original.generated, { pagesFile, vars, children }) +}) + +test('concrete page context is returned unchanged', () => { + assert.equal(pageInfoForWorker(page), page) + assert.deepEqual(structuredClone(pageInfoForWorker(page)), page) +}) diff --git a/lib/build-pages/worker.js b/lib/build-pages/worker.js index 41dfce30..6e9ba90a 100644 --- a/lib/build-pages/worker.js +++ b/lib/build-pages/worker.js @@ -1,5 +1,6 @@ import { parentPort, workerData } from 'worker_threads' -import { buildPagesDirect, serializeBuildError } from './index.js' +import { buildPagesDirect } from './build.js' +import { serializeBuildError } from './worker-protocol.js' async function run () { if (!parentPort) throw new Error('parentPort returned null') diff --git a/lib/watch/index.js b/lib/watch/index.js index f7682ab1..0a475af0 100644 --- a/lib/watch/index.js +++ b/lib/watch/index.js @@ -7,9 +7,9 @@ * @import { BsInstance } from '@domstack/sync' * @import { Logger as PinoLogger } from 'pino' * @import { DomstackManifestRecord } from '../domstack-manifest/index.js' - * @import { WatchDependencyState } from '../build-pages/watch-dependencies.js' + * @import { WatchDependencyState } from '../build-pages/data/watch-dependencies.js' * @import { WatchSnapshot, WatchEvent, WatchPlan } from './plan.js' - * @import { GlobalDataBaseline, GlobalDataInputChanges } from '../build-pages/global-data-state.js' + * @import { GlobalDataBaseline, GlobalDataInputChanges } from '../build-pages/data/global-data-state.js' * @typedef {{ dispose: () => Promise }} DisposableBuildContext * @typedef {{ pageFilePath: string, sourcePageFilePath?: string | undefined, pagesFilePath?: string | undefined, layoutNames: string[], outputs?: DomstackManifestRecord[] | undefined }} WatchedPageReport * @typedef {object} WatchSession diff --git a/lib/watch/page-output-ledger.js b/lib/watch/page-output-ledger.js index c4e284f6..eeb99615 100644 --- a/lib/watch/page-output-ledger.js +++ b/lib/watch/page-output-ledger.js @@ -1,6 +1,6 @@ /** * @import { PageReport, WorkerBuildStepResult } from '../build-pages/index.js' - * @import { PageOutputCache } from '../build-pages/page-builders/page-output-writer.js' + * @import { PageOutputCache } from '../build-pages/outputs/page-output-writer.js' */ import { lstat, rm } from 'node:fs/promises' import { dirname, resolve } from 'node:path' diff --git a/lib/watch/page-output-ledger.test.js b/lib/watch/page-output-ledger.test.js index bf38cadc..64215ba2 100644 --- a/lib/watch/page-output-ledger.test.js +++ b/lib/watch/page-output-ledger.test.js @@ -1,7 +1,7 @@ /** * @import { TestContext } from 'node:test' * @import { PageReport, WorkerBuildStepResult } from '../build-pages/index.js' - * @import { PageOutputCache } from '../build-pages/page-builders/page-output-writer.js' + * @import { PageOutputCache } from '../build-pages/outputs/page-output-writer.js' * @import { DomstackManifestRecord } from '../domstack-manifest/index.js' */ import { test } from 'node:test' diff --git a/test-cases/general-features/src/worker-page/page.js b/test-cases/general-features/src/worker-page/page.js index 76f4912a..05f136de 100644 --- a/test-cases/general-features/src/worker-page/page.js +++ b/test-cases/general-features/src/worker-page/page.js @@ -1,5 +1,5 @@ /** - * @import { AsyncPageFunction } from '../../../../lib/build-pages/page-builders/page-writer.js' + * @import { AsyncPageFunction } from '../../../../lib/build-pages/outputs/page-writer.js' */ /** diff --git a/test-cases/nested-layouts/type-checks.ts b/test-cases/nested-layouts/type-checks.ts index 4ff33c5a..b3f19acc 100644 --- a/test-cases/nested-layouts/type-checks.ts +++ b/test-cases/nested-layouts/type-checks.ts @@ -1,7 +1,7 @@ // Compile-time regressions exercised by npm run test:tsc, not the Node test runner. import type { LayoutFunction, PageData, PageFunction } from '#types' import type { ResolvedLayout } from '../../lib/build-pages/page-data.js' -import { pageWriter } from '../../lib/build-pages/page-builders/page-writer.js' +import { pageWriter } from '../../lib/build-pages/outputs/page-writer.js' type Vars = { title: string } type Frame = { html: string } diff --git a/types.ts b/types.ts index 57e70524..5fdd888f 100644 --- a/types.ts +++ b/types.ts @@ -9,15 +9,15 @@ import type { PagesFunctionParams as PagesFunctionParamsExport, } from './lib/build-pages/index.js' -import type { PageFunction as PageFunctionExport } from './lib/build-pages/page-builders/page-writer.js' -import type { PageOutputsFunction as PageOutputsFunctionExport } from './lib/build-pages/page-outputs.js' +import type { PageFunction as PageFunctionExport } from './lib/build-pages/outputs/page-writer.js' +import type { PageOutputsFunction as PageOutputsFunctionExport } from './lib/build-pages/outputs/page-outputs.js' -export type { DataDeps } from './lib/build-pages/data-deps.js' +export type { DataDeps } from './lib/build-pages/data/data-deps.js' export type { GlobalDataChanges, GlobalDataDeltaChanges, GlobalDataResetChanges, -} from './lib/build-pages/global-data-state.js' +} from './lib/build-pages/data/global-data-state.js' export type { WatchEvent } from './lib/watch/plan.js' export type { PageOutput, @@ -27,7 +27,7 @@ export type { PageOutputsPage, PageOutputsResult, CollectedPageOutput, -} from './lib/build-pages/page-outputs.js' +} from './lib/build-pages/outputs/page-outputs.js' export type { BuildOptions } from 'esbuild' export type { DomStackOpts, Results, SiteData } from './lib/builder.js' @@ -52,7 +52,7 @@ export type { AsyncPageFunction, PageFunction, PageFunctionParams, -} from './lib/build-pages/page-builders/page-writer.js' +} from './lib/build-pages/outputs/page-writer.js' export type { AsyncTemplateFunction, TemplateAsyncIterator, From 53f359e868ace904bd33d1733f8fe5ac5fdcea82 Mon Sep 17 00:00:00 2001 From: Bret Comnes Date: Wed, 16 Sep 2026 18:23:03 -0700 Subject: [PATCH 06/20] perf(build-pages): reduce vars copying and unnecessary collection scans Avoid source-array allocation on vars cache hits and merge into one target while preserving spread semantics and snapshot timing. Reduce subscriber bookkeeping allocations, skip unselected factory collision setup, and reuse full-build page selections. Add regression coverage for semantics and skipped work. --- lib/build-pages/build.js | 11 +- lib/build-pages/data/watch-dependencies.js | 34 ++- .../data/watch-dependencies.test.js | 136 +++++++++- lib/build-pages/generated-pages/index.js | 8 +- lib/build-pages/generated-pages/index.test.js | 111 ++++++++ lib/build-pages/page-data.js | 45 +++- lib/build-pages/page-data.test.js | 241 ++++++++++++++++++ 7 files changed, 550 insertions(+), 36 deletions(-) create mode 100644 lib/build-pages/generated-pages/index.test.js diff --git a/lib/build-pages/build.js b/lib/build-pages/build.js index b0d6af77..93e1b784 100644 --- a/lib/build-pages/build.js +++ b/lib/build-pages/build.js @@ -187,14 +187,9 @@ export async function buildPagesDirect (_src, dest, siteData, opts) { }) } - /** @type {PageData[]} */ - const pagesToWrite = [] - - for (const page of concretePages) { - if (!pageFilterSet || pageFilterSet.has(page.pageInfo.pageFile.filepath)) { - pagesToWrite.push(page) - } - } + const pagesToWrite = pageFilterSet + ? concretePages.filter(page => pageFilterSet.has(page.pageInfo.pageFile.filepath)) + : concretePages /** @type {[number, number]} Divided concurrency values */ const dividedConcurrency = MAX_CONCURRENCY % 2 diff --git a/lib/build-pages/data/watch-dependencies.js b/lib/build-pages/data/watch-dependencies.js index bb26578a..85ea3047 100644 --- a/lib/build-pages/data/watch-dependencies.js +++ b/lib/build-pages/data/watch-dependencies.js @@ -78,12 +78,15 @@ export class WatchDependencyTracker { } const changed = new Set() - const allKeys = new Set([ - ...Object.keys(previousFingerprints ?? {}), - ...Object.keys(current), - ]) + for (const key of Object.keys(previousFingerprints ?? {})) { + const previous = previousFingerprints?.[key] + const next = current[key] + if (previous == null || next == null || previous !== next) changed.add(key) + } - for (const key of allKeys) { + // Preserve union order: previous enumerable keys, then current-only keys. + for (const key of Object.keys(current)) { + if (previousFingerprints && Object.prototype.propertyIsEnumerable.call(previousFingerprints, key)) continue const previous = previousFingerprints?.[key] const next = current[key] if (previous == null || next == null || previous !== next) changed.add(key) @@ -106,9 +109,15 @@ export class WatchDependencyTracker { getInvalidatedConsumers (previousState, changedGlobalDataKeys) { if (!this.#enabled || !previousState || changedGlobalDataKeys.size === 0) return [] - return Object.values(previousState.consumers).filter(consumer => { - return consumer.globalDataKeys.some(key => changedGlobalDataKeys.has(key)) - }) + /** @type {WatchConsumer[]} */ + const invalidated = [] + const consumers = previousState.consumers + for (const id in consumers) { + if (!Object.hasOwn(consumers, id)) continue + const consumer = /** @type {WatchConsumer} */ (consumers[id]) + if (consumer.globalDataKeys.some(key => changedGlobalDataKeys.has(key))) invalidated.push(consumer) + } + return invalidated } /** @@ -118,16 +127,19 @@ export class WatchDependencyTracker { * @param {Set | null} rebuiltOwnerPaths - Null when every owner was rebuilt. */ pruneGeneratedPages (currentGeneratedPageKeys, rebuiltOwnerPaths = null) { - if (!this.#enabled) return + if (!this.#enabled || rebuiltOwnerPaths?.size === 0) return - for (const [id, consumer] of Object.entries(this.#state.consumers)) { + const consumers = this.#state.consumers + for (const id in consumers) { + if (!Object.hasOwn(consumers, id)) continue + const consumer = /** @type {WatchConsumer} */ (consumers[id]) if ( consumer.type === 'page' && consumer.ownerPath && (rebuiltOwnerPaths === null || rebuiltOwnerPaths.has(consumer.ownerPath)) && !currentGeneratedPageKeys.has(consumer.key) ) { - delete this.#state.consumers[id] + delete consumers[id] } } } diff --git a/lib/build-pages/data/watch-dependencies.test.js b/lib/build-pages/data/watch-dependencies.test.js index 3f3547af..f6514909 100644 --- a/lib/build-pages/data/watch-dependencies.test.js +++ b/lib/build-pages/data/watch-dependencies.test.js @@ -94,6 +94,86 @@ describe('declarative watch dependencies', () => { assert.deepEqual(Array.from(changedKeys).sort(), ['newKey', 'oldKey']) }) + test('reports changed keys in previous-key order followed by current-only key order', () => { + const tracker = new WatchDependencyTracker(null, { fullBuild: true }) + tracker.updateGlobalDataFingerprints({ 10: 'old', removed: 1, stable: 1, changed: 1, opaque: undefined }, null) + const changed = tracker.updateGlobalDataFingerprints( + { 2: 'new', 10: 'new', added: 1, opaque: undefined, changed: 2, stable: 1 }, + tracker.state.globalDataFingerprints + ) + + assert.deepEqual([...changed], ['10', 'removed', 'changed', 'opaque', '2', 'added']) + }) + + test('enumerates only own enumerable fingerprint keys but still compares inherited and hidden values', () => { + const tracker = new WatchDependencyTracker(null, { fullBuild: true }) + tracker.updateGlobalDataFingerprints({ value: 1 }, null) + const hash = tracker.state.globalDataFingerprints['value'] + const previous = Object.assign(Object.create({ inheritedSame: hash, inheritedChanged: hash, inheritedOnly: hash }), { + removed: hash, + }) + Object.defineProperties(previous, { + hiddenSame: { value: hash }, + hiddenChanged: { value: hash }, + hiddenOnly: { value: hash }, + propertyIsEnumerable: { value: null }, + [Symbol('previous')]: { value: hash, enumerable: true }, + }) + const data = Object.assign(Object.create({ inheritedOnly: 2 }), { + inheritedSame: 1, + inheritedChanged: 2, + hiddenSame: 1, + hiddenChanged: 2, + added: 1, + }) + Object.defineProperties(data, { + hiddenOnly: { get () { assert.fail('must not read hidden global data') } }, + [Symbol('data')]: { get () { assert.fail('must not read symbol global data') }, enumerable: true }, + }) + + assert.deepEqual([...tracker.updateGlobalDataFingerprints(data, previous)], ['removed', 'inheritedChanged', 'hiddenChanged', 'added']) + assert.deepEqual(Object.keys(tracker.state.globalDataFingerprints), ['inheritedSame', 'inheritedChanged', 'hiddenSame', 'hiddenChanged', 'added']) + }) + + test('invalidates own enumerable consumers once each in enumeration order', () => { + const tracker = new WatchDependencyTracker(null, { fullBuild: true }) + tracker.registerConsumer('page', '/site/a.md', ['posts', 'navigation', 'posts']) + tracker.registerConsumer('template', '/site/feed.template.js', ['navigation']) + tracker.registerConsumer('pages-file', '/site/archive.pages.js', ['posts']) + tracker.registerConsumer('page', '/site/unrelated.md', ['other']) + const registered = Object.values(tracker.state.consumers) + assert.ok(registered[0]) + assert.ok(registered[1]) + assert.ok(registered[2]) + assert.ok(registered[3]) + const consumers = { z: registered[0], 10: registered[1], 2: registered[2], unrelated: registered[3] } + Object.setPrototypeOf(consumers, { + get inherited () { return assert.fail('must not read inherited consumers') }, + }) + Object.defineProperties(consumers, { + hidden: { get () { assert.fail('must not read hidden consumers') } }, + [Symbol('consumer')]: { get () { assert.fail('must not read symbol consumers') }, enumerable: true }, + }) + tracker.state.consumers = consumers + + const invalidated = tracker.getInvalidatedConsumers(tracker.state, new Set(['posts', 'navigation'])) + assert.deepEqual(invalidated, [registered[2], registered[1], registered[0]]) + assert.equal(invalidated[0], registered[2], 'invalidation returns the original snapshot consumers') + }) + + test('empty invalidation and targeted owner scopes do not traverse consumers', () => { + const tracker = new WatchDependencyTracker(null, { fullBuild: true }) + tracker.state.consumers = new Proxy({}, { + ownKeys () { assert.fail('must not enumerate consumers') }, + get () { assert.fail('must not read consumers') }, + }) + const generatedKeys = new Set() + generatedKeys.has = () => assert.fail('must not inspect generated keys') + + assert.deepEqual(tracker.getInvalidatedConsumers(tracker.state, new Set()), []) + tracker.pruneGeneratedPages(generatedKeys, new Set()) + }) + test('conservatively invalidates values JSON serialization would lose', () => { const initial = new WatchDependencyTracker(null, { fullBuild: true }) initial.updateGlobalDataFingerprints({ opaque: { format: () => 'first' } }, null) @@ -124,17 +204,69 @@ describe('declarative watch dependencies', () => { test('prunes only rebuilt owners while preserving unrelated consumers', () => { const tracker = new WatchDependencyTracker(null, { fullBuild: true }) tracker.registerConsumer('page', 'removed.html', ['posts'], { ownerPath: '/site/a.pages.js' }) + tracker.registerConsumer('page', 'also-removed.html', ['posts'], { ownerPath: '/site/a.pages.js' }) tracker.registerConsumer('page', 'retained.html', ['posts'], { ownerPath: '/site/a.pages.js' }) tracker.registerConsumer('page', 'other.html', ['posts'], { ownerPath: '/site/b.pages.js' }) tracker.registerConsumer('page', '/site/page.md', ['posts']) - tracker.registerConsumer('pages-file', '/site/a.pages.js', ['posts']) - tracker.registerConsumer('template', '/site/feed.template.js', ['posts']) + tracker.registerConsumer('pages-file', '/site/a.pages.js', ['posts'], { ownerPath: '/site/a.pages.js' }) + tracker.registerConsumer('template', '/site/feed.template.js', ['posts'], { ownerPath: '/site/a.pages.js' }) const before = structuredClone(tracker.state.consumers) tracker.pruneGeneratedPages(new Set(['retained.html']), new Set(['/site/a.pages.js'])) delete before['page\0removed.html'] + delete before['page\0also-removed.html'] assert.deepEqual(tracker.state.consumers, before) + assert.deepEqual(Object.keys(tracker.state.consumers), Object.keys(before)) + }) + + test('full pruning visits own enumerable consumers in order without skipping adjacent removals', () => { + const tracker = new WatchDependencyTracker(null, { fullBuild: true }) + tracker.registerConsumer('page', 'removed.html', ['posts'], { ownerPath: '/site/a.pages.js' }) + tracker.registerConsumer('page', 'also-removed.html', ['posts'], { ownerPath: '/site/b.pages.js' }) + tracker.registerConsumer('page', 'retained.html', ['posts'], { ownerPath: '/site/a.pages.js' }) + const consumers = tracker.state.consumers + const ids = Object.keys(consumers) + Object.setPrototypeOf(consumers, { + get inherited () { return assert.fail('must not read inherited consumers') }, + }) + Object.defineProperties(consumers, { + hidden: { get () { assert.fail('must not read hidden consumers') } }, + [Symbol('consumer')]: { get () { assert.fail('must not read symbol consumers') }, enumerable: true }, + }) + /** @type {PropertyKey[]} */ + const deleted = [] + tracker.state.consumers = new Proxy(consumers, { + deleteProperty (target, key) { + deleted.push(key) + return Reflect.deleteProperty(target, key) + }, + }) + + tracker.pruneGeneratedPages(new Set(['retained.html'])) + + assert.deepEqual(deleted, ids.slice(0, 2)) + assert.deepEqual(Object.keys(consumers), ids.slice(2)) + }) + + test('incremental state remains isolated from external nested mutations', () => { + const initial = new WatchDependencyTracker(null, { fullBuild: true }) + initial.registerConsumer('page', '/site/page.md', ['posts']) + initial.updateGlobalDataFingerprints({ posts: ['Alpha'] }, null) + const before = structuredClone(initial.state) + const next = new WatchDependencyTracker(initial.state, { fullBuild: false }) + + const nextConsumer = next.state.consumers['page\0/site/page.md'] + const initialConsumer = initial.state.consumers['page\0/site/page.md'] + assert.ok(nextConsumer) + assert.ok(initialConsumer) + nextConsumer.globalDataKeys.push('navigation') + nextConsumer.ownerPath = '/site/archive.pages.js' + next.state.globalDataFingerprints['posts'] = null + + assert.deepEqual(structuredClone(initial.state), before) + initialConsumer.globalDataKeys.push('other') + assert.deepEqual(nextConsumer.globalDataKeys, ['posts', 'navigation']) }) test('incremental registration does not mutate or replace the previous invalidation snapshot', () => { diff --git a/lib/build-pages/generated-pages/index.js b/lib/build-pages/generated-pages/index.js index 19eb7985..653b9546 100644 --- a/lib/build-pages/generated-pages/index.js +++ b/lib/build-pages/generated-pages/index.js @@ -111,6 +111,10 @@ function generatedDefinitionToPageInfo ({ definition, pagesFile, index }) { * @returns {AsyncGenerator} */ export async function * resolveGeneratedPageInfos ({ siteData, factoryVars, globalData, pagesFileFilterSet, buildDrafts, watchDependencyTracker }) { + const pagesFiles = siteData.pagesFiles ?? [] + // No generated writes means no collision reservations are needed. + if (pagesFileFilterSet?.size === 0 || !pagesFiles.some(owner => !pagesFileFilterSet || pagesFileFilterSet.has(owner.pagesFile.filepath))) return + /** @type {Map} */ const pageOutputClaims = new Map() @@ -124,7 +128,7 @@ export async function * resolveGeneratedPageInfos ({ siteData, factoryVars, glob // Unselected factories keep their outputs. Reserve those paths without // rerunning the owners, so a targeted build cannot silently overwrite them. if (pagesFileFilterSet) { - const ownerRelnames = new Map((siteData.pagesFiles ?? []).map(({ pagesFile }) => [pagesFile.filepath, pagesFile.relname])) + const ownerRelnames = new Map(pagesFiles.map(({ pagesFile }) => [pagesFile.filepath, pagesFile.relname])) for (const consumer of Object.values(watchDependencyTracker.state.consumers)) { if (consumer.type === 'page' && consumer.ownerPath && !pagesFileFilterSet.has(consumer.ownerPath)) { pageOutputClaims.set(resolve(consumer.key), { type: 'page', path: ownerRelnames.get(consumer.ownerPath) ?? consumer.key }) @@ -132,7 +136,7 @@ export async function * resolveGeneratedPageInfos ({ siteData, factoryVars, glob } } - for (const pagesFile of siteData.pagesFiles ?? []) { + for (const pagesFile of pagesFiles) { if (pagesFileFilterSet && !pagesFileFilterSet.has(pagesFile.pagesFile.filepath)) continue try { diff --git a/lib/build-pages/generated-pages/index.test.js b/lib/build-pages/generated-pages/index.test.js new file mode 100644 index 00000000..570188ca --- /dev/null +++ b/lib/build-pages/generated-pages/index.test.js @@ -0,0 +1,111 @@ +/** + * @import { SiteData } from '../../builder.js' + * @import { PageInfo, PagesFileInfo } from '../../identify-pages.js' + */ +import assert from 'node:assert/strict' +import { test } from 'node:test' +import { mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { resolveGeneratedPageInfos } from './index.js' +import { WatchDependencyTracker } from '../data/watch-dependencies.js' +import { DomStackOutputConflictError } from '../../helpers/domstack-error.js' + +/** @returns {SiteData} */ +function emptySite () { + return { + pages: [], + templates: [], + pagesFiles: [], + layouts: {}, + globalStyle: undefined, + globalClient: undefined, + serviceWorker: undefined, + globalVars: undefined, + globalData: undefined, + esbuildSettings: undefined, + markdownItSettings: undefined, + domstackManifestSettings: undefined, + defaultStyle: null, + defaultClient: null, + defaultLayout: false, + warnings: [], + errors: [], + } +} + +/** @param {string} root @param {string} name @returns {PagesFileInfo} */ +function owner (root, name) { + const basename = `${name}.pages.js` + return { + pagesFile: { root, filepath: join(root, basename), relname: basename, basename, parentName: '' }, + path: '', + name, + } +} + +for (const kind of ['absent', 'empty', 'unselected', 'unknown selection']) { + test(`factory expansion skips collision setup for ${kind} factories`, async () => { + const siteData = emptySite() + const factory = owner('/unused', 'archive') + if (kind === 'absent') Reflect.deleteProperty(siteData, 'pagesFiles') + if (kind === 'unselected' || kind === 'unknown selection') siteData.pagesFiles = [factory] + Object.defineProperty(siteData, 'pages', { get: () => assert.fail('source pages must not be scanned') }) + const tracker = new WatchDependencyTracker(null, { fullBuild: true }) + Object.defineProperty(tracker.state, 'consumers', { get: () => assert.fail('previous consumers must not be scanned') }) + const pagesFileFilterSet = kind === 'unselected' + ? new Set() + : kind === 'unknown selection' ? new Set(['/unused/missing.pages.js']) : null + const results = [] + for await (const page of resolveGeneratedPageInfos({ + siteData, + factoryVars: {}, + globalData: {}, + pagesFileFilterSet, + buildDrafts: false, + watchDependencyTracker: tracker, + })) results.push(page) + assert.deepEqual(results, []) + }) +} + +for (const existing of ['source', 'unselected factory']) { + test(`selected factories still reserve outputs belonging to the ${existing}`, async t => { + const root = await mkdtemp(join(tmpdir(), 'domstack-factory-selection-')) + t.after(() => rm(root, { recursive: true, force: true })) + const selected = owner(root, 'selected') + const untouched = owner(root, 'untouched') + await writeFile(selected.pagesFile.filepath, 'export default { outputName: "shared.html" }') + const siteData = emptySite() + siteData.pagesFiles = [selected, untouched] + const tracker = new WatchDependencyTracker(null, { fullBuild: true }) + if (existing === 'source') { + /** @type {PageInfo} */ + const page = { + pageFile: { root, filepath: join(root, 'page.md'), relname: 'page.md', basename: 'page.md', parentName: '' }, + type: 'md', + path: '', + url: '/shared.html', + outputName: 'shared.html', + outputRelname: 'shared.html', + draft: false, + } + siteData.pages.push(page) + } else { + tracker.registerConsumer('page', 'shared.html', [], { ownerPath: untouched.pagesFile.filepath }) + } + const definitions = resolveGeneratedPageInfos({ + siteData, + factoryVars: {}, + globalData: {}, + pagesFileFilterSet: new Set([selected.pagesFile.filepath]), + buildDrafts: false, + watchDependencyTracker: tracker, + }) + await assert.rejects(definitions.next(), error => { + assert.ok(error instanceof DomStackOutputConflictError) + assert.equal(error.conflict.outputPath, 'shared.html') + return true + }) + }) +} diff --git a/lib/build-pages/page-data.js b/lib/build-pages/page-data.js index 274b9f23..923caca7 100644 --- a/lib/build-pages/page-data.js +++ b/lib/build-pages/page-data.js @@ -222,6 +222,34 @@ export class PageData { ] } + #varSourcesUnchanged () { + const sources = this.#varsCacheSources + const layoutCount = this.layoutVars.length + if ( + !sources || + sources.length !== layoutCount + 3 || + sources[0] !== this.globalVars || + sources[layoutCount + 1] !== this.pageVars || + sources[layoutCount + 2] !== this.builderVars + ) return false + + for (let index = 0; index < layoutCount; index++) { + const layout = this.layoutVars[index] + if (!layout || sources[index + 1] !== layout.vars) return false + } + return true + } + + /** @param {(Partial | null)[]} sources */ + #mergeVars (sources) { + // A null prototype makes assignment behave like spread for __proto__ and + // inherited setters, without repeatedly copying the growing merged object. + const merged = Object.create(null) + for (const vars of sources) Object.assign(merged, vars) + Object.setPrototypeOf(merged, Object.prototype) + return /** @type {T} */ (merged) + } + /** * Source-root-relative identity with POSIX separators, independent of checkout * location and output URL. Generated pages use their synthetic factory relname. @@ -237,21 +265,11 @@ export class PageData { */ get vars () { if (!this.#initialized) throw new Error(`Initialize PageData before accessing vars for page "${this.pageInfo?.path ?? ''}"`) + if (this.#varsCache && this.#varSourcesUnchanged()) return this.#varsCache const sources = this.#varSources() - if ( - this.#varsCache && - this.#varsCacheSources && - this.#varsCacheSources.length === sources.length && - this.#varsCacheSources.every((source, index) => source === sources[index]) - ) { - return this.#varsCache - } - try { - this.#varsCache = /** @type {T} */ (Object.freeze( - sources.reduce((merged, vars) => ({ ...merged, ...vars }), {}) - )) + this.#varsCache = /** @type {T} */ (Object.freeze(this.#mergeVars(sources))) this.#varsCacheSources = sources return this.#varsCache } catch (err) { @@ -396,8 +414,9 @@ export class PageData { } } + // First vars access must still observe source changes made after init. /** @type {object} */ - const finalVars = this.#varSources().reduce((merged, vars) => ({ ...merged, ...vars }), {}) + const finalVars = this.#mergeVars(this.#varSources()) // disable-eslint-next-line dot-notation if ('defaultStyle' in finalVars && finalVars.defaultStyle) { diff --git a/lib/build-pages/page-data.test.js b/lib/build-pages/page-data.test.js index b25e5f80..6cb615ba 100644 --- a/lib/build-pages/page-data.test.js +++ b/lib/build-pages/page-data.test.js @@ -141,14 +141,255 @@ test.describe('PageData.vars', () => { assert.notStrictEqual(removedVars, reorderedVars) assert.strictEqual(removedVars.fromLayout, 'inner value') + pd.layoutVars[0] = { name: 'replacement', vars: { fromLayout: 'replacement entry' } } + const replacedEntryVars = pd.vars + assert.notStrictEqual(replacedEntryVars, removedVars, 'replacing an array entry should invalidate the cache') + assert.strictEqual(replacedEntryVars.fromLayout, 'replacement entry') + + pd.layoutVars = pd.layoutVars.map(layer => ({ ...layer })) + assert.strictEqual(pd.vars, replacedEntryVars, 'only vars identities matter, not the array or wrapper identities') + pd.layoutVars = [] assert.strictEqual(pd.vars.fromLayout, undefined, 'removing all layers should drop their values') assert.strictEqual(pd.vars.title, 'Test') + + for (const key of /** @type {const} */ (['globalVars', 'pageVars', 'builderVars'])) { + const previous = pd.vars + pd[key] = { ...pd[key], fromSource: key } + const updated = pd.vars + assert.notStrictEqual(updated, previous, `replacing ${key} should invalidate the cache`) + assert.strictEqual(updated['fromSource'], key) + assert.strictEqual(pd.vars, updated) + assert.strictEqual(Object.isFrozen(updated), true) + } } finally { await rm(dir, { recursive: true, force: true }) } }) + test('merges separately at init and first access without mapping layouts or rereading vars on cache hits', async (t) => { + const dir = await mkdtemp(join(tmpdir(), 'domstack-pagedata-vars-work-test-')) + t.after(() => rm(dir, { recursive: true, force: true })) + const mdFile = join(dir, 'test.md') + await writeFile(mdFile, '# Test\n') + + let enumerations = 0 + let reads = 0 + const globalVars = new Proxy({ + layout: 'default', + get defaultStyle () { + reads++ + return true + }, + }, { + ownKeys (target) { + enumerations++ + return Reflect.ownKeys(target) + }, + }) + /** @type {PageData} */ + const pd = new PageData({ + pageInfo: mdPageInfo(mdFile), + globalVars, + globalStyle: undefined, + globalClient: undefined, + defaultStyle: 'default.css', + defaultClient: 'default.js', + builderOptions, + }) + await pd.init({ layouts: { default: fakeLayout } }) + assert.strictEqual(enumerations, 1) + assert.strictEqual(reads, 1) + assert.deepStrictEqual(pd.styles, ['/default.css']) + assert.deepStrictEqual(pd.scripts, ['/default.js']) + + const vars = pd.vars + assert.strictEqual(enumerations, 2, 'first access takes a fresh snapshot after initialization') + assert.strictEqual(reads, 2) + const map = t.mock.fn(pd.layoutVars.map) + pd.layoutVars.map = map + for (let index = 0; index < 100; index++) assert.strictEqual(pd.vars, vars) + await pd.init({ layouts: { default: fakeLayout } }) + assert.strictEqual(map.mock.callCount(), 0, 'cache hits must not map layout sources') + assert.strictEqual(enumerations, 2, 'cache hits and repeated init do not merge again') + assert.strictEqual(reads, 2) + + pd.pageVars = { fromPage: 'new source' } + const updated = pd.vars + assert.notStrictEqual(updated, vars) + assert.strictEqual(updated['fromPage'], 'new source') + assert.strictEqual(pd.vars, updated) + assert.strictEqual(enumerations, 3, 'an invalidation merges each source just once') + assert.strictEqual(reads, 3) + }) + + test('observes in-place source updates and getter changes between init and first vars access', async (t) => { + const dir = await mkdtemp(join(tmpdir(), 'domstack-pagedata-vars-snapshot-test-')) + t.after(() => rm(dir, { recursive: true, force: true })) + const mdFile = join(dir, 'test.md') + await writeFile(mdFile, '# Test\n') + + let getterValue = 'during init' + let reads = 0 + const globalVars = { + layout: 'default', + fromGlobal: 'during init', + get dynamic () { + reads++ + return getterValue + }, + } + /** @type {PageData} */ + const pd = new PageData({ + pageInfo: mdPageInfo(mdFile), + globalVars, + globalStyle: undefined, + globalClient: undefined, + defaultStyle: null, + defaultClient: null, + builderOptions, + }) + await pd.init({ layouts: { default: fakeLayout } }) + assert.strictEqual(reads, 1) + + globalVars.fromGlobal = 'before first access' + getterValue = 'before first access' + assert.strictEqual(pd.globalVars, globalVars, 'source identity has not changed') + const vars = pd.vars + assert.strictEqual(vars.fromGlobal, 'before first access') + assert.strictEqual(vars['dynamic'], 'before first access') + assert.strictEqual(reads, 2, 'first access must evaluate the getter again') + assert.strictEqual(Object.isFrozen(vars), true) + + globalVars.fromGlobal = 'after first access' + getterValue = 'after first access' + assert.strictEqual(pd.vars, vars) + assert.strictEqual(pd.vars.fromGlobal, 'before first access') + assert.strictEqual(pd.vars['dynamic'], 'before first access') + assert.strictEqual(reads, 2, 'later reads use the snapshot established by first access') + }) + + test('merges like object spread, including symbols, accessors and own __proto__ properties', async (t) => { + const dir = await mkdtemp(join(tmpdir(), 'domstack-pagedata-vars-spread-test-')) + t.after(() => rm(dir, { recursive: true, force: true })) + const mdFile = join(dir, 'test.md') + await writeFile(mdFile, '# Test\n') + /** @type {PageData} */ + const pd = new PageData({ + pageInfo: mdPageInfo(mdFile), + globalVars: { layout: 'default' }, + globalStyle: undefined, + globalClient: undefined, + defaultStyle: null, + defaultClient: null, + builderOptions, + }) + await pd.init({ layouts: { default: fakeLayout } }) + + const symbol = Symbol('enumerable') + const hiddenSymbol = Symbol('hidden') + const nested = { value: 'original' } + const prototypeValue = { notThePrototype: true } + /** @type {string[]} */ + const reads = [] + /** @param {string} name */ + const source = (name) => Object.defineProperties(Object.create({ inherited: 'not copied' }), { + [name]: { enumerable: true, value: name }, + shared: { enumerable: true, get () { reads.push(name); return name } }, + [symbol]: { enumerable: true, value: name }, + [hiddenSymbol]: { value: 'not copied' }, + hidden: { get () { throw new Error('non-enumerable getters must not run') } }, + ['__proto__']: { enumerable: true, value: name }, + }) + pd.globalVars = source('global') + const outerVars = source('outer') + const innerVars = source('inner') + pd.layoutVars = [ + { name: 'outer', vars: outerVars }, + { name: 'inner', vars: innerVars }, + ] + pd.pageVars = source('page') + pd.builderVars = { ...source('builder'), nested, ['__proto__']: prototypeValue } + reads.length = 0 + + const expected = Object.freeze({ + ...pd.globalVars, + ...outerVars, + ...innerVars, + ...pd.pageVars, + ...pd.builderVars, + }) + const expectedReads = [...reads] + reads.length = 0 + const vars = pd.vars + assert.deepStrictEqual(vars, expected) + assert.deepStrictEqual(Reflect.ownKeys(vars), Reflect.ownKeys(expected), 'preserve property order') + assert.deepStrictEqual(Object.getOwnPropertyDescriptors(vars), Object.getOwnPropertyDescriptors(expected)) + assert.deepStrictEqual(reads, expectedReads, 'read enumerable getters once in cascade order') + assert.strictEqual(Object.getPrototypeOf(vars), Object.prototype) + assert.strictEqual(Object.hasOwn(vars, '__proto__'), true) + assert.strictEqual(Reflect.get(vars, '__proto__'), prototypeValue) + assert.strictEqual(Reflect.get(vars, symbol), 'builder') + assert.strictEqual(Object.hasOwn(vars, hiddenSymbol), false) + assert.strictEqual(Object.hasOwn(vars, 'inherited'), false) + assert.strictEqual(Object.hasOwn(vars, 'hidden'), false) + assert.strictEqual(Object.isFrozen(vars), true) + assert.strictEqual(vars['nested'], nested) + assert.strictEqual(Object.isFrozen(nested), false, 'freezing remains shallow') + nested.value = 'changed' + assert.ok(pd.builderVars) + pd.builderVars['added'] = 'not visible until source replacement' + assert.strictEqual(pd.vars, vars, 'in-place source mutations do not invalidate an identity cache') + assert.strictEqual(vars['added'], undefined) + assert.strictEqual(Reflect.get(vars['nested'], 'value'), 'changed') + assert.deepStrictEqual(reads, expectedReads, 'cache hits do not re-read getters') + + pd.pageVars = null + pd.builderVars = null + assert.deepStrictEqual(pd.vars, Object.freeze({ ...pd.globalVars, ...outerVars, ...innerVars })) + }) + + test('keeps init errors unwrapped and contextualizes failed cache rebuilds without caching them', async (t) => { + const dir = await mkdtemp(join(tmpdir(), 'domstack-pagedata-vars-errors-test-')) + t.after(() => rm(dir, { recursive: true, force: true })) + const mdFile = join(dir, 'test.md') + await writeFile(mdFile, '# Test\n') + const cause = new Error('vars getter failed') + const brokenVars = { layout: 'default', get broken () { throw cause } } + /** + * @param {Partial} globalVars + * @returns {PageData} + */ + const createPage = (globalVars) => new PageData({ + pageInfo: mdPageInfo(mdFile), + globalVars, + globalStyle: undefined, + globalClient: undefined, + defaultStyle: null, + defaultClient: null, + builderOptions, + }) + const failed = createPage(brokenVars) + await assert.rejects(failed.init({ layouts: { default: fakeLayout } }), err => err === cause) + assert.throws(() => failed.vars, /Initialize PageData before accessing vars for page "blog\/post"/) + + const globalVars = { layout: 'default' } + const pd = createPage(globalVars) + await pd.init({ layouts: { default: fakeLayout } }) + const cached = pd.vars + pd.globalVars = brokenVars + for (let attempt = 0; attempt < 2; attempt++) { + assert.throws(() => pd.vars, { + message: 'Failed to resolve vars for page "blog/post": vars getter failed', + cause, + }) + } + pd.globalVars = globalVars + assert.strictEqual(pd.vars, cached, 'failed merges do not replace a valid cache or its source identities') + pd.globalVars = { layout: 'default', recovered: true } + assert.strictEqual(pd.vars['recovered'], true) + }) + test('resolves layout vars exports', async () => { const dir = await mkdtemp(join(tmpdir(), 'domstack-resolve-layout-vars-test-')) const layoutFile = join(dir, 'test.layout.mjs') From c1f8c01fd6e8afd0acb2e1edd1b65da5d72959ab Mon Sep 17 00:00:00 2001 From: Bret Comnes Date: Wed, 16 Sep 2026 18:34:58 -0700 Subject: [PATCH 07/20] docs: keep implementation guide focused on high-level behavior --- docs/implementation/README.md | 31 +------------------------------ 1 file changed, 1 insertion(+), 30 deletions(-) diff --git a/docs/implementation/README.md b/docs/implementation/README.md index 65504a9b..1b1c0ecc 100644 --- a/docs/implementation/README.md +++ b/docs/implementation/README.md @@ -167,24 +167,6 @@ Layout subscriptions contribute to page invalidation, but each layout still rece Page initialization uses a concurrency limit of `min(CPUs, 24)`. The final page and template rendering queues run in parallel, splitting that concurrency budget between them. -### Page-build module boundaries - -The page-build implementation lives in `lib/build-pages/`: - -- `index.js` launches the worker and preserves the page-build API and type exports. -- `worker.js` invokes the direct build coordinator without importing the worker-launching facade. -- `worker-protocol.js` defines transferable options and error metadata, strips generated rendering functions from error context, and restores domain errors in the parent thread. -- `build.js` coordinates preparation, global-data production, subscription-based selection, rendering, and reporting. -- `data/` keeps producer state, subscription tracking, and provider-specific data access in separate modules. -- `generated-pages/` streams factory definitions, validates output names, filters drafts, and reserves generated-page output paths. -- `outputs/` contains page writing, sidecar normalization, and sidecar persistence. -- `page-builders/` contains format adapters and the template builder. -- `page-data.js` retains the page-facing vars, layout, subscription, rendering, and output-hook interface. - -Output filters do not restrict the source-page collection supplied to the global-data producer. -Generated pages remain downstream consumers, and the worker returns candidate watch state rather than committing it. -The watch coordinator accepts that state only after a successful page build and output reconciliation. -Writes remain non-transactional, and successful page emissions are reported even when a later output hook fails. Variable Resolution Layers, from lowest to highest precedence: - **Domstack defaults** - Internal defaults such as the default `layout: 'root'`. @@ -212,18 +194,7 @@ Watch mode coordinates three independent watchers: Chokidar events pass through a pure planner before any rebuild executes. The planner reads an explicit snapshot of discovery, dependency maps, and the previous page-build outcome; it does not perform I/O or mutate that state. -The public `DomStack` class in `index.js` validates and normalizes options, runs one-shot builds, and delegates its watch API to an internal `DomStackWatcher` in `lib/watch/index.js`. -Watch coordination and its helpers live together in `lib/watch/`: - -- `index.js` owns the watch session, serializes events, executes plans, and releases its watchers, esbuild context, and server on shutdown. -- `plan.js` makes pure rebuild decisions from an explicit routing snapshot. -- `dependency-index.js` owns file-dependency maps and successful source-page and generated-page layout selections. -- `page-output-ledger.js` owns output claims, the sidecar write cache, and safe removal of obsolete page-owned files. -- `logging.js` formats rebuild trees, errors, and build summaries. - -Initial page builds and rebuilds share one acceptance path: record emitted files, reject page errors, reconcile obsolete outputs, refresh dependency routing, then commit subscriptions and the global-data baseline. -Recording writes is separate from accepting the baseline because failed builds and failed cleanup can still leave files on disk. -One coordinator and output ledger are retained per `DomStack` instance so output ownership survives stop/start cycles, while shutdown clears session resources and global-data state. +DOMStack manages the watch session, queues changes, rebuilds affected outputs, and shuts down the watchers and server when stopped.
 flowchart TD

From cf59229f80f7eeb8dd1a42c696232297be1489d3 Mon Sep 17 00:00:00 2001
From: Bret Comnes 
Date: Wed, 16 Sep 2026 19:07:50 -0700
Subject: [PATCH 08/20] refactor(build-pages): collect remaining subsystem
 boundaries

---
 index.js                                      |   2 +-
 lib/build-esbuild/index.js                    |   2 +-
 lib/build-pages/build.js                      |  22 ++-
 lib/build-pages/generated-pages/index.js      |   6 +-
 lib/build-pages/generated-pages/index.test.js |   2 +-
 .../{data => global-data}/data-deps.js        |   0
 .../global-data-state-types.test.ts           |   0
 .../global-data-state.js                      |   2 +-
 .../global-data-state.test.js                 |   2 +-
 .../global-data/resolve-global-data.js        |  35 ++++
 .../watch-dependencies.js                     |   0
 .../watch-dependencies.test.js                |   0
 lib/build-pages/index.js                      |  73 +-------
 .../{ => layouts}/resolve-layout-chain.js     |   0
 .../resolve-layout-chain.test.js              |   2 +-
 .../layouts/resolve-layout-name.js            |  22 +++
 .../layouts/resolve-layout-name.test.js       |  21 +++
 lib/build-pages/layouts/resolve-layout.js     | 130 +++++++++++++
 .../layouts/resolve-layout.test.js            |  24 +++
 lib/build-pages/outputs/page-output-writer.js |   2 +-
 lib/build-pages/outputs/page-writer.js        |   2 +-
 lib/build-pages/page-builders/index.js        |   2 +-
 .../{ => page}/page-data-page-outputs.test.js |  13 +-
 .../{ => page}/page-data-vars-catch.test.js   |   0
 lib/build-pages/{ => page}/page-data.js       | 174 ++----------------
 lib/build-pages/{ => page}/page-data.test.js  |  45 +----
 .../template-builder.js                       |   4 +-
 .../template-builder.test.js                  |   2 +-
 lib/build-pages/{ => vars}/resolve-vars.js    |  37 +---
 .../{ => vars}/resolve-vars.test.js           |   2 +-
 lib/build-pages/worker/index.js               |  64 +++++++
 .../protocol.js}                              |  12 +-
 .../protocol.test.js}                         |  16 +-
 lib/build-pages/{ => worker}/worker.js        |   4 +-
 lib/domstack-manifest/settings.js             |   2 +-
 .../compute-page-url.js                       |   2 +-
 lib/helpers/compute-page-url.test.js          |  21 +++
 .../fs-path-to-url.js                         |   0
 .../fs-path-to-url.test.js                    |   0
 lib/identify-pages.js                         |   2 +-
 lib/watch/index.js                            |   4 +-
 test-cases/nested-layouts/type-checks.ts      |   2 +-
 types.ts                                      |  10 +-
 43 files changed, 407 insertions(+), 360 deletions(-)
 rename lib/build-pages/{data => global-data}/data-deps.js (100%)
 rename lib/build-pages/{data => global-data}/global-data-state-types.test.ts (100%)
 rename lib/build-pages/{data => global-data}/global-data-state.js (99%)
 rename lib/build-pages/{data => global-data}/global-data-state.test.js (99%)
 create mode 100644 lib/build-pages/global-data/resolve-global-data.js
 rename lib/build-pages/{data => global-data}/watch-dependencies.js (100%)
 rename lib/build-pages/{data => global-data}/watch-dependencies.test.js (100%)
 rename lib/build-pages/{ => layouts}/resolve-layout-chain.js (100%)
 rename lib/build-pages/{ => layouts}/resolve-layout-chain.test.js (98%)
 create mode 100644 lib/build-pages/layouts/resolve-layout-name.js
 create mode 100644 lib/build-pages/layouts/resolve-layout-name.test.js
 create mode 100644 lib/build-pages/layouts/resolve-layout.js
 create mode 100644 lib/build-pages/layouts/resolve-layout.test.js
 rename lib/build-pages/{ => page}/page-data-page-outputs.test.js (97%)
 rename lib/build-pages/{ => page}/page-data-vars-catch.test.js (100%)
 rename lib/build-pages/{ => page}/page-data.js (67%)
 rename lib/build-pages/{ => page}/page-data.test.js (93%)
 rename lib/build-pages/{page-builders => templates}/template-builder.js (97%)
 rename lib/build-pages/{page-builders => templates}/template-builder.test.js (95%)
 rename lib/build-pages/{ => vars}/resolve-vars.js (58%)
 rename lib/build-pages/{ => vars}/resolve-vars.test.js (78%)
 create mode 100644 lib/build-pages/worker/index.js
 rename lib/build-pages/{worker-protocol.js => worker/protocol.js} (93%)
 rename lib/build-pages/{worker-protocol.test.js => worker/protocol.test.js} (95%)
 rename lib/build-pages/{ => worker}/worker.js (86%)
 rename lib/{build-pages => helpers}/compute-page-url.js (90%)
 create mode 100644 lib/helpers/compute-page-url.test.js
 rename lib/{build-pages/page-builders => helpers}/fs-path-to-url.js (100%)
 rename lib/{build-pages/page-builders => helpers}/fs-path-to-url.test.js (100%)

diff --git a/index.js b/index.js
index c178f4e5..8e72cd15 100644
--- a/index.js
+++ b/index.js
@@ -16,7 +16,7 @@ import { builder } from './lib/builder.js'
 import { createDomStackLogger } from './lib/logger.js'
 import { DomStackWatcher } from './lib/watch/index.js'
 
-export { PageData } from './lib/build-pages/page-data.js'
+export { PageData } from './lib/build-pages/page/page-data.js'
 export {
   DOMSTACK_MANIFEST_SCHEMA_ID,
   DOMSTACK_MANIFEST_SCHEMA_PATH,
diff --git a/lib/build-esbuild/index.js b/lib/build-esbuild/index.js
index f37f86c4..f513a0ae 100644
--- a/lib/build-esbuild/index.js
+++ b/lib/build-esbuild/index.js
@@ -8,7 +8,7 @@ import { writeFile } from 'fs/promises'
 import { join, relative, basename, resolve, extname } from 'path'
 import esbuild from 'esbuild'
 import { globalBundleAssets, pageBundleAssets, layoutBundleAssets } from '../file-conventions.js'
-import { resolveVars } from '../build-pages/resolve-vars.js'
+import { resolveVars } from '../build-pages/vars/resolve-vars.js'
 import {
   createDomstackManifestRecord,
   DEFAULT_DOMSTACK_MANIFEST_FILENAME,
diff --git a/lib/build-pages/build.js b/lib/build-pages/build.js
index 93e1b784..de907886 100644
--- a/lib/build-pages/build.js
+++ b/lib/build-pages/build.js
@@ -2,26 +2,28 @@
  * @import { BuilderOptions } from './outputs/page-writer.js'
  * @import { SiteData } from '../builder.js'
  * @import { PageInfo, PagesFileInfo } from '../identify-pages.js'
- * @import { ResolvedLayout } from './page-data.js'
- * @import { WatchConsumer } from './data/watch-dependencies.js'
- * @import { BuildPagesFilterOptions, WorkerBuildStepResult } from './worker-protocol.js'
+ * @import { ResolvedLayout } from './layouts/resolve-layout.js'
+ * @import { WatchConsumer } from './global-data/watch-dependencies.js'
+ * @import { BuildPagesFilterOptions, WorkerBuildStepResult } from './worker/protocol.js'
  */
 
 import { join, resolve } from 'path'
 import pMap from 'p-map'
 import { cpus } from 'os'
 import { keyBy } from '../helpers/key-by.js'
-import { resolveVars, resolveGlobalData } from './resolve-vars.js'
-import { templateBuilder } from './page-builders/index.js'
-import { PageData, resolveLayout } from './page-data.js'
-import { resolveLayoutChain } from './resolve-layout-chain.js'
+import { resolveVars } from './vars/resolve-vars.js'
+import { resolveGlobalData } from './global-data/resolve-global-data.js'
+import { templateBuilder } from './templates/template-builder.js'
+import { PageData } from './page/page-data.js'
+import { resolveLayout } from './layouts/resolve-layout.js'
+import { resolveLayoutChain } from './layouts/resolve-layout-chain.js'
 import { pageWriter } from './outputs/page-writer.js'
 import { DomStackDataError } from '../helpers/domstack-error.js'
-import { WatchDependencyTracker as WatchDependencyTrackerClass } from './data/watch-dependencies.js'
+import { WatchDependencyTracker as WatchDependencyTrackerClass } from './global-data/watch-dependencies.js'
 import { outputWarnings } from '../helpers/output-warnings.js'
-import { createGlobalDataState } from './data/global-data-state.js'
+import { createGlobalDataState } from './global-data/global-data-state.js'
 import { resolveGeneratedPageInfos } from './generated-pages/index.js'
-import { pageInfoForWorker, serializeBuildError } from './worker-protocol.js'
+import { pageInfoForWorker, serializeBuildError } from './worker/protocol.js'
 
 const MAX_CONCURRENCY = Math.min(cpus().length, 24)
 
diff --git a/lib/build-pages/generated-pages/index.js b/lib/build-pages/generated-pages/index.js
index 653b9546..d4f3eacd 100644
--- a/lib/build-pages/generated-pages/index.js
+++ b/lib/build-pages/generated-pages/index.js
@@ -2,14 +2,14 @@
  * @import { SiteData } from '../../builder.js'
  * @import { PageInfo, PagesFileInfo } from '../../identify-pages.js'
  * @import { GeneratedPageDefinition } from '../index.js'
- * @import { WatchDependencyTracker } from '../data/watch-dependencies.js'
+ * @import { WatchDependencyTracker } from '../global-data/watch-dependencies.js'
  */
 
 import { basename, dirname, isAbsolute, join, normalize, resolve } from 'path'
-import { computePageUrl } from '../compute-page-url.js'
+import { computePageUrl } from '../../helpers/compute-page-url.js'
 import { DomStackOutputConflictError } from '../../helpers/domstack-error.js'
 import { isAsyncIterable, isPlainObject } from '../../helpers/type-guards.js'
-import { createSubscribedData, resolveDataDeps } from '../data/data-deps.js'
+import { createSubscribedData, resolveDataDeps } from '../global-data/data-deps.js'
 
 /**
  * @param {unknown} value
diff --git a/lib/build-pages/generated-pages/index.test.js b/lib/build-pages/generated-pages/index.test.js
index 570188ca..d39f4923 100644
--- a/lib/build-pages/generated-pages/index.test.js
+++ b/lib/build-pages/generated-pages/index.test.js
@@ -8,7 +8,7 @@ import { mkdtemp, rm, writeFile } from 'node:fs/promises'
 import { tmpdir } from 'node:os'
 import { join } from 'node:path'
 import { resolveGeneratedPageInfos } from './index.js'
-import { WatchDependencyTracker } from '../data/watch-dependencies.js'
+import { WatchDependencyTracker } from '../global-data/watch-dependencies.js'
 import { DomStackOutputConflictError } from '../../helpers/domstack-error.js'
 
 /** @returns {SiteData} */
diff --git a/lib/build-pages/data/data-deps.js b/lib/build-pages/global-data/data-deps.js
similarity index 100%
rename from lib/build-pages/data/data-deps.js
rename to lib/build-pages/global-data/data-deps.js
diff --git a/lib/build-pages/data/global-data-state-types.test.ts b/lib/build-pages/global-data/global-data-state-types.test.ts
similarity index 100%
rename from lib/build-pages/data/global-data-state-types.test.ts
rename to lib/build-pages/global-data/global-data-state-types.test.ts
diff --git a/lib/build-pages/data/global-data-state.js b/lib/build-pages/global-data/global-data-state.js
similarity index 99%
rename from lib/build-pages/data/global-data-state.js
rename to lib/build-pages/global-data/global-data-state.js
index bd3fed78..2359d870 100644
--- a/lib/build-pages/data/global-data-state.js
+++ b/lib/build-pages/global-data/global-data-state.js
@@ -1,5 +1,5 @@
 /**
- * @import { PageData } from '../page-data.js'
+ * @import { PageData } from '../page/page-data.js'
  * @import { GlobalDataFunctionParams } from '../index.js'
  * @import { WatchEvent } from '../../watch/plan.js'
  */
diff --git a/lib/build-pages/data/global-data-state.test.js b/lib/build-pages/global-data/global-data-state.test.js
similarity index 99%
rename from lib/build-pages/data/global-data-state.test.js
rename to lib/build-pages/global-data/global-data-state.test.js
index e969626a..dd3805bf 100644
--- a/lib/build-pages/data/global-data-state.test.js
+++ b/lib/build-pages/global-data/global-data-state.test.js
@@ -12,7 +12,7 @@ import { createGlobalDataState } from './global-data-state.js'
 import { buildPages, buildPagesDirect } from '../index.js'
 import { identifyPages } from '../../identify-pages.js'
 import { classifyWatchEvent } from '../../watch/plan.js'
-import { resolveGlobalData } from '../resolve-vars.js'
+import { resolveGlobalData } from './resolve-global-data.js'
 
 /** @param {TestContext} t @param {string} producer */
 async function fixture (t, producer) {
diff --git a/lib/build-pages/global-data/resolve-global-data.js b/lib/build-pages/global-data/resolve-global-data.js
new file mode 100644
index 00000000..1b42dbc1
--- /dev/null
+++ b/lib/build-pages/global-data/resolve-global-data.js
@@ -0,0 +1,35 @@
+/**
+ * @import { GlobalDataFunctionParams } from '../index.js'
+ */
+
+import { isFunction, isObject } from '../../helpers/type-guards.js'
+
+/**
+ * Resolve and call a global.data.js file with initialized source-backed pages.
+ * Receives fully resolved PageData instances (with .vars, .pageInfo, etc.) so
+ * that global.data.js can filter and aggregate by layout, publishDate, title, etc.
+ * Generated pages are created afterward, and downstream consumers may subscribe
+ * to named values from the returned data.
+ * Returns an empty object if no file is provided or the file exports nothing useful.
+ *
+ * @param {object} params
+ * @param {string | undefined} [params.globalDataPath] - Path to the global.data file.
+ * @param {GlobalDataFunctionParams} params.context - Callback context prepared by the page phase.
+ * @returns {Promise}
+ */
+export async function resolveGlobalData ({ globalDataPath, context }) {
+  if (!globalDataPath) return {}
+
+  const imported = await import(globalDataPath)
+  const maybeGlobalData = imported.default
+
+  if (isFunction(maybeGlobalData)) {
+    const result = await maybeGlobalData(context)
+    if (isObject(result)) return result
+    throw new Error('global.data default export function must return an object')
+  } else if (isObject(maybeGlobalData)) {
+    return maybeGlobalData
+  } else {
+    return {}
+  }
+}
diff --git a/lib/build-pages/data/watch-dependencies.js b/lib/build-pages/global-data/watch-dependencies.js
similarity index 100%
rename from lib/build-pages/data/watch-dependencies.js
rename to lib/build-pages/global-data/watch-dependencies.js
diff --git a/lib/build-pages/data/watch-dependencies.test.js b/lib/build-pages/global-data/watch-dependencies.test.js
similarity index 100%
rename from lib/build-pages/data/watch-dependencies.test.js
rename to lib/build-pages/global-data/watch-dependencies.test.js
diff --git a/lib/build-pages/index.js b/lib/build-pages/index.js
index 58ca1fe1..243ca039 100644
--- a/lib/build-pages/index.js
+++ b/lib/build-pages/index.js
@@ -1,26 +1,21 @@
 /**
  * @import { PageFunction } from './outputs/page-writer.js'
- * @import { TemplateReport } from './page-builders/template-builder.js'
+ * @import { TemplateReport } from './templates/template-builder.js'
  * @import { BuildStep, DomStackOpts } from '../builder.js'
  * @import { PagesFileInfo } from '../identify-pages.js'
- * @import { PageData } from './page-data.js'
+ * @import { PageData } from './page/page-data.js'
  * @import { DomstackManifestRecord } from '../domstack-manifest/index.js'
- * @import { WatchDependencyState } from './data/watch-dependencies.js'
+ * @import { WatchDependencyState } from './global-data/watch-dependencies.js'
  * @import { PageOutputCache } from './outputs/page-output-writer.js'
- * @import { GlobalDataBaseline, GlobalDataChanges } from './data/global-data-state.js'
- * @import { BuildPagesFilterOptions as WorkerBuildPagesFilterOptions, WorkerErrorData as ProtocolWorkerErrorData, WorkerBuildStepResult as ProtocolWorkerBuildStepResult } from './worker-protocol.js'
+ * @import { GlobalDataBaseline, GlobalDataChanges } from './global-data/global-data-state.js'
+ * @import { BuildPagesFilterOptions as WorkerBuildPagesFilterOptions, WorkerErrorData as ProtocolWorkerErrorData, WorkerBuildStepResult as ProtocolWorkerBuildStepResult } from './worker/protocol.js'
  */
 
-import { Worker } from 'worker_threads'
-import { join } from 'path'
-import { restoreWorkerError } from './worker-protocol.js'
-
+export { buildPages } from './worker/index.js'
 export { buildPagesDirect } from './build.js'
-export { serializeBuildError } from './worker-protocol.js'
+export { serializeBuildError } from './worker/protocol.js'
 export { pageBuilders } from './page-builders/index.js'
 
-const __dirname = import.meta.dirname
-
 /**
  * @typedef {object} PageReport
  * @property {string} pageFilePath
@@ -150,57 +145,3 @@ const __dirname = import.meta.dirname
  * @typedef {ProtocolWorkerErrorData} WorkerErrorData
  * @typedef {ProtocolWorkerBuildStepResult} WorkerBuildStepResult
  */
-
-/**
- * Page builder glue. Most of the magic happens in the builders.
- *
- * @type {PageBuildStep}
- */
-export function buildPages (src, dest, siteData, opts) {
-  // Only page-build filters cross the worker boundary. General build options
-  // can contain functions (manifest hooks and predicates) or logger instances,
-  // neither of which can be structured-cloned.
-  /** @type {BuildPagesFilterOptions} */
-  const workerOpts = {
-    pageFilterPaths: opts?.pageFilterPaths,
-    templateFilterPaths: opts?.templateFilterPaths,
-    pagesFileFilterPaths: opts?.pagesFileFilterPaths,
-    buildDrafts: opts?.buildDrafts,
-    previousWatchDependencies: opts?.previousWatchDependencies,
-    trackWatchDependencies: opts?.trackWatchDependencies,
-    previousPageOutputCache: opts?.previousPageOutputCache,
-    previousGlobalDataBaseline: opts?.globalDataInputChanges?.resetReason === undefined ? opts?.previousGlobalDataBaseline : undefined,
-    globalDataInputChanges: opts?.globalDataInputChanges,
-  }
-
-  return new Promise((resolve, reject) => {
-    const worker = new Worker(join(__dirname, 'worker.js'), {
-      workerData: { src, dest, siteData, opts: workerOpts },
-    })
-
-    worker.once('message', message => {
-      /** @type { WorkerBuildStepResult }  */
-      const workerReport = message
-
-      /** @type {PageBuildStepResult} */
-      const buildReport = {
-        type: workerReport.type,
-        report: workerReport.report,
-        outputs: workerReport.outputs,
-        errors: [],
-        warnings: workerReport.warnings ?? [],
-      }
-
-      if (workerReport.errors.length > 0) {
-        buildReport.errors = workerReport.errors.map(({ error, errorData = {} }) => {
-          return restoreWorkerError(error, errorData)
-        })
-      }
-      resolve(buildReport)
-    })
-    worker.once('error', reject)
-    worker.once('exit', (code) => {
-      if (code !== 0) { reject(new Error(`Worker stopped with exit code ${code}`)) }
-    })
-  })
-}
diff --git a/lib/build-pages/resolve-layout-chain.js b/lib/build-pages/layouts/resolve-layout-chain.js
similarity index 100%
rename from lib/build-pages/resolve-layout-chain.js
rename to lib/build-pages/layouts/resolve-layout-chain.js
diff --git a/lib/build-pages/resolve-layout-chain.test.js b/lib/build-pages/layouts/resolve-layout-chain.test.js
similarity index 98%
rename from lib/build-pages/resolve-layout-chain.test.js
rename to lib/build-pages/layouts/resolve-layout-chain.test.js
index fbbbe7bb..acb5e6cb 100644
--- a/lib/build-pages/resolve-layout-chain.test.js
+++ b/lib/build-pages/layouts/resolve-layout-chain.test.js
@@ -4,7 +4,7 @@ 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'
+import { resolveLayout } from './resolve-layout.js'
 
 test('resolves explicit parent chains without treating vars.layout as a parent', () => {
   const root = { name: 'root' }
diff --git a/lib/build-pages/layouts/resolve-layout-name.js b/lib/build-pages/layouts/resolve-layout-name.js
new file mode 100644
index 00000000..385b0aab
--- /dev/null
+++ b/lib/build-pages/layouts/resolve-layout-name.js
@@ -0,0 +1,22 @@
+/**
+ * Resolve the selected layout name without constructing a partial vars object.
+ *
+ * Layout selection intentionally uses only pre-layout sources to avoid circular
+ * dependency on the selected layout's own vars. The lookup preserves the same
+ * precedence as the previous spread: builder/page-frontmatter vars, then
+ * page.vars, then global vars.
+ *
+ * @param {object} globalVars
+ * @param {object | null} pageVars
+ * @param {object | null} builderVars
+ * @returns {string}
+ */
+export function resolveLayoutName (globalVars, pageVars, builderVars) {
+  for (const source of [builderVars, pageVars, globalVars]) {
+    if (!source || !('layout' in source)) continue
+    if (typeof source.layout !== 'string') throw new Error('Layout variable must be a string')
+    return source.layout
+  }
+
+  throw new Error('Page variables missing a layout var')
+}
diff --git a/lib/build-pages/layouts/resolve-layout-name.test.js b/lib/build-pages/layouts/resolve-layout-name.test.js
new file mode 100644
index 00000000..26e2ee39
--- /dev/null
+++ b/lib/build-pages/layouts/resolve-layout-name.test.js
@@ -0,0 +1,21 @@
+import { test } from 'node:test'
+import assert from 'node:assert/strict'
+import { resolveLayoutName } from './resolve-layout-name.js'
+
+test('selects builder, companion, then global layout without merging unrelated vars', () => {
+  const globalVars = { layout: 'global' }
+  const pageVars = { layout: 'companion' }
+  const builderVars = {
+    layout: 'builder',
+    get title () { throw new Error('Unrelated vars must not be read') },
+  }
+  assert.equal(resolveLayoutName(globalVars, pageVars, builderVars), 'builder')
+  assert.equal(resolveLayoutName(globalVars, pageVars, {}), 'companion')
+  assert.equal(resolveLayoutName(globalVars, null, null), 'global')
+})
+
+test('rejects missing or invalid selected layouts instead of falling back', () => {
+  assert.throws(() => resolveLayoutName({}, null, null), /Page variables missing a layout var/)
+  assert.throws(() => resolveLayoutName({ layout: 'global' }, { layout: undefined }, null), /Layout variable must be a string/)
+  assert.throws(() => resolveLayoutName({ layout: 'global' }, null, { layout: false }), /Layout variable must be a string/)
+})
diff --git a/lib/build-pages/layouts/resolve-layout.js b/lib/build-pages/layouts/resolve-layout.js
new file mode 100644
index 00000000..21d227d0
--- /dev/null
+++ b/lib/build-pages/layouts/resolve-layout.js
@@ -0,0 +1,130 @@
+/**
+ * @import { PageInfo } from '../../identify-pages.js'
+ * @import { PageOutputsFunction } from '../outputs/page-outputs.js'
+ */
+
+import { pathToFileURL } from 'node:url'
+import { resolveVarsExport } from '../vars/resolve-vars.js'
+import { validatePageOutputsHook } from '../outputs/page-outputs.js'
+
+/**
+ * Resolves a layout from an ESM module.
+ *
+ * @function
+ * @template {Record} T - The type of variables for the layout
+ * @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)
+ * @template {object} [D=Record] - Declared global data.
+ * @param {string} layoutPath - The string path to the layout ESM module.
+ * @returns {Promise<{ render: InternalLayoutFunction, vars: Partial, parentLayout: string | undefined, pageOutputs: PageOutputsFunction | undefined, source: string }>} The resolved layout module exports.
+ */
+export async function resolveLayout (layoutPath) {
+  const { default: layout, vars, parentLayout, pageOutputs } = await import(pathToFileURL(layoutPath).href)
+  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,
+    source: layoutPath,
+    pageOutputs: validatePageOutputsHook(pageOutputs, layoutPath),
+    vars: /** @type {Partial} */ (await resolveVarsExport(vars, 'Layout vars')),
+  }
+}
+
+/**
+ * Synchronous layout vars export.
+ *
+ * Layout modules may export `vars` as an object or function. These vars are
+ * merged into `PageData.vars` after global vars and before page/frontmatter vars.
+ *
+ * @template {Record} T - The layout vars shape.
+ * @callback LayoutVarsFunction
+ * @returns {T}
+ */
+
+/**
+ * Asynchronous layout vars export.
+ *
+ * @template {Record} T - The layout vars shape.
+ * @callback AsyncLayoutVarsFunction
+ * @returns {Promise}
+ */
+
+/**
+ * Layout vars export value.
+ *
+ * @template {Record} T - The layout vars shape.
+ * @typedef {T | LayoutVarsFunction | AsyncLayoutVarsFunction} LayoutVars
+ */
+
+/**
+  * Common parameters for layout functions.
+  *
+  * @template {Record} T - The type of variables passed to the layout function
+  * @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)
+  * @template {object} [D=Record] - Declared global data.
+  * @typedef {object} LayoutFunctionParams
+  * @property {T} vars - All default, global, layout, page, and builder vars shallow merged.
+  * @property {string[]} [scripts] - Array of script URLs to include.
+  * @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 {D} data - Global data declared by this renderer, independent of other layouts and the page.
+  * @property {Object} [workers] - Map of worker names to their output paths
+  */
+
+/**
+  * Callback for rendering a layout, synchronously or asynchronously.
+  *
+  * @template {Record} T - The type of variables passed to the layout function
+  * @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)
+  * @template {object} [D=Record] - Declared global data.
+  * @callback LayoutFunction
+  * @param {LayoutFunctionParams} params - The parameters for the layout.
+  * @returns {V | Promise} The rendered content.
+  */
+
+/**
+  * Asynchronous callback for rendering a layout.
+  *
+  * @template {Record} T - The type of variables passed to the layout function
+  * @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)
+  * @template {object} [D=Record] - Declared global data.
+  * @callback AsyncLayoutFunction
+  * @param {LayoutFunctionParams} params - The parameters for the layout.
+  * @returns {Promise} The rendered content.
+  */
+
+/**
+  * Callback for rendering a layout (can be sync or async).
+  *
+  * @template {Record} T - The type of variables passed to the layout function
+  * @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)
+  * @template {object} [D=Record] - Declared global data.
+  * @typedef {LayoutFunction} InternalLayoutFunction
+  */
+
+/**
+ * A resolved layout module with its render function and associated asset paths.
+ *
+ * @template {Record} T - The type of variables for the layout
+ * @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)
+ * @template {object} [D=Record] - Declared global data.
+ * @typedef ResolvedLayout
+ * @property {InternalLayoutFunction} render - The layout function
+ * @property {Partial} [vars] - Variables exported by the layout module.
+ * @property {PageOutputsFunction | undefined} [pageOutputs] - Explicit output-phase hook.
+ * @property {string} [source] - Layout module path for diagnostics.
+ * @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
+ */
diff --git a/lib/build-pages/layouts/resolve-layout.test.js b/lib/build-pages/layouts/resolve-layout.test.js
new file mode 100644
index 00000000..7e29274b
--- /dev/null
+++ b/lib/build-pages/layouts/resolve-layout.test.js
@@ -0,0 +1,24 @@
+import { test } from 'node:test'
+import assert from 'node:assert'
+import { mkdtemp, writeFile, rm } from 'node:fs/promises'
+import { join } from 'node:path'
+import { tmpdir } from 'node:os'
+import { resolveLayout } from './resolve-layout.js'
+
+test('resolves layout vars exports', async () => {
+  const dir = await mkdtemp(join(tmpdir(), 'domstack-resolve-layout-vars-test-'))
+  const layoutFile = join(dir, 'test.layout.mjs')
+
+  try {
+    await writeFile(layoutFile, `export const vars = async () => ({ fromLayout: 'layout vars' })
+export default function layout ({ children }) { return String(children) }
+`)
+
+    const layout = await resolveLayout(layoutFile)
+    const layoutVars = /** @type {{ fromLayout?: unknown }} */ (layout.vars)
+    assert.strictEqual(layoutVars.fromLayout, 'layout vars')
+    assert.strictEqual(typeof layout.render, 'function')
+  } finally {
+    await rm(dir, { recursive: true, force: true })
+  }
+})
diff --git a/lib/build-pages/outputs/page-output-writer.js b/lib/build-pages/outputs/page-output-writer.js
index d650dfaa..7a2a1927 100644
--- a/lib/build-pages/outputs/page-output-writer.js
+++ b/lib/build-pages/outputs/page-output-writer.js
@@ -1,6 +1,6 @@
 /**
  * @import { Stats } from 'node:fs'
- * @import { PageData } from '../page-data.js'
+ * @import { PageData } from '../page/page-data.js'
  *
  * @typedef {Map} PageOutputCache
  */
diff --git a/lib/build-pages/outputs/page-writer.js b/lib/build-pages/outputs/page-writer.js
index 7d139a7a..abb4a845 100644
--- a/lib/build-pages/outputs/page-writer.js
+++ b/lib/build-pages/outputs/page-writer.js
@@ -1,6 +1,6 @@
 /**
  * @import { PageInfo } from '../../identify-pages.js'
- * @import { PageData as PageDataClass } from '../page-data.js'
+ * @import { PageData as PageDataClass } from '../page/page-data.js'
  * @import { DomstackManifestRecord } from '../../domstack-manifest/index.js'
  * @import { PageOutputsFunction } from './page-outputs.js'
  * @import { PageOutputCache } from './page-output-writer.js'
diff --git a/lib/build-pages/page-builders/index.js b/lib/build-pages/page-builders/index.js
index 837be348..1928dfb6 100644
--- a/lib/build-pages/page-builders/index.js
+++ b/lib/build-pages/page-builders/index.js
@@ -1,7 +1,7 @@
 import { mdBuilder } from './md/index.js'
 import { jsBuilder } from './js/index.js'
 import { htmlBuilder } from './html/index.js'
-export { templateBuilder } from './template-builder.js'
+export { templateBuilder } from '../templates/template-builder.js'
 
 export const pageBuilders = {
   md: mdBuilder,
diff --git a/lib/build-pages/page-data-page-outputs.test.js b/lib/build-pages/page/page-data-page-outputs.test.js
similarity index 97%
rename from lib/build-pages/page-data-page-outputs.test.js
rename to lib/build-pages/page/page-data-page-outputs.test.js
index 8668c357..9017f463 100644
--- a/lib/build-pages/page-data-page-outputs.test.js
+++ b/lib/build-pages/page/page-data-page-outputs.test.js
@@ -1,17 +1,18 @@
 /**
- * @import { PageInfo } from '../identify-pages.js'
- * @import { ResolvedLayout } from './page-data.js'
+ * @import { PageInfo } from '../../identify-pages.js'
+ * @import { ResolvedLayout } from '../layouts/resolve-layout.js'
  * @import { TestContext } from 'node:test'
- * @import { PageOutputCache } from './outputs/page-output-writer.js'
+ * @import { PageOutputCache } from '../outputs/page-output-writer.js'
  */
 import { test } from 'node:test'
 import assert from 'node:assert/strict'
 import { mkdtemp, readFile, writeFile, rm } from 'node:fs/promises'
 import { tmpdir } from 'node:os'
 import { join } from 'node:path'
-import { PageData, resolveLayout } from './page-data.js'
-import { identifyPages } from '../identify-pages.js'
-import { pageWriter } from './outputs/page-writer.js'
+import { PageData } from './page-data.js'
+import { resolveLayout } from '../layouts/resolve-layout.js'
+import { identifyPages } from '../../identify-pages.js'
+import { pageWriter } from '../outputs/page-writer.js'
 
 /**
  * @param {TestContext} t
diff --git a/lib/build-pages/page-data-vars-catch.test.js b/lib/build-pages/page/page-data-vars-catch.test.js
similarity index 100%
rename from lib/build-pages/page-data-vars-catch.test.js
rename to lib/build-pages/page/page-data-vars-catch.test.js
diff --git a/lib/build-pages/page-data.js b/lib/build-pages/page/page-data.js
similarity index 67%
rename from lib/build-pages/page-data.js
rename to lib/build-pages/page/page-data.js
index 923caca7..36fccf6a 100644
--- a/lib/build-pages/page-data.js
+++ b/lib/build-pages/page/page-data.js
@@ -1,150 +1,29 @@
 /**
- * @import { PageInfo } from '../identify-pages.js'
- * @import { DomstackManifestRecord } from '../domstack-manifest/index.js'
- * @import { DomStackWarning } from '../helpers/domstack-warning.js'
- * @import { BuilderOptions, InternalPageFunction } from './outputs/page-writer.js'
- * @import { PageOutputsFunction, PageOutputProvenance, CollectedPageOutput } from './outputs/page-outputs.js'
+ * @import { PageInfo } from '../../identify-pages.js'
+ * @import { ResolvedLayout } from '../layouts/resolve-layout.js'
+ * @import { DomstackManifestRecord } from '../../domstack-manifest/index.js'
+ * @import { DomStackWarning } from '../../helpers/domstack-warning.js'
+ * @import { BuilderOptions, InternalPageFunction } from '../outputs/page-writer.js'
+ * @import { PageOutputsFunction, PageOutputProvenance, CollectedPageOutput } from '../outputs/page-outputs.js'
  */
 
 import { readFile } from 'node:fs/promises'
 import { normalize } from 'node:path'
-import { toPosix } from '../helpers/path.js'
-import { resolveVars, resolvePostVars, resolveVarsExport } from './resolve-vars.js'
-import { pageBuilders } from './page-builders/index.js'
-import { parseMdFileContents } from './page-builders/md/parse-md.js'
-import { createSubscribedData, extractDataDeps } from './data/data-deps.js'
-import { DomStackDataError } from '../helpers/domstack-error.js'
+import { toPosix } from '../../helpers/path.js'
+import { resolveVars, resolvePostVars } from '../vars/resolve-vars.js'
+import { pageBuilders } from '../page-builders/index.js'
+import { parseMdFileContents } from '../page-builders/md/parse-md.js'
+import { createSubscribedData, extractDataDeps } from '../global-data/data-deps.js'
+import { DomStackDataError } from '../../helpers/domstack-error.js'
 import pretty from 'pretty'
-import { resolveLayoutChain } from './resolve-layout-chain.js'
-import { createPageOutputsPage, normalizePageOutputs, validatePageOutputsHook } from './outputs/page-outputs.js'
-import { pathToFileURL } from 'node:url'
+import { resolveLayoutChain } from '../layouts/resolve-layout-chain.js'
+import { resolveLayoutName } from '../layouts/resolve-layout-name.js'
+import { createPageOutputsPage, normalizePageOutputs, validatePageOutputsHook } from '../outputs/page-outputs.js'
 
 /**
  * @typedef {Object} WorkerFiles
  */
 
-/**
- * Resolves a layout from an ESM module.
- *
- * @function
- * @template {Record} T - The type of variables for the layout
- * @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)
- * @template {object} [D=Record] - Declared global data.
- * @param {string} layoutPath - The string path to the layout ESM module.
- * @returns {Promise<{ render: InternalLayoutFunction, vars: Partial, parentLayout: string | undefined, pageOutputs: PageOutputsFunction | undefined, source: string }>} The resolved layout module exports.
- */
-export async function resolveLayout (layoutPath) {
-  const { default: layout, vars, parentLayout, pageOutputs } = await import(pathToFileURL(layoutPath).href)
-  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,
-    source: layoutPath,
-    pageOutputs: validatePageOutputsHook(pageOutputs, layoutPath),
-    vars: /** @type {Partial} */ (await resolveVarsExport(vars, 'Layout vars')),
-  }
-}
-
-/**
- * Synchronous layout vars export.
- *
- * Layout modules may export `vars` as an object or function. These vars are
- * merged into `PageData.vars` after global vars and before page/frontmatter vars.
- *
- * @template {Record} T - The layout vars shape.
- * @callback LayoutVarsFunction
- * @returns {T}
- */
-
-/**
- * Asynchronous layout vars export.
- *
- * @template {Record} T - The layout vars shape.
- * @callback AsyncLayoutVarsFunction
- * @returns {Promise}
- */
-
-/**
- * Layout vars export value.
- *
- * @template {Record} T - The layout vars shape.
- * @typedef {T | LayoutVarsFunction | AsyncLayoutVarsFunction} LayoutVars
- */
-
-/**
-  * Common parameters for layout functions.
-  *
-  * @template {Record} T - The type of variables passed to the layout function
-  * @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)
-  * @template {object} [D=Record] - Declared global data.
-  * @typedef {object} LayoutFunctionParams
-  * @property {T} vars - All default, global, layout, page, and builder vars shallow merged.
-  * @property {string[]} [scripts] - Array of script URLs to include.
-  * @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 {D} data - Global data declared by this renderer, independent of other layouts and the page.
-  * @property {Object} [workers] - Map of worker names to their output paths
-  */
-
-/**
-  * Callback for rendering a layout, synchronously or asynchronously.
-  *
-  * @template {Record} T - The type of variables passed to the layout function
-  * @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)
-  * @template {object} [D=Record] - Declared global data.
-  * @callback LayoutFunction
-  * @param {LayoutFunctionParams} params - The parameters for the layout.
-  * @returns {V | Promise} The rendered content.
-  */
-
-/**
-  * Asynchronous callback for rendering a layout.
-  *
-  * @template {Record} T - The type of variables passed to the layout function
-  * @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)
-  * @template {object} [D=Record] - Declared global data.
-  * @callback AsyncLayoutFunction
-  * @param {LayoutFunctionParams} params - The parameters for the layout.
-  * @returns {Promise} The rendered content.
-  */
-
-/**
-  * Callback for rendering a layout (can be sync or async).
-  *
-  * @template {Record} T - The type of variables passed to the layout function
-  * @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)
-  * @template {object} [D=Record] - Declared global data.
-  * @typedef {LayoutFunction} InternalLayoutFunction
-  */
-
-/**
- * A resolved layout module with its render function and associated asset paths.
- *
- * @template {Record} T - The type of variables for the layout
- * @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)
- * @template {object} [D=Record] - Declared global data.
- * @typedef ResolvedLayout
- * @property {InternalLayoutFunction} render - The layout function
- * @property {Partial} [vars] - Variables exported by the layout module.
- * @property {PageOutputsFunction | undefined} [pageOutputs] - Explicit output-phase hook.
- * @property {string} [source] - Layout module path for diagnostics.
- * @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
- */
-
 /**
  * Represents the data for a page.
  * @template {Record} T - The type of variables for the page data
@@ -517,26 +396,3 @@ export class PageData {
     return pretty(String(rendered))
   }
 }
-
-/**
- * Resolve the selected layout name without constructing a partial vars object.
- *
- * Layout selection intentionally uses only pre-layout sources to avoid circular
- * dependency on the selected layout's own vars. The lookup preserves the same
- * precedence as the previous spread: builder/page-frontmatter vars, then
- * page.vars, then global vars.
- *
- * @param {object} globalVars
- * @param {object | null} pageVars
- * @param {object | null} builderVars
- * @returns {string}
- */
-function resolveLayoutName (globalVars, pageVars, builderVars) {
-  for (const source of [builderVars, pageVars, globalVars]) {
-    if (!source || !('layout' in source)) continue
-    if (typeof source.layout !== 'string') throw new Error('Layout variable must be a string')
-    return source.layout
-  }
-
-  throw new Error('Page variables missing a layout var')
-}
diff --git a/lib/build-pages/page-data.test.js b/lib/build-pages/page/page-data.test.js
similarity index 93%
rename from lib/build-pages/page-data.test.js
rename to lib/build-pages/page/page-data.test.js
index 6cb615ba..83e22464 100644
--- a/lib/build-pages/page-data.test.js
+++ b/lib/build-pages/page/page-data.test.js
@@ -1,7 +1,7 @@
 /**
- * @import { PageInfo } from '../identify-pages.js'
- * @import { ResolvedLayout } from './page-data.js'
- * @import { BuilderOptions } from './outputs/page-writer.js'
+ * @import { PageInfo } from '../../identify-pages.js'
+ * @import { ResolvedLayout } from '../layouts/resolve-layout.js'
+ * @import { BuilderOptions } from '../outputs/page-writer.js'
  */
 
 import { test } from 'node:test'
@@ -9,8 +9,7 @@ import assert from 'node:assert'
 import { mkdtemp, writeFile, rm } from 'node:fs/promises'
 import { join, sep } from 'node:path'
 import { tmpdir } from 'node:os'
-import { PageData, resolveLayout } from './page-data.js'
-import { computePageUrl } from './compute-page-url.js'
+import { PageData } from './page-data.js'
 
 /**
  * @typedef {Record & { layout: string, title?: string, fromGlobal?: string, fromLayout?: string, fromPage?: string }} TestVars
@@ -390,24 +389,6 @@ test.describe('PageData.vars', () => {
     assert.strictEqual(pd.vars['recovered'], true)
   })
 
-  test('resolves layout vars exports', async () => {
-    const dir = await mkdtemp(join(tmpdir(), 'domstack-resolve-layout-vars-test-'))
-    const layoutFile = join(dir, 'test.layout.mjs')
-
-    try {
-      await writeFile(layoutFile, `export const vars = async () => ({ fromLayout: 'layout vars' })
-export default function layout ({ children }) { return String(children) }
-`)
-
-      const layout = await resolveLayout(layoutFile)
-      const layoutVars = /** @type {{ fromLayout?: unknown }} */ (layout.vars)
-      assert.strictEqual(layoutVars.fromLayout, 'layout vars')
-      assert.strictEqual(typeof layout.render, 'function')
-    } finally {
-      await rm(dir, { recursive: true, force: true })
-    }
-  })
-
   test('preserves every layout vars layer while merging between global and page vars', async () => {
     const dir = await mkdtemp(join(tmpdir(), 'domstack-pagedata-layout-vars-test-'))
     const mdFile = join(dir, 'test.md')
@@ -673,21 +654,3 @@ export default function layout ({ children }) { return String(children) }
     )
   })
 })
-
-test.describe('computePageUrl', () => {
-  test('root index.html maps to /', () => {
-    assert.strictEqual(computePageUrl({ path: '', outputName: 'index.html' }), '/')
-  })
-
-  test('nested index.html gets a trailing-slash URL', () => {
-    assert.strictEqual(computePageUrl({ path: 'blog/post', outputName: 'index.html' }), '/blog/post/')
-  })
-
-  test('non-index output includes filename in URL', () => {
-    assert.strictEqual(computePageUrl({ path: 'md-page', outputName: 'loose-md.html' }), '/md-page/loose-md.html')
-  })
-
-  test('non-index file at root includes filename only', () => {
-    assert.strictEqual(computePageUrl({ path: '', outputName: 'robots.txt' }), '/robots.txt')
-  })
-})
diff --git a/lib/build-pages/page-builders/template-builder.js b/lib/build-pages/templates/template-builder.js
similarity index 97%
rename from lib/build-pages/page-builders/template-builder.js
rename to lib/build-pages/templates/template-builder.js
index 62a87973..9df67519 100644
--- a/lib/build-pages/page-builders/template-builder.js
+++ b/lib/build-pages/templates/template-builder.js
@@ -1,7 +1,7 @@
 /**
  * @import { TemplateInfo } from '../../identify-pages.js'
  * @import { DomstackManifestRecord } from '../../domstack-manifest/index.js'
- * @import { WatchDependencyTracker } from '../data/watch-dependencies.js'
+ * @import { WatchDependencyTracker } from '../global-data/watch-dependencies.js'
  */
 
 import { dirname, join, relative, resolve } from 'node:path'
@@ -9,7 +9,7 @@ import { writeFile, mkdir } from 'fs/promises'
 import { createDomstackManifestRecord } from '../../domstack-manifest/index.js'
 import { assertInsideDest, toPosix } from '../../helpers/path.js'
 import { isAsyncIterable, isPlainObject } from '../../helpers/type-guards.js'
-import { createSubscribedData, resolveDataDeps } from '../data/data-deps.js'
+import { createSubscribedData, resolveDataDeps } from '../global-data/data-deps.js'
 
 /** @typedef {{
  *   outputName: string,
diff --git a/lib/build-pages/page-builders/template-builder.test.js b/lib/build-pages/templates/template-builder.test.js
similarity index 95%
rename from lib/build-pages/page-builders/template-builder.test.js
rename to lib/build-pages/templates/template-builder.test.js
index b0bab269..6733e0f6 100644
--- a/lib/build-pages/page-builders/template-builder.test.js
+++ b/lib/build-pages/templates/template-builder.test.js
@@ -8,7 +8,7 @@ import { mkdtemp, rm, writeFile } from 'node:fs/promises'
 import { join } from 'node:path'
 import { tmpdir } from 'node:os'
 import { templateBuilder } from './template-builder.js'
-import { WatchDependencyTracker } from '../data/watch-dependencies.js'
+import { WatchDependencyTracker } from '../global-data/watch-dependencies.js'
 
 test('template builder rejects malformed output shapes', async (t) => {
   const root = await mkdtemp(join(tmpdir(), 'domstack-template-builder-'))
diff --git a/lib/build-pages/resolve-vars.js b/lib/build-pages/vars/resolve-vars.js
similarity index 58%
rename from lib/build-pages/resolve-vars.js
rename to lib/build-pages/vars/resolve-vars.js
index 91d46074..40c7ee14 100644
--- a/lib/build-pages/resolve-vars.js
+++ b/lib/build-pages/vars/resolve-vars.js
@@ -1,9 +1,4 @@
-/**
-
- * @import { GlobalDataFunctionParams } from './index.js'
- */
-
-import { isFunction, isObject, isPlainObject } from '../helpers/type-guards.js'
+import { isFunction, isPlainObject } from '../../helpers/type-guards.js'
 
 /**
  * Resolve an object-or-function vars export.
@@ -44,36 +39,6 @@ export async function resolveVars ({
   return await resolveVarsExport(imported[key], 'Var')
 }
 
-/**
- * Resolve and call a global.data.js file with initialized source-backed pages.
- * Receives fully resolved PageData instances (with .vars, .pageInfo, etc.) so
- * that global.data.js can filter and aggregate by layout, publishDate, title, etc.
- * Generated pages are created afterward, and downstream consumers may subscribe
- * to named values from the returned data.
- * Returns an empty object if no file is provided or the file exports nothing useful.
- *
- * @param {object} params
- * @param {string | undefined} [params.globalDataPath] - Path to the global.data file.
- * @param {GlobalDataFunctionParams} params.context - Callback context prepared by the page phase.
- * @returns {Promise}
- */
-export async function resolveGlobalData ({ globalDataPath, context }) {
-  if (!globalDataPath) return {}
-
-  const imported = await import(globalDataPath)
-  const maybeGlobalData = imported.default
-
-  if (isFunction(maybeGlobalData)) {
-    const result = await maybeGlobalData(context)
-    if (isObject(result)) return result
-    throw new Error('global.data default export function must return an object')
-  } else if (isObject(maybeGlobalData)) {
-    return maybeGlobalData
-  } else {
-    return {}
-  }
-}
-
 /**
  * Resolve variables by importing them from a specified path.
  *
diff --git a/lib/build-pages/resolve-vars.test.js b/lib/build-pages/vars/resolve-vars.test.js
similarity index 78%
rename from lib/build-pages/resolve-vars.test.js
rename to lib/build-pages/vars/resolve-vars.test.js
index cb4b8a23..3dbf6c18 100644
--- a/lib/build-pages/resolve-vars.test.js
+++ b/lib/build-pages/vars/resolve-vars.test.js
@@ -8,7 +8,7 @@ const __dirname = import.meta.dirname
 
 test.describe('resolve-vars', () => {
   test('resolve vars resolves vars', async () => {
-    const varsPath = resolve(__dirname, '../../test-cases/general-features/src/globals/global.vars.js')
+    const varsPath = resolve(__dirname, '../../../test-cases/general-features/src/globals/global.vars.js')
 
     const vars = await resolveVars({ varsPath })
 
diff --git a/lib/build-pages/worker/index.js b/lib/build-pages/worker/index.js
new file mode 100644
index 00000000..9bbacdf3
--- /dev/null
+++ b/lib/build-pages/worker/index.js
@@ -0,0 +1,64 @@
+/**
+ * @import { PageBuildStep, PageBuildStepResult } from '../index.js'
+ * @import { BuildPagesFilterOptions, WorkerBuildStepResult } from './protocol.js'
+ */
+
+import { Worker } from 'worker_threads'
+import { join } from 'path'
+import { restoreWorkerError } from './protocol.js'
+
+const __dirname = import.meta.dirname
+
+/**
+ * Run a page build in a fresh worker so source modules reload between builds.
+ *
+ * @type {PageBuildStep}
+ */
+export function buildPages (src, dest, siteData, opts) {
+  // Only page-build filters cross the worker boundary. General build options
+  // can contain functions (manifest hooks and predicates) or logger instances,
+  // neither of which can be structured-cloned.
+  /** @type {BuildPagesFilterOptions} */
+  const workerOpts = {
+    pageFilterPaths: opts?.pageFilterPaths,
+    templateFilterPaths: opts?.templateFilterPaths,
+    pagesFileFilterPaths: opts?.pagesFileFilterPaths,
+    buildDrafts: opts?.buildDrafts,
+    previousWatchDependencies: opts?.previousWatchDependencies,
+    trackWatchDependencies: opts?.trackWatchDependencies,
+    previousPageOutputCache: opts?.previousPageOutputCache,
+    previousGlobalDataBaseline: opts?.globalDataInputChanges?.resetReason === undefined ? opts?.previousGlobalDataBaseline : undefined,
+    globalDataInputChanges: opts?.globalDataInputChanges,
+  }
+
+  return new Promise((resolve, reject) => {
+    const worker = new Worker(join(__dirname, 'worker.js'), {
+      workerData: { src, dest, siteData, opts: workerOpts },
+    })
+
+    worker.once('message', message => {
+      /** @type { WorkerBuildStepResult }  */
+      const workerReport = message
+
+      /** @type {PageBuildStepResult} */
+      const buildReport = {
+        type: workerReport.type,
+        report: workerReport.report,
+        outputs: workerReport.outputs,
+        errors: [],
+        warnings: workerReport.warnings ?? [],
+      }
+
+      if (workerReport.errors.length > 0) {
+        buildReport.errors = workerReport.errors.map(({ error, errorData = {} }) => {
+          return restoreWorkerError(error, errorData)
+        })
+      }
+      resolve(buildReport)
+    })
+    worker.once('error', reject)
+    worker.once('exit', (code) => {
+      if (code !== 0) { reject(new Error(`Worker stopped with exit code ${code}`)) }
+    })
+  })
+}
diff --git a/lib/build-pages/worker-protocol.js b/lib/build-pages/worker/protocol.js
similarity index 93%
rename from lib/build-pages/worker-protocol.js
rename to lib/build-pages/worker/protocol.js
index 7300f3fd..f78ee0cf 100644
--- a/lib/build-pages/worker-protocol.js
+++ b/lib/build-pages/worker/protocol.js
@@ -1,12 +1,12 @@
 /**
- * @import { PageInfo, TemplateInfo, PagesFileInfo } from '../identify-pages.js'
- * @import { PageBuildStepResult } from './index.js'
- * @import { WatchDependencyState } from './data/watch-dependencies.js'
- * @import { PageOutputCache } from './outputs/page-output-writer.js'
- * @import { GlobalDataBaseline, GlobalDataInputChanges } from './data/global-data-state.js'
+ * @import { PageInfo, TemplateInfo, PagesFileInfo } from '../../identify-pages.js'
+ * @import { PageBuildStepResult } from '../index.js'
+ * @import { WatchDependencyState } from '../global-data/watch-dependencies.js'
+ * @import { PageOutputCache } from '../outputs/page-output-writer.js'
+ * @import { GlobalDataBaseline, GlobalDataInputChanges } from '../global-data/global-data-state.js'
  */
 
-import { DomStackDataError, DomStackOutputConflictError } from '../helpers/domstack-error.js'
+import { DomStackDataError, DomStackOutputConflictError } from '../../helpers/domstack-error.js'
 
 /**
  * Internal options sent to the page worker.
diff --git a/lib/build-pages/worker-protocol.test.js b/lib/build-pages/worker/protocol.test.js
similarity index 95%
rename from lib/build-pages/worker-protocol.test.js
rename to lib/build-pages/worker/protocol.test.js
index 49cd85c7..a35a1aa1 100644
--- a/lib/build-pages/worker-protocol.test.js
+++ b/lib/build-pages/worker/protocol.test.js
@@ -1,15 +1,16 @@
 /**
- * @import { PageInfo, PagesFileInfo, TemplateInfo, WalkerFile } from '../identify-pages.js'
- * @import { WorkerErrorData } from './worker-protocol.js'
+ * @import { PageInfo, PagesFileInfo, TemplateInfo, WalkerFile } from '../../identify-pages.js'
+ * @import { WorkerErrorData } from './protocol.js'
  */
 import assert from 'node:assert/strict'
 import { test } from 'node:test'
 import { posix } from 'node:path'
-import * as facade from './index.js'
-import { buildPagesDirect } from './build.js'
-import { pageBuilders } from './page-builders/index.js'
-import { pageInfoForWorker, restoreWorkerError, serializeBuildError } from './worker-protocol.js'
-import { DomStackDataError, DomStackOutputConflictError } from '../helpers/domstack-error.js'
+import * as facade from '../index.js'
+import { buildPagesDirect } from '../build.js'
+import { buildPages } from './index.js'
+import { pageBuilders } from '../page-builders/index.js'
+import { pageInfoForWorker, restoreWorkerError, serializeBuildError } from './protocol.js'
+import { DomStackDataError, DomStackOutputConflictError } from '../../helpers/domstack-error.js'
 
 /** @param {string} relname @returns {WalkerFile} */
 function file (relname) {
@@ -58,6 +59,7 @@ function roundTrip (error, context, message) {
 }
 
 test('build-pages facade preserves runtime export identity after the split', () => {
+  assert.equal(facade.buildPages, buildPages)
   assert.equal(facade.buildPagesDirect, buildPagesDirect)
   assert.equal(facade.serializeBuildError, serializeBuildError)
   assert.equal(facade.pageBuilders, pageBuilders)
diff --git a/lib/build-pages/worker.js b/lib/build-pages/worker/worker.js
similarity index 86%
rename from lib/build-pages/worker.js
rename to lib/build-pages/worker/worker.js
index 6e9ba90a..969c1fc4 100644
--- a/lib/build-pages/worker.js
+++ b/lib/build-pages/worker/worker.js
@@ -1,6 +1,6 @@
 import { parentPort, workerData } from 'worker_threads'
-import { buildPagesDirect } from './build.js'
-import { serializeBuildError } from './worker-protocol.js'
+import { buildPagesDirect } from '../build.js'
+import { serializeBuildError } from './protocol.js'
 
 async function run () {
   if (!parentPort) throw new Error('parentPort returned null')
diff --git a/lib/domstack-manifest/settings.js b/lib/domstack-manifest/settings.js
index 38f6d3ce..1e7449c9 100644
--- a/lib/domstack-manifest/settings.js
+++ b/lib/domstack-manifest/settings.js
@@ -2,7 +2,7 @@
  * @import { DomStackOpts } from '../builder.js'
  * @import { DomstackManifestOptions, DomstackManifestTransform, DomstackManifestPolicyTransform, DomstackManifestBuiltHook } from './schema.js'
  */
-import { resolveVars } from '../build-pages/resolve-vars.js'
+import { resolveVars } from '../build-pages/vars/resolve-vars.js'
 import { isFunction, isPlainObject } from '../helpers/type-guards.js'
 
 /**
diff --git a/lib/build-pages/compute-page-url.js b/lib/helpers/compute-page-url.js
similarity index 90%
rename from lib/build-pages/compute-page-url.js
rename to lib/helpers/compute-page-url.js
index 94f3f014..05a35714 100644
--- a/lib/build-pages/compute-page-url.js
+++ b/lib/helpers/compute-page-url.js
@@ -1,4 +1,4 @@
-import { fsPathToUrlPath } from './page-builders/fs-path-to-url.js'
+import { fsPathToUrlPath } from './fs-path-to-url.js'
 
 /**
  * Derive the canonical URL path for a page from its filesystem path and output name.
diff --git a/lib/helpers/compute-page-url.test.js b/lib/helpers/compute-page-url.test.js
new file mode 100644
index 00000000..3daf5730
--- /dev/null
+++ b/lib/helpers/compute-page-url.test.js
@@ -0,0 +1,21 @@
+import { test } from 'node:test'
+import assert from 'node:assert'
+import { computePageUrl } from './compute-page-url.js'
+
+test.describe('computePageUrl', () => {
+  test('root index.html maps to /', () => {
+    assert.strictEqual(computePageUrl({ path: '', outputName: 'index.html' }), '/')
+  })
+
+  test('nested index.html gets a trailing-slash URL', () => {
+    assert.strictEqual(computePageUrl({ path: 'blog/post', outputName: 'index.html' }), '/blog/post/')
+  })
+
+  test('non-index output includes filename in URL', () => {
+    assert.strictEqual(computePageUrl({ path: 'md-page', outputName: 'loose-md.html' }), '/md-page/loose-md.html')
+  })
+
+  test('non-index file at root includes filename only', () => {
+    assert.strictEqual(computePageUrl({ path: '', outputName: 'robots.txt' }), '/robots.txt')
+  })
+})
diff --git a/lib/build-pages/page-builders/fs-path-to-url.js b/lib/helpers/fs-path-to-url.js
similarity index 100%
rename from lib/build-pages/page-builders/fs-path-to-url.js
rename to lib/helpers/fs-path-to-url.js
diff --git a/lib/build-pages/page-builders/fs-path-to-url.test.js b/lib/helpers/fs-path-to-url.test.js
similarity index 100%
rename from lib/build-pages/page-builders/fs-path-to-url.test.js
rename to lib/helpers/fs-path-to-url.test.js
diff --git a/lib/identify-pages.js b/lib/identify-pages.js
index 8b6f55d6..a609bd65 100644
--- a/lib/identify-pages.js
+++ b/lib/identify-pages.js
@@ -9,7 +9,7 @@ import { pageBuilders } from './build-pages/index.js'
 import { DomStackDuplicatePageError, DomStackDuplicateServiceWorkerError } from './helpers/domstack-error.js'
 import { fileConventions, jsPageNames, jsPageDraftNames, pageClientNames, pageWorkerSuffixs, pageVarsNames, layoutSuffixs, layoutClientSuffixs, layoutStyleSuffix, templateSuffixs, pagesSuffixs, globalStyleNames, globalClientNames, serviceWorkerNames, globalVarsNames, globalDataNames, esbuildSettingsNames, markdownItSettingsNames, domstackManifestSettingsNames } from './file-conventions.js'
 export { jsPageNames, jsPageDraftNames, pageClientNames, pageWorkerSuffixs, pageVarsNames, layoutSuffixs, layoutClientSuffixs, layoutStyleSuffix, templateSuffixs, pagesSuffixs, globalStyleNames, pageStyleName, globalClientNames, serviceWorkerNames, globalVarsNames, globalDataNames, esbuildSettingsNames, markdownItSettingsNames, domstackManifestSettingsNames } from './file-conventions.js'
-import { computePageUrl } from './build-pages/compute-page-url.js'
+import { computePageUrl } from './helpers/compute-page-url.js'
 
 const __dirname = import.meta.dirname
 
diff --git a/lib/watch/index.js b/lib/watch/index.js
index 0a475af0..ba1007a3 100644
--- a/lib/watch/index.js
+++ b/lib/watch/index.js
@@ -7,9 +7,9 @@
  * @import { BsInstance } from '@domstack/sync'
  * @import { Logger as PinoLogger } from 'pino'
  * @import { DomstackManifestRecord } from '../domstack-manifest/index.js'
- * @import { WatchDependencyState } from '../build-pages/data/watch-dependencies.js'
+ * @import { WatchDependencyState } from '../build-pages/global-data/watch-dependencies.js'
  * @import { WatchSnapshot, WatchEvent, WatchPlan } from './plan.js'
- * @import { GlobalDataBaseline, GlobalDataInputChanges } from '../build-pages/data/global-data-state.js'
+ * @import { GlobalDataBaseline, GlobalDataInputChanges } from '../build-pages/global-data/global-data-state.js'
  * @typedef {{ dispose: () => Promise }} DisposableBuildContext
  * @typedef {{ pageFilePath: string, sourcePageFilePath?: string | undefined, pagesFilePath?: string | undefined, layoutNames: string[], outputs?: DomstackManifestRecord[] | undefined }} WatchedPageReport
  * @typedef {object} WatchSession
diff --git a/test-cases/nested-layouts/type-checks.ts b/test-cases/nested-layouts/type-checks.ts
index b3f19acc..623b7676 100644
--- a/test-cases/nested-layouts/type-checks.ts
+++ b/test-cases/nested-layouts/type-checks.ts
@@ -1,6 +1,6 @@
 // Compile-time regressions exercised by npm run test:tsc, not the Node test runner.
 import type { LayoutFunction, PageData, PageFunction } from '#types'
-import type { ResolvedLayout } from '../../lib/build-pages/page-data.js'
+import type { ResolvedLayout } from '../../lib/build-pages/layouts/resolve-layout.js'
 import { pageWriter } from '../../lib/build-pages/outputs/page-writer.js'
 
 type Vars = { title: string }
diff --git a/types.ts b/types.ts
index 5fdd888f..ae50e400 100644
--- a/types.ts
+++ b/types.ts
@@ -12,12 +12,12 @@ import type {
 import type { PageFunction as PageFunctionExport } from './lib/build-pages/outputs/page-writer.js'
 import type { PageOutputsFunction as PageOutputsFunctionExport } from './lib/build-pages/outputs/page-outputs.js'
 
-export type { DataDeps } from './lib/build-pages/data/data-deps.js'
+export type { DataDeps } from './lib/build-pages/global-data/data-deps.js'
 export type {
   GlobalDataChanges,
   GlobalDataDeltaChanges,
   GlobalDataResetChanges,
-} from './lib/build-pages/data/global-data-state.js'
+} from './lib/build-pages/global-data/global-data-state.js'
 export type { WatchEvent } from './lib/watch/plan.js'
 export type {
   PageOutput,
@@ -46,8 +46,8 @@ export type {
   LayoutFunctionParams,
   LayoutVars,
   LayoutVarsFunction,
-  PageData,
-} from './lib/build-pages/page-data.js'
+} from './lib/build-pages/layouts/resolve-layout.js'
+export type { PageData } from './lib/build-pages/page/page-data.js'
 export type {
   AsyncPageFunction,
   PageFunction,
@@ -59,7 +59,7 @@ export type {
   TemplateFunction,
   TemplateFunctionParams,
   TemplateOutputOverride,
-} from './lib/build-pages/page-builders/template-builder.js'
+} from './lib/build-pages/templates/template-builder.js'
 export type { PageInfo, PagesFileInfo, ServiceWorkerInfo, TemplateInfo } from './lib/identify-pages.js'
 export type {
   DomstackManifest,

From 68fc3a4493f5b05f960e45c83f25312849c7f020 Mon Sep 17 00:00:00 2001
From: Bret Comnes 
Date: Wed, 16 Sep 2026 19:07:59 -0700
Subject: [PATCH 09/20] refactor(types): remove redundant internal aliases

---
 index.js                                  |  5 +----
 lib/build-pages/layouts/resolve-layout.js | 14 ++------------
 lib/build-pages/outputs/page-writer.js    | 21 ++-------------------
 lib/build-pages/page/page-data.js         |  4 ++--
 lib/watch/index.js                        |  5 ++---
 5 files changed, 9 insertions(+), 40 deletions(-)

diff --git a/index.js b/index.js
index 8e72cd15..d1536d62 100644
--- a/index.js
+++ b/index.js
@@ -3,10 +3,7 @@
 /**
  * @import { DomStackOpts, Results } from './lib/builder.js'
  * @import { TestBuildResult } from './types.js'
- * @import { DisposableBuildContext as DisposableBuildContextType, WatchedPageReport as WatchedPageReportType, WatchSession as WatchSessionType } from './lib/watch/index.js'
- * @typedef {DisposableBuildContextType} DisposableBuildContext
- * @typedef {WatchedPageReportType} WatchedPageReport
- * @typedef {WatchSessionType} WatchSession
+
  */
 import { mkdtemp, readFile, rm } from 'node:fs/promises'
 import { tmpdir } from 'node:os'
diff --git a/lib/build-pages/layouts/resolve-layout.js b/lib/build-pages/layouts/resolve-layout.js
index 21d227d0..eb56dd6f 100644
--- a/lib/build-pages/layouts/resolve-layout.js
+++ b/lib/build-pages/layouts/resolve-layout.js
@@ -16,7 +16,7 @@ import { validatePageOutputsHook } from '../outputs/page-outputs.js'
  * @template [V=string] V - The return type of the layout function (defaults to string)
  * @template {object} [D=Record] - Declared global data.
  * @param {string} layoutPath - The string path to the layout ESM module.
- * @returns {Promise<{ render: InternalLayoutFunction, vars: Partial, parentLayout: string | undefined, pageOutputs: PageOutputsFunction | undefined, source: string }>} The resolved layout module exports.
+ * @returns {Promise<{ render: LayoutFunction, vars: Partial, parentLayout: string | undefined, pageOutputs: PageOutputsFunction | undefined, source: string }>} The resolved layout module exports.
  */
 export async function resolveLayout (layoutPath) {
   const { default: layout, vars, parentLayout, pageOutputs } = await import(pathToFileURL(layoutPath).href)
@@ -101,16 +101,6 @@ export async function resolveLayout (layoutPath) {
   * @returns {Promise} The rendered content.
   */
 
-/**
-  * Callback for rendering a layout (can be sync or async).
-  *
-  * @template {Record} T - The type of variables passed to the layout function
-  * @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)
-  * @template {object} [D=Record] - Declared global data.
-  * @typedef {LayoutFunction} InternalLayoutFunction
-  */
-
 /**
  * A resolved layout module with its render function and associated asset paths.
  *
@@ -119,7 +109,7 @@ export async function resolveLayout (layoutPath) {
  * @template [V=string] V - The return type of the layout function (defaults to string)
  * @template {object} [D=Record] - Declared global data.
  * @typedef ResolvedLayout
- * @property {InternalLayoutFunction} render - The layout function
+ * @property {LayoutFunction} render - The layout function
  * @property {Partial} [vars] - Variables exported by the layout module.
  * @property {PageOutputsFunction | undefined} [pageOutputs] - Explicit output-phase hook.
  * @property {string} [source] - Layout module path for diagnostics.
diff --git a/lib/build-pages/outputs/page-writer.js b/lib/build-pages/outputs/page-writer.js
index abb4a845..f143d1a8 100644
--- a/lib/build-pages/outputs/page-writer.js
+++ b/lib/build-pages/outputs/page-writer.js
@@ -1,6 +1,6 @@
 /**
  * @import { PageInfo } from '../../identify-pages.js'
- * @import { PageData as PageDataClass } from '../page/page-data.js'
+ * @import { PageData } from '../page/page-data.js'
  * @import { DomstackManifestRecord } from '../../domstack-manifest/index.js'
  * @import { PageOutputsFunction } from './page-outputs.js'
  * @import { PageOutputCache } from './page-output-writer.js'
@@ -16,14 +16,6 @@ import { writePageOutputs } from './page-output-writer.js'
  * @property {string | null | undefined} [markdownItSettingsPath] - Path to the markdown-it settings file
  */
 
-/**
- * @template {Record} T
- * @template [U=any] U - The return type of the page function (defaults to any)
- * @template [V=string] V - The return type of the layout function (defaults to string)
- * @template {object} [D=Record] - Declared global data.
- * @typedef {PageDataClass} PageData
- */
-
 /**
  * Common parameters for page functions.
  *
@@ -61,22 +53,13 @@ import { writePageOutputs } from './page-output-writer.js'
  * @returns {Promise} The rendered inner page thats compatible with its matched layout
  */
 
-/**
- * Internal alias for a page's render function.
- *
- * @template {Record} T - The type of variables passed to the page function
- * @template [U=any] U - The return type of the page function (defaults to any)
- * @template {object} [D=Record] - Declared global data.
- * @typedef {PageFunction} InternalPageFunction
- */
-
 /**
  * @template {Record} T - The type of variables for the page
  * @template [U=any] U - The return type of the pageLayout function
  * @template {object} [D=Record] - The page's declared global data.
  * @typedef PageBuilderResult
  * @property {Partial} vars - Any variables resolved by the builder
- * @property {InternalPageFunction} pageLayout - The function that returns the rendered page
+ * @property {PageFunction} pageLayout - The function that returns the rendered page
  * @property {PageOutputsFunction | undefined} [pageOutputs] - Optional build-only page-output hook.
  */
 
diff --git a/lib/build-pages/page/page-data.js b/lib/build-pages/page/page-data.js
index 36fccf6a..398fb339 100644
--- a/lib/build-pages/page/page-data.js
+++ b/lib/build-pages/page/page-data.js
@@ -3,7 +3,7 @@
  * @import { ResolvedLayout } from '../layouts/resolve-layout.js'
  * @import { DomstackManifestRecord } from '../../domstack-manifest/index.js'
  * @import { DomStackWarning } from '../../helpers/domstack-warning.js'
- * @import { BuilderOptions, InternalPageFunction } from '../outputs/page-writer.js'
+ * @import { BuilderOptions, PageFunction } from '../outputs/page-writer.js'
  * @import { PageOutputsFunction, PageOutputProvenance, CollectedPageOutput } from '../outputs/page-outputs.js'
  */
 
@@ -364,7 +364,7 @@ export class PageData {
     const builder = pageBuilders[pageInfo.type]
     const { pageLayout } = await builder({ pageInfo, options: builderOptions })
     // Discovery selects the builder; the caller's U describes that page's result.
-    const render = /** @type {InternalPageFunction} */ (pageLayout)
+    const render = /** @type {PageFunction} */ (pageLayout)
     const results = await render({ vars, data, styles, scripts, page: pageInfo, workers })
     return results
   }
diff --git a/lib/watch/index.js b/lib/watch/index.js
index ba1007a3..7e17bd59 100644
--- a/lib/watch/index.js
+++ b/lib/watch/index.js
@@ -6,12 +6,11 @@
  * @import { PageBuildStepResult } from '../build-pages/index.js'
  * @import { BsInstance } from '@domstack/sync'
  * @import { Logger as PinoLogger } from 'pino'
- * @import { DomstackManifestRecord } from '../domstack-manifest/index.js'
+ * @import { DisposableBuildContext } from '../build-esbuild/index.js'
  * @import { WatchDependencyState } from '../build-pages/global-data/watch-dependencies.js'
  * @import { WatchSnapshot, WatchEvent, WatchPlan } from './plan.js'
  * @import { GlobalDataBaseline, GlobalDataInputChanges } from '../build-pages/global-data/global-data-state.js'
- * @typedef {{ dispose: () => Promise }} DisposableBuildContext
- * @typedef {{ pageFilePath: string, sourcePageFilePath?: string | undefined, pagesFilePath?: string | undefined, layoutNames: string[], outputs?: DomstackManifestRecord[] | undefined }} WatchedPageReport
+
  * @typedef {object} WatchSession
  * @property {'starting' | 'watching' | 'stopping'} state
  * @property {AbortController} cancellation - Cancels event waits, not resource acquisition.

From f1a876ddb78b732d0c6f48faec121c56514b9546 Mon Sep 17 00:00:00 2001
From: Bret Comnes 
Date: Wed, 16 Sep 2026 19:13:16 -0700
Subject: [PATCH 10/20] fix(types): type Markdown plugins without suppressions

---
 .../page-builders/md/get-md-types.test.ts     | 33 +++++++
 lib/build-pages/page-builders/md/get-md.js    | 17 +---
 .../page-builders/md/get-md.test.js           | 41 ++++++++-
 package.json                                  |  1 +
 types/markdown-it-plugins.d.ts                | 88 +++++++++++++++++++
 5 files changed, 164 insertions(+), 16 deletions(-)
 create mode 100644 lib/build-pages/page-builders/md/get-md-types.test.ts
 create mode 100644 types/markdown-it-plugins.d.ts

diff --git a/lib/build-pages/page-builders/md/get-md-types.test.ts b/lib/build-pages/page-builders/md/get-md-types.test.ts
new file mode 100644
index 00000000..5263fb94
--- /dev/null
+++ b/lib/build-pages/page-builders/md/get-md-types.test.ts
@@ -0,0 +1,33 @@
+import type { MarkdownIt } from 'markdown-it'
+import { full as emoji } from 'markdown-it-emoji'
+import subscript from 'markdown-it-sub'
+import superscript from 'markdown-it-sup'
+import definitionList from 'markdown-it-deflist'
+import insertedText from 'markdown-it-ins'
+import markedText from 'markdown-it-mark'
+import abbreviation from 'markdown-it-abbr'
+import taskLists from 'markdown-it-task-lists'
+import tableOfContents from 'markdown-it-table-of-contents'
+import highlightjs from 'markdown-it-highlightjs'
+
+export function checkPluginTypes (md: MarkdownIt) {
+  for (const plugin of [emoji, subscript, superscript, definitionList, insertedText, markedText, abbreviation]) {
+    md.use(plugin)
+    // @ts-expect-error Plugins require a MarkdownIt instance, not an arbitrary object.
+    plugin({})
+  }
+
+  taskLists(md, { enabled: true, label: true, labelAfter: false })
+  tableOfContents(md, { includeLevel: [1, 2, 3], slugify: (text, token) => text + token.content })
+  highlightjs(md, { auto: false, code: true })
+  const auto: boolean = highlightjs.defaults.auto
+
+  // @ts-expect-error Checkbox options are booleans.
+  taskLists(md, { enabled: 'yes' })
+  // @ts-expect-error Heading levels are numbers.
+  tableOfContents(md, { includeLevel: ['1'] })
+  // @ts-expect-error Preserve the upstream highlighting option types.
+  highlightjs(md, { auto: 'no' })
+
+  return auto
+}
diff --git a/lib/build-pages/page-builders/md/get-md.js b/lib/build-pages/page-builders/md/get-md.js
index 66cd23a6..3e029221 100644
--- a/lib/build-pages/page-builders/md/get-md.js
+++ b/lib/build-pages/page-builders/md/get-md.js
@@ -2,27 +2,16 @@ import markdownIt from 'markdown-it'
 import markdownItFootnote from 'markdown-it-footnote'
 import markdownItHighlightjs from 'markdown-it-highlightjs'
 import markdownItGitHubAlerts from 'markdown-it-github-alerts'
-// @ts-ignore
 import { full as markdownItEmoji } from 'markdown-it-emoji'
-// @ts-ignore
 import markdownItSub from 'markdown-it-sub'
-// @ts-ignore
 import markdownItSup from 'markdown-it-sup'
-// @ts-ignore
 import markdownItDeflist from 'markdown-it-deflist'
-// @ts-ignore
 import markdownItIns from 'markdown-it-ins'
-// @ts-ignore
 import markdownItMark from 'markdown-it-mark'
-// @ts-ignore
 import markdownItAbbr from 'markdown-it-abbr'
-// @ts-ignore
 import markdownItTaskList from 'markdown-it-task-lists'
-// @ts-ignore
 import markdownItAnchor from 'markdown-it-anchor'
-// @ts-ignore
 import markdownItAttrs from 'markdown-it-attrs'
-// @ts-ignore
 import markdownItTOC from 'markdown-it-table-of-contents'
 import Handlebars from 'handlebars'
 
@@ -53,7 +42,6 @@ export async function getMd (settingsPath = null) {
     .use(markdownItTOC, {
       includeLevel: [1, 2, 3],
     })
-    // @ts-ignore These @types suck! This works fine.
     .use(markdownItHighlightjs, { auto: false, code: true })
 
   // disable autolinking for filenames
@@ -78,15 +66,14 @@ export async function getMd (settingsPath = null) {
 /**
  * Renders markdown, and accepts an optional markdown-it instance
  * @param  {string} mdUnparsed unparsed markdown
- * @param  {object} vars to expose to handlebars
+ * @param  {{ vars?: Record }} vars to expose to handlebars
  * @param  {InstanceType | null} [md] an instance of markdown
  * @param  {string | null | undefined} [settingsPath] Path to the markdown-it settings file
  * @return {Promise}            Rendered markdown to html
  */
 export async function renderMd (mdUnparsed, vars, md, settingsPath) {
   if (!md) md = await getMd(settingsPath)
-  // @ts-ignore
-  if (vars?.vars?.handlebars) {
+  if (vars?.vars?.['handlebars']) {
     const template = Handlebars.compile(mdUnparsed)
     const body = rewriteLinks(md.render(template(vars)))
     return body
diff --git a/lib/build-pages/page-builders/md/get-md.test.js b/lib/build-pages/page-builders/md/get-md.test.js
index dd724b42..7b6b81e8 100644
--- a/lib/build-pages/page-builders/md/get-md.test.js
+++ b/lib/build-pages/page-builders/md/get-md.test.js
@@ -1,7 +1,46 @@
 import { test } from 'node:test'
 import assert from 'node:assert'
 
-import { getMd } from './get-md.js'
+import { getMd, renderMd } from './get-md.js'
+
+test('registers the typed Markdown plugins', async () => {
+  const md = await getMd()
+  const cases = [
+    { source: 'H~2~O', expected: /H2<\/sub>O/ },
+    { source: 'x^2^', expected: /x2<\/sup>/ },
+    { source: 'Term\n: Definition', expected: /
[\s\S]*
Term<\/dt>[\s\S]*
Definition<\/dd>/ }, + { source: ':smile:', expected: /😄/ }, + { source: '++inserted++', expected: /inserted<\/ins>/ }, + { source: '==marked==', expected: /marked<\/mark>/ }, + { source: '*[HTML]: Hyper Text Markup Language\n\nHTML', expected: /HTML<\/abbr>/ }, + { source: '- [x] Done', expected: /class="task-list-item-checkbox" checked=""/ }, + { source: '# Heading', expected: /

const<\/span>/ }, + ] + + for (const { source, expected } of cases) { + assert.match(md.render(source), expected, source) + } +}) + +test('includes only the configured heading levels in the table of contents', async () => { + const md = await getMd() + const html = md.render('[[toc]]\n\n# First\n\n## Second\n\n### Third\n\n#### Fourth') + assert.match(html, /class="table-of-contents"/) + for (const name of ['first', 'second', 'third']) { + assert.ok(html.includes(`href="#${name}"`)) + } + assert.ok(!html.includes('href="#fourth"')) +}) + +test('renders Handlebars only when enabled in page vars', async () => { + const source = '{{vars.title}}' + assert.equal(await renderMd(source, { vars: { title: 'Example', handlebars: true } }), '

Example

\n') + assert.equal(await renderMd(source, { vars: { title: 'Example', handlebars: false } }), '

{{vars.title}}

\n') + assert.equal(await renderMd(source, {}), '

{{vars.title}}

\n') +}) test('renders GitHub-style Markdown alerts', async () => { const md = await getMd() diff --git a/package.json b/package.json index 3a9fb6b0..b7166728 100644 --- a/package.json +++ b/package.json @@ -90,6 +90,7 @@ }, "devDependencies": { "@playwright/test": "^1.61.1", + "@types/markdown-it-emoji": "^3.0.1", "@types/markdown-it-footnote": "^3.0.4", "@types/mime-types": "^3.0.1", "@types/node": "^26.0.1", diff --git a/types/markdown-it-plugins.d.ts b/types/markdown-it-plugins.d.ts new file mode 100644 index 00000000..f1d6b9f6 --- /dev/null +++ b/types/markdown-it-plugins.d.ts @@ -0,0 +1,88 @@ +declare module 'markdown-it-sub' { + import type { MarkdownIt } from 'markdown-it' + + export default function subscript (md: MarkdownIt): void +} + +declare module 'markdown-it-sup' { + import type { MarkdownIt } from 'markdown-it' + + export default function superscript (md: MarkdownIt): void +} + +declare module 'markdown-it-deflist' { + import type { MarkdownIt } from 'markdown-it' + + export default function definitionList (md: MarkdownIt): void +} + +declare module 'markdown-it-ins' { + import type { MarkdownIt } from 'markdown-it' + + export default function insertedText (md: MarkdownIt): void +} + +declare module 'markdown-it-mark' { + import type { MarkdownIt } from 'markdown-it' + + export default function markedText (md: MarkdownIt): void +} + +declare module 'markdown-it-abbr' { + import type { MarkdownIt } from 'markdown-it' + + export default function abbreviation (md: MarkdownIt): void +} + +declare module 'markdown-it-task-lists' { + import type { MarkdownIt } from 'markdown-it' + + function taskLists (md: MarkdownIt, options?: taskLists.Options): void + + namespace taskLists { + interface Options { + enabled?: boolean + label?: boolean + labelAfter?: boolean + } + } + + export = taskLists +} + +declare module 'markdown-it-table-of-contents' { + import type { MarkdownIt, Token } from 'markdown-it' + + export interface Options { + includeLevel?: number[] + containerClass?: string + slugify?: (text: string, token: Token) => string + markerPattern?: RegExp + omitTag?: string + listType?: 'ul' | 'ol' + format?: (content: string, md: MarkdownIt, anchor: string | null) => string + containerHeaderHtml?: string + containerFooterHtml?: string + transformLink?: (anchor: string | null) => string | null + transformContainerOpen?: (containerClass: string, containerHeaderHtml: string | undefined) => string + transformContainerClose?: (containerFooterHtml: string | undefined) => string + getTokensText?: (tokens: Token[], token: Token) => string + } + + export default function tableOfContents (md: MarkdownIt, options?: Options): void +} + +// The package's entry point assigns the function to module.exports, but its +// bundled declaration describes a default property on that CommonJS export. +declare module 'markdown-it-highlightjs' { + import type { MarkdownIt } from 'markdown-it' + import type { HighlightOptions } from 'markdown-it-highlightjs/types/core.js' + + function highlightjs (md: MarkdownIt, options?: HighlightOptions): void + + namespace highlightjs { + const defaults: Required> + } + + export = highlightjs +} From 7ebf7d35153cfeaf94fcf0252cb83be3e5a4e5bc Mon Sep 17 00:00:00 2001 From: Bret Comnes Date: Wed, 16 Sep 2026 19:19:05 -0700 Subject: [PATCH 11/20] chore(types): explain expected errors and remove stale suppressions --- examples/tailwind/src/layouts/root.layout.js | 2 +- lib/build-esbuild/index.js | 1 - lib/build-pages/page-builders/html/index.js | 3 +-- lib/build-pages/vars/resolve-vars.test.js | 2 +- test-cases/build-errors/src/another-broken-page/page.js | 2 +- test-cases/general-features/src/feeds.template.js | 2 +- test-cases/general-features/src/global.data.js | 2 +- test-cases/general-features/src/markdown-it.settings.js | 2 +- types/thread-stream.d.ts | 2 ++ 9 files changed, 9 insertions(+), 9 deletions(-) diff --git a/examples/tailwind/src/layouts/root.layout.js b/examples/tailwind/src/layouts/root.layout.js index 2bd8695c..12048f0a 100644 --- a/examples/tailwind/src/layouts/root.layout.js +++ b/examples/tailwind/src/layouts/root.layout.js @@ -1,7 +1,7 @@ /** * @import { LayoutFunction } from '@domstack/static' */ -// @ts-ignore + import { html } from 'htm/preact' import { render } from 'preact-render-to-string' diff --git a/lib/build-esbuild/index.js b/lib/build-esbuild/index.js index f513a0ae..db1c69c1 100644 --- a/lib/build-esbuild/index.js +++ b/lib/build-esbuild/index.js @@ -621,7 +621,6 @@ async function createWatchBuild ({ buildOpts, dest, label, logger, onEnd, should /** @type {esbuild.BuildContext | undefined} */ let context try { - // @ts-ignore esbuild context() accepts same opts as build() context = await esbuild.context(contextOpts) await context.watch() const initialResult = await initial.promise diff --git a/lib/build-pages/page-builders/html/index.js b/lib/build-pages/page-builders/html/index.js index 8721efc7..834c5948 100644 --- a/lib/build-pages/page-builders/html/index.js +++ b/lib/build-pages/page-builders/html/index.js @@ -18,8 +18,7 @@ export async function htmlBuilder ({ pageInfo }) { return { vars: {}, pageLayout: async (vars) => { - // @ts-ignore - if (vars?.vars?.handlebars) { + if (vars?.vars?.['handlebars']) { const template = Handlebars.compile(fileContents) return template(vars) } else { diff --git a/lib/build-pages/vars/resolve-vars.test.js b/lib/build-pages/vars/resolve-vars.test.js index 3dbf6c18..8cdc455e 100644 --- a/lib/build-pages/vars/resolve-vars.test.js +++ b/lib/build-pages/vars/resolve-vars.test.js @@ -12,7 +12,7 @@ test.describe('resolve-vars', () => { const vars = await resolveVars({ varsPath }) - // @ts-ignore + // @ts-expect-error resolveVars returns object; this fixture exports the known foo property. assert.equal(vars.foo, 'global') }) }) diff --git a/test-cases/build-errors/src/another-broken-page/page.js b/test-cases/build-errors/src/another-broken-page/page.js index 1d415113..d93d7895 100644 --- a/test-cases/build-errors/src/another-broken-page/page.js +++ b/test-cases/build-errors/src/another-broken-page/page.js @@ -4,5 +4,5 @@ export default () => /* html */` // Some garbled JS syntax // eslint-disable-next-line -// @ts-ignore + fdsf dsf fdsaf; diff --git a/test-cases/general-features/src/feeds.template.js b/test-cases/general-features/src/feeds.template.js index 9bc4c8b1..6ca4375b 100644 --- a/test-cases/general-features/src/feeds.template.js +++ b/test-cases/general-features/src/feeds.template.js @@ -1,7 +1,7 @@ /** * @import { TemplateAsyncIterator } from '#types' */ -// @ts-ignore +// @ts-expect-error jsonfeed-to-atom does not provide TypeScript declarations. import jsonfeedToAtom from 'jsonfeed-to-atom' /** diff --git a/test-cases/general-features/src/global.data.js b/test-cases/general-features/src/global.data.js index 7c874269..b0c52844 100644 --- a/test-cases/general-features/src/global.data.js +++ b/test-cases/general-features/src/global.data.js @@ -14,7 +14,7 @@ import pMap from 'p-map' export default async function ({ pages }) { const blogPosts = pages .filter(page => page.vars?.layout === 'blog' && page.vars?.publishDate) - // @ts-ignore + // @ts-expect-error JavaScript coerces Dates to timestamps for subtraction; TypeScript requires numeric operands. .sort((a, b) => new Date(b.vars.publishDate) - new Date(a.vars.publishDate)) .slice(0, 5) diff --git a/test-cases/general-features/src/markdown-it.settings.js b/test-cases/general-features/src/markdown-it.settings.js index 6829054f..2658635d 100644 --- a/test-cases/general-features/src/markdown-it.settings.js +++ b/test-cases/general-features/src/markdown-it.settings.js @@ -26,7 +26,7 @@ function createTestBoxPlugin () { const TEST_BOX_MARKER = 'test-box' return (/** @type {InstanceType} */md) => { - // @ts-ignore + // @ts-expect-error This test-only block rule leaves its MarkdownIt callback parameters untyped. const container = (state, startLine, endLine, silent) => { let pos = state.bMarks[startLine] + state.tShift[startLine] let max = state.eMarks[startLine] diff --git a/types/thread-stream.d.ts b/types/thread-stream.d.ts index 9fd1b2fd..aca3dbd1 100644 --- a/types/thread-stream.d.ts +++ b/types/thread-stream.d.ts @@ -15,6 +15,8 @@ declare namespace ThreadStreamCompat { declare module 'worker_threads' { // An import alias keeps the duplicate-name diagnostic here, rather than in // Node's declarations. The packed tests check old and new Node declarations. + // expect-error cannot be used here: Node 26 needs this alias and has no duplicate + // diagnostic, so declaration checking would reject an unused expect-error. // @ts-ignore Node <=25 already exports this equivalent alias. export import TransferListItem = ThreadStreamCompat.TransferListItem } From b4c2d4ea7f315e7e2361d72d437336ff03197f78 Mon Sep 17 00:00:00 2001 From: Bret Comnes Date: Wed, 16 Sep 2026 19:28:51 -0700 Subject: [PATCH 12/20] refactor(build-pages): isolate per-page data subscriptions --- .../global-data/page-subscriptions.js | 67 +++++++++++++++++++ .../global-data/page-subscriptions.test.js | 59 ++++++++++++++++ lib/build-pages/page/page-data.js | 51 ++++---------- 3 files changed, 140 insertions(+), 37 deletions(-) create mode 100644 lib/build-pages/global-data/page-subscriptions.js create mode 100644 lib/build-pages/global-data/page-subscriptions.test.js diff --git a/lib/build-pages/global-data/page-subscriptions.js b/lib/build-pages/global-data/page-subscriptions.js new file mode 100644 index 00000000..b558230b --- /dev/null +++ b/lib/build-pages/global-data/page-subscriptions.js @@ -0,0 +1,67 @@ +/** + * @import { PageInfo } from '../../identify-pages.js' + */ +import { createSubscribedData } from './data-deps.js' +import { DomStackDataError } from '../../helpers/domstack-error.js' + +/** + * Per-page access to published data, separate from producer state and watch fingerprints. + * @template {object} [D=Record] + */ +export class PageSubscriptions { + /** @type {string[]} */ #pageKeys = [] + /** @type {Map }>} */ #layouts = new Map() + /** @type {D} */ #data = /** @type {D} */ (Object.freeze({})) + #ready = false + + /** @param {string[]} companionKeys @param {string[]} builderKeys */ + setPageDependencies (companionKeys, builderKeys) { + this.#pageKeys = [...new Set([...companionKeys, ...builderKeys])].sort() + } + + /** @param {string} name @param {string[]} keys */ + addLayout (name, keys) { + this.#layouts.set(name, { keys, data: Object.freeze({}) }) + } + + /** The invalidation union is broader than any individual renderer's access. */ + get dependencies () { + const keys = new Set(this.#pageKeys) + for (const layout of this.#layouts.values()) { + for (const key of layout.keys) keys.add(key) + } + return [...keys].sort() + } + + /** @param {string[]} dependencies @param {PageInfo} pageInfo */ + assertReady (dependencies, pageInfo) { + if (!this.#ready && dependencies.length > 0) { + throw new DomStackDataError( + `Global data is not available while resolving global.data for page "${pageInfo.pageFile.relname}" or its layouts`, + { reason: 'NOT_READY', consumer: `Page "${pageInfo.pageFile.relname}"` } + ) + } + } + + /** @param {PageInfo} pageInfo @returns {D} */ + getPageData (pageInfo) { + this.assertReady(this.#pageKeys, pageInfo) + return this.#data + } + + /** @param {string} name @param {PageInfo} pageInfo */ + getLayoutData (name, pageInfo) { + const subscription = this.#layouts.get(name) + if (subscription) this.assertReady(subscription.keys, pageInfo) + return subscription?.data + } + + /** @param {Record} globalData @param {PageInfo} pageInfo */ + bind (globalData, pageInfo) { + this.#data = /** @type {D} */ (createSubscribedData(globalData, this.#pageKeys, `Page "${pageInfo.pageFile.relname}"`)) + for (const [name, subscription] of this.#layouts) { + subscription.data = createSubscribedData(globalData, subscription.keys, `Layout "${name}"`) + } + this.#ready = true + } +} diff --git a/lib/build-pages/global-data/page-subscriptions.test.js b/lib/build-pages/global-data/page-subscriptions.test.js new file mode 100644 index 00000000..e61f6048 --- /dev/null +++ b/lib/build-pages/global-data/page-subscriptions.test.js @@ -0,0 +1,59 @@ +/** + * @import { PageInfo } from '../../identify-pages.js' + */ +import assert from 'node:assert/strict' +import { test } from 'node:test' +import { PageSubscriptions } from './page-subscriptions.js' +import { DomStackDataError } from '../../helpers/domstack-error.js' + +const pageInfo = /** @type {PageInfo} */ ({ pageFile: { relname: 'blog/page.js' } }) + +test('keeps page and layout access narrower than the invalidation union', () => { + const subscriptions = new PageSubscriptions() + subscriptions.setPageDependencies(['posts', 'shared'], ['shared']) + subscriptions.addLayout('root', ['navigation', 'shared']) + subscriptions.addLayout('article', ['author']) + assert.deepEqual(subscriptions.dependencies, ['author', 'navigation', 'posts', 'shared']) + + const posts = [{ title: 'First' }] + subscriptions.bind({ posts, shared: true, navigation: ['Home'], author: 'Author' }, pageInfo) + const page = subscriptions.getPageData(pageInfo) + const root = subscriptions.getLayoutData('root', pageInfo) + assert.ok(root) + assert.deepEqual(Object.keys(page), ['posts', 'shared']) + assert.deepEqual(Object.keys(root), ['navigation', 'shared']) + assert.throws(() => page['navigation'], { code: 'DOM_STACK_ERROR_DATA' }) + assert.throws(() => root['posts'], { code: 'DOM_STACK_ERROR_DATA' }) + assert.equal(Object.isFrozen(page), true) + assert.equal(page['posts'], posts, 'published nested values remain shared, not cloned') + assert.equal(Object.isFrozen(posts), false) +}) + +test('guards only declared data before binding and preserves readiness error metadata', () => { + const subscriptions = new PageSubscriptions() + subscriptions.addLayout('root', ['navigation']) + assert.deepEqual(subscriptions.getPageData(pageInfo), {}) + assert.equal(subscriptions.getLayoutData('missing', pageInfo), undefined) + assert.throws(() => subscriptions.getLayoutData('root', pageInfo), error => { + assert.ok(error instanceof DomStackDataError) + assert.deepEqual(error.dataDependency, { reason: 'NOT_READY', consumer: 'Page "blog/page.js"' }) + return true + }) + assert.throws(() => subscriptions.assertReady(subscriptions.dependencies, pageInfo), /Global data is not available/) + subscriptions.bind({ navigation: [] }, pageInfo) + assert.doesNotThrow(() => subscriptions.assertReady(subscriptions.dependencies, pageInfo)) +}) + +test('a failed first binding remains unready and can be retried', () => { + const subscriptions = new PageSubscriptions() + subscriptions.setPageDependencies(['posts'], []) + subscriptions.addLayout('root', ['navigation']) + assert.throws(() => subscriptions.bind({ posts: [] }, pageInfo), error => { + assert.ok(error instanceof DomStackDataError) + assert.deepEqual(error.dataDependency, { reason: 'MISSING_KEY', consumer: 'Layout "root"', key: 'navigation' }) + return true + }) + assert.throws(() => subscriptions.getPageData(pageInfo), /Global data is not available/) + subscriptions.bind({ posts: ['recovered'], navigation: [] }, pageInfo) + assert.deepEqual(subscriptions.getPageData(pageInfo)['posts'], ['recovered']) +}) diff --git a/lib/build-pages/page/page-data.js b/lib/build-pages/page/page-data.js index 398fb339..e4f04611 100644 --- a/lib/build-pages/page/page-data.js +++ b/lib/build-pages/page/page-data.js @@ -13,7 +13,8 @@ import { toPosix } from '../../helpers/path.js' import { resolveVars, resolvePostVars } from '../vars/resolve-vars.js' import { pageBuilders } from '../page-builders/index.js' import { parseMdFileContents } from '../page-builders/md/parse-md.js' -import { createSubscribedData, extractDataDeps } from '../global-data/data-deps.js' +import { extractDataDeps } from '../global-data/data-deps.js' +import { PageSubscriptions } from '../global-data/page-subscriptions.js' import { DomStackDataError } from '../../helpers/domstack-error.js' import pretty from 'pretty' import { resolveLayoutChain } from '../layouts/resolve-layout-chain.js' @@ -42,17 +43,16 @@ export class PageData { /** @type {Partial | null} */ pageVars = null /** @type {Partial | null} */ builderVars = null /** @type {string[]} Union of the page and entire layout chain, for output invalidation. */ dataDeps = [] - /** @type {string[]} */ #pageDataDeps = [] + /** @type {PageSubscriptions} */ #subscriptions = new PageSubscriptions() /** @type {{ hook: PageOutputsFunction, provenance: PageOutputProvenance } | undefined} */ #pageOutputs - /** @type {Map }>} */ #layoutSubscriptions = new Map() + /** @type {string[]} */ styles = [] /** @type {string[]} */ scripts = [] /** @type {WorkerFiles} */ workerFiles = {} /** @type {boolean} */ #initialized = false /** @type {T | null} */ #varsCache = null /** @type {(Partial | null)[] | null} */ #varsCacheSources = null - /** @type {D} */ #data = /** @type {D} */ (Object.freeze({})) - /** @type {boolean} */ #dataReady = false + /** @type {string?} */ #defaultStyle = null /** @type {string?} */ #defaultClient = null /** @type {BuilderOptions} */ builderOptions @@ -173,17 +173,7 @@ export class PageData { * @returns {D} */ get data () { - if (!this.#dataReady && this.#pageDataDeps.length > 0) { - throw this.#dataNotReadyError() - } - return this.#data - } - - #dataNotReadyError () { - return new DomStackDataError( - `Global data is not available while resolving global.data for page "${this.pageInfo.pageFile.relname}" or its layouts`, - { reason: 'NOT_READY', consumer: `Page "${this.pageInfo.pageFile.relname}"` } - ) + return this.#subscriptions.getPageData(this.pageInfo) } /** @@ -192,15 +182,7 @@ export class PageData { * @param {Record} globalData */ setGlobalData (globalData) { - this.#data = /** @type {D} */ (createSubscribedData( - globalData, - this.#pageDataDeps, - `Page "${this.pageInfo.pageFile.relname}"` - )) - for (const [name, subscription] of this.#layoutSubscriptions) { - subscription.data = createSubscribedData(globalData, subscription.keys, `Layout "${name}"`) - } - this.#dataReady = true + this.#subscriptions.bind(globalData, this.pageInfo) } /** @@ -264,18 +246,17 @@ export class PageData { const builderResolution = extractDataDeps(builderVars, `Page "${pageInfo.pageFile.relname}"`) this.pageVars = pageResolution.vars this.builderVars = /** @type {Partial} */ (builderResolution.vars) - this.#pageDataDeps = [...new Set([...pageResolution.dataDeps, ...builderResolution.dataDeps])].sort() - const dependencies = new Set(this.#pageDataDeps) + this.#subscriptions.setPageDependencies(pageResolution.dataDeps, builderResolution.dataDeps) for (const layout of this.layoutChain) { const resolution = extractDataDeps(layout.vars, `Layout "${layout.name}"`) - this.#layoutSubscriptions.set(layout.name, { keys: resolution.dataDeps, data: Object.freeze({}) }) + this.#subscriptions.addLayout(layout.name, resolution.dataDeps) this.layoutVars.push({ name: layout.name, vars: /** @type {Partial} */ (resolution.vars) }) - for (const key of resolution.dataDeps) dependencies.add(key) + if (layout.layoutStylePath) this.styles.push(layout.layoutStylePath) if (layout.layoutClientPath) this.scripts.push(layout.layoutClientPath) } - this.dataDeps = [...dependencies].sort() + this.dataDeps = this.#subscriptions.dependencies if (pageInfo.pageStyle) { this.styles.push(`./${pageInfo.pageStyle.outputName}`) @@ -342,9 +323,7 @@ export class PageData { const hook = validatePageOutputsHook(layout.pageOutputs, source) if (!hook) continue yield * collect(hook, { kind: 'layout', source, layoutName: layout.name }, () => { - const subscription = this.#layoutSubscriptions.get(layout.name) - if (!this.#dataReady && subscription?.keys.length) throw this.#dataNotReadyError() - return subscription?.data ?? Object.freeze({}) + return this.#subscriptions.getLayoutData(layout.name, this.pageInfo) ?? Object.freeze({}) }) } if (this.#pageOutputs) { @@ -374,9 +353,7 @@ export class PageData { */ async renderFullPage () { if (!this.#initialized) throw new Error('Must be initialized before rendering full pages') - if (!this.#dataReady && this.dataDeps.length > 0) { - throw this.#dataNotReadyError() - } + this.#subscriptions.assertReady(this.dataDeps, this.pageInfo) 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') @@ -388,7 +365,7 @@ export class PageData { styles, scripts, page: pageInfo, - data: this.#layoutSubscriptions.get(currentLayout.name)?.data, + data: this.#subscriptions.getLayoutData(currentLayout.name, this.pageInfo), children: rendered, workers: this.workers }) From 35d7b5a0eace49ba0f947d6303aa87a9d5e7aec0 Mon Sep 17 00:00:00 2001 From: Bret Comnes Date: Wed, 16 Sep 2026 19:30:59 -0700 Subject: [PATCH 13/20] refactor(build-pages): isolate page vars caching --- lib/build-pages/page/page-data.js | 50 ++---------------- lib/build-pages/vars/page-vars.js | 70 ++++++++++++++++++++++++++ lib/build-pages/vars/page-vars.test.js | 53 +++++++++++++++++++ 3 files changed, 127 insertions(+), 46 deletions(-) create mode 100644 lib/build-pages/vars/page-vars.js create mode 100644 lib/build-pages/vars/page-vars.test.js diff --git a/lib/build-pages/page/page-data.js b/lib/build-pages/page/page-data.js index e4f04611..eb91d74c 100644 --- a/lib/build-pages/page/page-data.js +++ b/lib/build-pages/page/page-data.js @@ -11,6 +11,7 @@ import { readFile } from 'node:fs/promises' import { normalize } from 'node:path' import { toPosix } from '../../helpers/path.js' import { resolveVars, resolvePostVars } from '../vars/resolve-vars.js' +import { PageVars } from '../vars/page-vars.js' import { pageBuilders } from '../page-builders/index.js' import { parseMdFileContents } from '../page-builders/md/parse-md.js' import { extractDataDeps } from '../global-data/data-deps.js' @@ -50,8 +51,7 @@ export class PageData { /** @type {string[]} */ scripts = [] /** @type {WorkerFiles} */ workerFiles = {} /** @type {boolean} */ #initialized = false - /** @type {T | null} */ #varsCache = null - /** @type {(Partial | null)[] | null} */ #varsCacheSources = null + /** @type {PageVars} */ #vars = new PageVars() /** @type {string?} */ #defaultStyle = null /** @type {string?} */ #defaultClient = null @@ -92,43 +92,6 @@ export class PageData { } } - #varSources () { - return [ - this.globalVars, - ...this.layoutVars.map(layout => layout.vars), - this.pageVars, - this.builderVars, - ] - } - - #varSourcesUnchanged () { - const sources = this.#varsCacheSources - const layoutCount = this.layoutVars.length - if ( - !sources || - sources.length !== layoutCount + 3 || - sources[0] !== this.globalVars || - sources[layoutCount + 1] !== this.pageVars || - sources[layoutCount + 2] !== this.builderVars - ) return false - - for (let index = 0; index < layoutCount; index++) { - const layout = this.layoutVars[index] - if (!layout || sources[index + 1] !== layout.vars) return false - } - return true - } - - /** @param {(Partial | null)[]} sources */ - #mergeVars (sources) { - // A null prototype makes assignment behave like spread for __proto__ and - // inherited setters, without repeatedly copying the growing merged object. - const merged = Object.create(null) - for (const vars of sources) Object.assign(merged, vars) - Object.setPrototypeOf(merged, Object.prototype) - return /** @type {T} */ (merged) - } - /** * Source-root-relative identity with POSIX separators, independent of checkout * location and output URL. Generated pages use their synthetic factory relname. @@ -144,13 +107,8 @@ export class PageData { */ get vars () { if (!this.#initialized) throw new Error(`Initialize PageData before accessing vars for page "${this.pageInfo?.path ?? ''}"`) - if (this.#varsCache && this.#varSourcesUnchanged()) return this.#varsCache - const sources = this.#varSources() - try { - this.#varsCache = /** @type {T} */ (Object.freeze(this.#mergeVars(sources))) - this.#varsCacheSources = sources - return this.#varsCache + return this.#vars.get(this) } catch (err) { throw new Error( `Failed to resolve vars for page "${this.pageInfo?.path ?? ''}": ${err instanceof Error ? err.message : String(err)}`, @@ -276,7 +234,7 @@ export class PageData { // First vars access must still observe source changes made after init. /** @type {object} */ - const finalVars = this.#mergeVars(this.#varSources()) + const finalVars = this.#vars.merge(this) // disable-eslint-next-line dot-notation if ('defaultStyle' in finalVars && finalVars.defaultStyle) { diff --git a/lib/build-pages/vars/page-vars.js b/lib/build-pages/vars/page-vars.js new file mode 100644 index 00000000..d1ae4755 --- /dev/null +++ b/lib/build-pages/vars/page-vars.js @@ -0,0 +1,70 @@ +/** + * @template {Record} T + * @typedef {object} PageVarSources + * @property {Partial} globalVars + * @property {{ vars: Partial }[]} layoutVars + * @property {Partial | null} pageVars + * @property {Partial | null} builderVars + */ + +/** + * Cache the variable cascade without owning or copying the page's mutable sources. + * @template {Record} T + */ +export class PageVars { + /** @type {T | null} */ #cache = null + /** @type {(Partial | null)[] | null} */ #sources = null + + /** @param {PageVarSources} page @returns {T} */ + get (page) { + if (this.#cache && this.#sourcesUnchanged(page)) return this.#cache + const sources = this.#collectSources(page) + // Commit both cache fields only after a successful merge. + this.#cache = /** @type {T} */ (Object.freeze(this.#merge(sources))) + this.#sources = sources + return this.#cache + } + + /** + * Initialization needs default-asset vars without establishing the cached snapshot. + * @param {PageVarSources} page + * @returns {T} + */ + merge (page) { + return this.#merge(this.#collectSources(page)) + } + + /** @param {PageVarSources} page */ + #collectSources (page) { + return [page.globalVars, ...page.layoutVars.map(layout => layout.vars), page.pageVars, page.builderVars] + } + + /** @param {PageVarSources} page */ + #sourcesUnchanged (page) { + const sources = this.#sources + const layoutCount = page.layoutVars.length + if ( + !sources || + sources.length !== layoutCount + 3 || + sources[0] !== page.globalVars || + sources[layoutCount + 1] !== page.pageVars || + sources[layoutCount + 2] !== page.builderVars + ) return false + + for (let index = 0; index < layoutCount; index++) { + const layout = page.layoutVars[index] + if (!layout || sources[index + 1] !== layout.vars) return false + } + return true + } + + /** @param {(Partial | null)[]} sources */ + #merge (sources) { + // Match spread semantics for __proto__ and inherited setters without copying + // the growing result for each source. + const merged = Object.create(null) + for (const vars of sources) Object.assign(merged, vars) + Object.setPrototypeOf(merged, Object.prototype) + return /** @type {T} */ (merged) + } +} diff --git a/lib/build-pages/vars/page-vars.test.js b/lib/build-pages/vars/page-vars.test.js new file mode 100644 index 00000000..a2213680 --- /dev/null +++ b/lib/build-pages/vars/page-vars.test.js @@ -0,0 +1,53 @@ +/** + * @import { PageVarSources } from './page-vars.js' + */ +import assert from 'node:assert/strict' +import { test } from 'node:test' +import { PageVars } from './page-vars.js' + +/** @returns {PageVarSources>} */ +function sources () { + return { globalVars: { title: 'global' }, layoutVars: [], pageVars: null, builderVars: null } +} + +test('an uncached initialization merge does not establish the first-access snapshot', () => { + const page = sources() + const vars = new PageVars() + assert.deepEqual(vars.merge(page), { title: 'global' }) + page.globalVars['title'] = 'before first access' + const snapshot = vars.get(page) + assert.equal(snapshot['title'], 'before first access') + page.globalVars['title'] = 'after first access' + assert.equal(vars.get(page), snapshot) + assert.equal(vars.merge(page)['title'], 'after first access') + assert.equal(vars.get(page), snapshot, 'uncached merges do not replace an existing snapshot') +}) + +test('cache hits compare source identities without enumerating or mapping sources', () => { + const page = sources() + let reads = 0 + page.globalVars = { get title () { reads++; return 'global' } } + page.layoutVars = [{ vars: { title: 'layout' } }] + const vars = new PageVars() + const snapshot = vars.get(page) + page.layoutVars.map = () => { throw new Error('Cache hits must not collect sources') } + for (let index = 0; index < 10; index++) assert.equal(vars.get(page), snapshot) + assert.equal(reads, 1) + assert.equal(Object.isFrozen(snapshot), true) + assert.equal(snapshot['title'], 'layout') +}) + +test('failed merges preserve the previous snapshot and can be retried', () => { + const page = sources() + const vars = new PageVars() + const first = vars.get(page) + const original = page.globalVars + const cause = new Error('Failed getter') + page.globalVars = { get title () { throw cause } } + assert.throws(() => vars.get(page), error => error === cause) + assert.throws(() => vars.get(page), error => error === cause) + page.globalVars = original + assert.equal(vars.get(page), first) + page.globalVars = { title: 'recovered' } + assert.equal(vars.get(page)['title'], 'recovered') +}) From beb0843c6a2854021770d1a84e2f5c1cf68b7581 Mon Sep 17 00:00:00 2001 From: Bret Comnes Date: Wed, 16 Sep 2026 19:34:11 -0700 Subject: [PATCH 14/20] refactor(build-pages): extract lazy output hook collection --- .../outputs/collect-page-outputs.js | 62 +++++++++++++++++++ lib/build-pages/page/page-data.js | 46 +++----------- 2 files changed, 69 insertions(+), 39 deletions(-) create mode 100644 lib/build-pages/outputs/collect-page-outputs.js diff --git a/lib/build-pages/outputs/collect-page-outputs.js b/lib/build-pages/outputs/collect-page-outputs.js new file mode 100644 index 00000000..5becf5c9 --- /dev/null +++ b/lib/build-pages/outputs/collect-page-outputs.js @@ -0,0 +1,62 @@ +/** + * @import { PageData } from '../page/page-data.js' + * @import { PageSubscriptions } from '../global-data/page-subscriptions.js' + * @import { PageOutputsFunction, PageOutputProvenance, CollectedPageOutput } from './page-outputs.js' + */ +import { readFile } from 'node:fs/promises' +import { parseMdFileContents } from '../page-builders/md/parse-md.js' +import { DomStackDataError } from '../../helpers/domstack-error.js' +import { createPageOutputsPage, normalizePageOutputs, validatePageOutputsHook } from './page-outputs.js' + +/** + * @template {Record} T + * @typedef {object} PageOutputProvider + * @property {PageOutputsFunction} hook + * @property {PageOutputProvenance} provenance + */ + +/** + * Collect outputs from an initialized source page without writing files or retaining records. + * Layout hooks run outermost first, followed by the selected page-level provider. + * @template {Record} T + * @param {Pick, 'pageInfo' | 'vars' | 'data' | 'layoutChain'>} pageData + * @param {PageSubscriptions} subscriptions + * @param {PageOutputProvider | undefined} pageOutputs + * @returns {AsyncGenerator} + */ +export async function * collectPageOutputs (pageData, subscriptions, pageOutputs) { + // Capture source metadata so rebinding the reader cannot change its source. + const sourceInfo = { ...pageData.pageInfo, pageFile: { ...pageData.pageInfo.pageFile } } + const page = createPageOutputsPage(sourceInfo, async () => { + if (sourceInfo.type !== 'md') throw new Error('Markdown content can only be read from markdown pages') + return parseMdFileContents(await readFile(sourceInfo.pageFile.filepath, 'utf8')).markdownContent + }) + /** + * @param {PageOutputsFunction} hook + * @param {PageOutputProvenance} provenance + * @param {() => object} getData + * @returns {AsyncGenerator} + */ + const collect = async function * (hook, provenance, getData) { + try { + yield * normalizePageOutputs(hook({ page, vars: pageData.vars, data: getData() }), provenance) + } catch (cause) { + const message = `pageOutputs for page "${pageData.pageInfo.pageFile.relname}" from ${provenance.kind} "${provenance.source}" failed: ${cause instanceof Error ? cause.message : String(cause)}` + throw cause instanceof DomStackDataError + ? new DomStackDataError(message, cause.dataDependency, { cause }) + : new Error(message, { cause }) + } + } + for (const layout of pageData.layoutChain) { + const source = layout.source ?? layout.name + const hook = validatePageOutputsHook(layout.pageOutputs, source) + if (!hook) continue + yield * collect(hook, { kind: 'layout', source, layoutName: layout.name }, () => { + return subscriptions.getLayoutData(layout.name, pageData.pageInfo) ?? Object.freeze({}) + }) + } + if (pageOutputs) { + const { hook, provenance } = pageOutputs + yield * collect(hook, provenance, () => pageData.data) + } +} diff --git a/lib/build-pages/page/page-data.js b/lib/build-pages/page/page-data.js index eb91d74c..ff62807b 100644 --- a/lib/build-pages/page/page-data.js +++ b/lib/build-pages/page/page-data.js @@ -4,7 +4,8 @@ * @import { DomstackManifestRecord } from '../../domstack-manifest/index.js' * @import { DomStackWarning } from '../../helpers/domstack-warning.js' * @import { BuilderOptions, PageFunction } from '../outputs/page-writer.js' - * @import { PageOutputsFunction, PageOutputProvenance, CollectedPageOutput } from '../outputs/page-outputs.js' + * @import { CollectedPageOutput } from '../outputs/page-outputs.js' + * @import { PageOutputProvider } from '../outputs/collect-page-outputs.js' */ import { readFile } from 'node:fs/promises' @@ -16,11 +17,12 @@ import { pageBuilders } from '../page-builders/index.js' import { parseMdFileContents } from '../page-builders/md/parse-md.js' import { extractDataDeps } from '../global-data/data-deps.js' import { PageSubscriptions } from '../global-data/page-subscriptions.js' -import { DomStackDataError } from '../../helpers/domstack-error.js' + import pretty from 'pretty' import { resolveLayoutChain } from '../layouts/resolve-layout-chain.js' import { resolveLayoutName } from '../layouts/resolve-layout-name.js' -import { createPageOutputsPage, normalizePageOutputs, validatePageOutputsHook } from '../outputs/page-outputs.js' +import { validatePageOutputsHook } from '../outputs/page-outputs.js' +import { collectPageOutputs } from '../outputs/collect-page-outputs.js' /** * @typedef {Object} WorkerFiles @@ -45,7 +47,7 @@ export class PageData { /** @type {Partial | null} */ builderVars = null /** @type {string[]} Union of the page and entire layout chain, for output invalidation. */ dataDeps = [] /** @type {PageSubscriptions} */ #subscriptions = new PageSubscriptions() - /** @type {{ hook: PageOutputsFunction, provenance: PageOutputProvenance } | undefined} */ #pageOutputs + /** @type {PageOutputProvider | undefined} */ #pageOutputs /** @type {string[]} */ styles = [] /** @type {string[]} */ scripts = [] @@ -253,41 +255,7 @@ export class PageData { async * collectPageOutputs () { if (!this.#initialized) throw new Error('Must be initialized before collecting pageOutputs') if (this.pageInfo.generated) return - // Capture source metadata so rebinding the reader cannot change its source. - const sourceInfo = { ...this.pageInfo, pageFile: { ...this.pageInfo.pageFile } } - const page = createPageOutputsPage(sourceInfo, async () => { - if (sourceInfo.type !== 'md') throw new Error('Markdown content can only be read from markdown pages') - return parseMdFileContents(await readFile(sourceInfo.pageFile.filepath, 'utf8')).markdownContent - }) - const pageData = this - /** - * @param {PageOutputsFunction} hook - * @param {PageOutputProvenance} provenance - * @param {() => object} getData - * @returns {AsyncGenerator} - */ - const collect = async function * (hook, provenance, getData) { - try { - yield * normalizePageOutputs(hook({ page, vars: pageData.vars, data: getData() }), provenance) - } catch (cause) { - const message = `pageOutputs for page "${pageData.pageInfo.pageFile.relname}" from ${provenance.kind} "${provenance.source}" failed: ${cause instanceof Error ? cause.message : String(cause)}` - throw cause instanceof DomStackDataError - ? new DomStackDataError(message, cause.dataDependency, { cause }) - : new Error(message, { cause }) - } - } - for (const layout of this.layoutChain) { - const source = layout.source ?? layout.name - const hook = validatePageOutputsHook(layout.pageOutputs, source) - if (!hook) continue - yield * collect(hook, { kind: 'layout', source, layoutName: layout.name }, () => { - return this.#subscriptions.getLayoutData(layout.name, this.pageInfo) ?? Object.freeze({}) - }) - } - if (this.#pageOutputs) { - const { hook, provenance } = this.#pageOutputs - yield * collect(hook, provenance, () => this.data) - } + yield * collectPageOutputs(this, this.#subscriptions, this.#pageOutputs) } /** From 6993b30d21004b57b6a58f15d24b4c9711ddcbcd Mon Sep 17 00:00:00 2001 From: Bret Comnes Date: Wed, 16 Sep 2026 19:52:06 -0700 Subject: [PATCH 15/20] Simplify page preparation and output reporting --- lib/build-pages/build.js | 38 ++++++------ .../global-data/page-subscriptions.js | 6 +- .../global-data/page-subscriptions.test.js | 6 +- .../outputs/resolve-page-output-provider.js | 43 ++++++++++++++ .../resolve-page-output-provider.test.js | 47 +++++++++++++++ .../page/page-data-page-outputs.test.js | 26 ++++++++ lib/build-pages/page/page-data.js | 42 ++++--------- .../page/resolve-page-companion.js | 21 +++++++ .../page/resolve-page-companion.test.js | 59 +++++++++++++++++++ lib/build-pages/vars/page-vars.js | 10 +++- lib/build-pages/vars/page-vars.test.js | 28 ++++----- lib/build-pages/vars/resolve-vars.js | 26 -------- test-cases/page-outputs/index.test.js | 9 ++- test-cases/page-outputs/ownership.test.js | 7 +++ 14 files changed, 269 insertions(+), 99 deletions(-) create mode 100644 lib/build-pages/outputs/resolve-page-output-provider.js create mode 100644 lib/build-pages/outputs/resolve-page-output-provider.test.js create mode 100644 lib/build-pages/page/resolve-page-companion.js create mode 100644 lib/build-pages/page/resolve-page-companion.test.js diff --git a/lib/build-pages/build.js b/lib/build-pages/build.js index de907886..e73f4c75 100644 --- a/lib/build-pages/build.js +++ b/lib/build-pages/build.js @@ -1,6 +1,7 @@ /** * @import { BuilderOptions } from './outputs/page-writer.js' * @import { SiteData } from '../builder.js' + * @import { DomstackManifestRecord } from '../domstack-manifest/index.js' * @import { PageInfo, PagesFileInfo } from '../identify-pages.js' * @import { ResolvedLayout } from './layouts/resolve-layout.js' * @import { WatchConsumer } from './global-data/watch-dependencies.js' @@ -207,6 +208,23 @@ export async function buildPagesDirect (_src, dest, siteData, opts) { : (siteData.pagesFiles ?? []).map(pagesFile => pagesFile.pagesFile.filepath) } + /** + * @param {PageData} page + * @param {string} pageFilePath + * @param {DomstackManifestRecord[]} outputs + */ + const recordPageOutputs = (page, pageFilePath, outputs) => { + result.report.pages.push({ + pageFilePath, + sourcePageFilePath: page.pageInfo.generated ? undefined : page.pageInfo.pageFile.filepath, + pagesFilePath: page.pageInfo.generated?.pagesFile.pagesFile.filepath, + layoutName: page.layout?.name, + layoutNames: page.layoutChain.map(layout => layout.name), + outputs, + }) + result.outputs.push(...outputs) + } + /** @param {PageData} page */ const writePage = async (page) => { try { @@ -216,29 +234,13 @@ export async function buildPagesDirect (_src, dest, siteData, opts) { outputCache, }) - result.report.pages.push({ - pageFilePath: buildResult.pageFilePath, - sourcePageFilePath: page.pageInfo.generated ? undefined : page.pageInfo.pageFile.filepath, - pagesFilePath: page.pageInfo.generated?.pagesFile.pagesFile.filepath, - layoutName: page.layout?.name, - layoutNames: page.layoutChain.map(layout => layout.name), - outputs: buildResult.outputs, - }) - result.outputs.push(...buildResult.outputs) + recordPageOutputs(page, buildResult.pageFilePath, buildResult.outputs) return true } catch (err) { // Direct writes already emitted by a failed iterator still need ownership // so a later successful watch rebuild can remove them. if (page.outputRecords.length > 0) { - result.report.pages.push({ - pageFilePath: join(dest, page.pageInfo.outputRelname), - sourcePageFilePath: page.pageInfo.generated ? undefined : page.pageInfo.pageFile.filepath, - pagesFilePath: page.pageInfo.generated?.pagesFile.pagesFile.filepath, - layoutName: page.layout?.name, - layoutNames: page.layoutChain.map(layout => layout.name), - outputs: page.outputRecords, - }) - result.outputs.push(...page.outputRecords) + recordPageOutputs(page, join(dest, page.pageInfo.outputRelname), page.outputRecords) } result.errors.push(serializeBuildError(err, { page: pageInfoForWorker(page.pageInfo) }, `Error building page "${page.pageInfo.pageFile.relname}"`)) return false diff --git a/lib/build-pages/global-data/page-subscriptions.js b/lib/build-pages/global-data/page-subscriptions.js index b558230b..1d6fefc9 100644 --- a/lib/build-pages/global-data/page-subscriptions.js +++ b/lib/build-pages/global-data/page-subscriptions.js @@ -16,7 +16,9 @@ export class PageSubscriptions { /** @param {string[]} companionKeys @param {string[]} builderKeys */ setPageDependencies (companionKeys, builderKeys) { - this.#pageKeys = [...new Set([...companionKeys, ...builderKeys])].sort() + const keys = new Set(companionKeys) + for (const key of builderKeys) keys.add(key) + this.#pageKeys = [...keys].sort() } /** @param {string} name @param {string[]} keys */ @@ -25,7 +27,7 @@ export class PageSubscriptions { } /** The invalidation union is broader than any individual renderer's access. */ - get dependencies () { + collectDependencies () { const keys = new Set(this.#pageKeys) for (const layout of this.#layouts.values()) { for (const key of layout.keys) keys.add(key) diff --git a/lib/build-pages/global-data/page-subscriptions.test.js b/lib/build-pages/global-data/page-subscriptions.test.js index e61f6048..0c0b9836 100644 --- a/lib/build-pages/global-data/page-subscriptions.test.js +++ b/lib/build-pages/global-data/page-subscriptions.test.js @@ -13,7 +13,7 @@ test('keeps page and layout access narrower than the invalidation union', () => subscriptions.setPageDependencies(['posts', 'shared'], ['shared']) subscriptions.addLayout('root', ['navigation', 'shared']) subscriptions.addLayout('article', ['author']) - assert.deepEqual(subscriptions.dependencies, ['author', 'navigation', 'posts', 'shared']) + assert.deepEqual(subscriptions.collectDependencies(), ['author', 'navigation', 'posts', 'shared']) const posts = [{ title: 'First' }] subscriptions.bind({ posts, shared: true, navigation: ['Home'], author: 'Author' }, pageInfo) @@ -39,9 +39,9 @@ test('guards only declared data before binding and preserves readiness error met assert.deepEqual(error.dataDependency, { reason: 'NOT_READY', consumer: 'Page "blog/page.js"' }) return true }) - assert.throws(() => subscriptions.assertReady(subscriptions.dependencies, pageInfo), /Global data is not available/) + assert.throws(() => subscriptions.assertReady(subscriptions.collectDependencies(), pageInfo), /Global data is not available/) subscriptions.bind({ navigation: [] }, pageInfo) - assert.doesNotThrow(() => subscriptions.assertReady(subscriptions.dependencies, pageInfo)) + assert.doesNotThrow(() => subscriptions.assertReady(subscriptions.collectDependencies(), pageInfo)) }) test('a failed first binding remains unready and can be retried', () => { diff --git a/lib/build-pages/outputs/resolve-page-output-provider.js b/lib/build-pages/outputs/resolve-page-output-provider.js new file mode 100644 index 00000000..5e12a878 --- /dev/null +++ b/lib/build-pages/outputs/resolve-page-output-provider.js @@ -0,0 +1,43 @@ +/** + * @import { PageInfo } from '../../identify-pages.js' + * @import { DomStackWarning } from '../../helpers/domstack-warning.js' + * @import { PageOutputsFunction } from './page-outputs.js' + * @import { PageOutputProvider } from './collect-page-outputs.js' + */ +import { validatePageOutputsHook } from './page-outputs.js' + +/** + * Select a provider without invoking it, validating even an overridden companion hook. + * @template {Record} T + * @param {PageInfo} pageInfo + * @param {PageOutputsFunction | undefined} pageOutputs + * @param {Record | undefined} companionExports + * @returns {{ provider?: PageOutputProvider, warning?: DomStackWarning | undefined }} + */ +export function resolvePageOutputProvider (pageInfo, pageOutputs, companionExports) { + if (pageInfo.generated) return {} + + const pageHook = pageInfo.type === 'js' ? pageOutputs : undefined + const companionPath = pageInfo.pageVars?.filepath + const companionHook = companionPath + ? validatePageOutputsHook(companionExports?.['pageOutputs'], companionPath) + : undefined + const hook = pageHook ?? companionHook + if (!hook) return {} + + return { + provider: { + hook, + provenance: { + kind: pageHook ? 'page' : 'companion', + source: pageHook ? pageInfo.pageFile.filepath : /** @type {string} */ (companionPath), + }, + }, + warning: pageHook && companionHook + ? { + code: 'DOM_STACK_WARNING_DUPLICATE_PAGE_OUTPUTS_PROVIDER', + message: `Page "${pageInfo.pageFile.filepath}" and companion "${companionPath}" both export pageOutputs; using the page module export and ignoring the companion export`, + } + : undefined, + } +} diff --git a/lib/build-pages/outputs/resolve-page-output-provider.test.js b/lib/build-pages/outputs/resolve-page-output-provider.test.js new file mode 100644 index 00000000..0166a8da --- /dev/null +++ b/lib/build-pages/outputs/resolve-page-output-provider.test.js @@ -0,0 +1,47 @@ +/** + * @import { PageInfo } from '../../identify-pages.js' + */ +import assert from 'node:assert/strict' +import { test } from 'node:test' +import { resolvePageOutputProvider } from './resolve-page-output-provider.js' + +/** @param {PageInfo['type']} [type] @returns {PageInfo} */ +function pageInfo (type = 'js') { + return /** @type {PageInfo} */ ({ + type, + pageFile: { filepath: '/src/page.js' }, + pageVars: { filepath: '/src/page.vars.js' }, + }) +} + +const pageHook = () => { throw new Error('page hook must remain lazy') } +const companionHook = () => { throw new Error('companion hook must remain lazy') } + +test('JS page providers win with a warning and source provenance', () => { + const { provider, warning } = resolvePageOutputProvider(pageInfo(), pageHook, { pageOutputs: companionHook }) + assert.equal(provider?.hook, pageHook) + assert.deepEqual(provider?.provenance, { kind: 'page', source: '/src/page.js' }) + assert.equal(warning?.code, 'DOM_STACK_WARNING_DUPLICATE_PAGE_OUTPUTS_PROVIDER') + assert.match(warning?.message ?? '', /page.js.*page.vars.js.*ignoring the companion/) +}) + +test('companions provide JS, HTML and Markdown outputs without warnings', () => { + for (const type of /** @type {const} */ (['js', 'html', 'md'])) { + const { provider, warning } = resolvePageOutputProvider(pageInfo(type), undefined, { pageOutputs: companionHook }) + assert.equal(provider?.hook, companionHook) + assert.deepEqual(provider?.provenance, { kind: 'companion', source: '/src/page.vars.js' }) + assert.equal(warning, undefined) + } + assert.deepEqual(resolvePageOutputProvider(pageInfo(), undefined, undefined), {}) + assert.deepEqual(resolvePageOutputProvider(pageInfo('html'), pageHook, undefined), {}) +}) + +test('malformed companion hooks are rejected even when a page provider wins', () => { + assert.throws(() => resolvePageOutputProvider(pageInfo(), pageHook, { pageOutputs: null }), /pageOutputs.*page.vars.js.*must be a function/) +}) + +test('generated pages skip provider selection and companion validation', () => { + const page = pageInfo() + page.generated = { pagesFile: { pagesFile: page.pageFile, path: '', name: 'generated' }, children: '' } + assert.deepEqual(resolvePageOutputProvider(page, pageHook, { pageOutputs: null }), {}) +}) diff --git a/lib/build-pages/page/page-data-page-outputs.test.js b/lib/build-pages/page/page-data-page-outputs.test.js index 9017f463..779e9c28 100644 --- a/lib/build-pages/page/page-data-page-outputs.test.js +++ b/lib/build-pages/page/page-data-page-outputs.test.js @@ -385,6 +385,32 @@ test('invalid output exports identify their sources', async t => { await assert.rejects(resolveLayout(path), /pageOutputs.*bad.layout.mjs.*function/) }) +test('initialization preserves companion vars, postVars, builder, then hook error ordering', async t => { + for (const [companion, expected] of [ + ['export default () => null; export const postVars = true', /Var function must resolve to a plain object/], + ['export const postVars = true; export const pageOutputs = null', /postVars is no longer supported/], + ['export const pageOutputs = null', /builder failed/], + ]) { + assert.ok(typeof companion === 'string') + assert.ok(expected instanceof RegExp) + const { pd, layouts } = await fixture(t, { + module: "throw new Error('builder failed')", + companion, + }) + await assert.rejects(pd.init({ layouts }), expected) + } +}) + +test('provider selection observes companion export changes made by the page builder', async t => { + const { pd, layouts } = await fixture(t, { + module: "import { replaceHook } from './page.vars.mjs'; replaceHook(); export default () => ''", + companion: `export let pageOutputs = null + export function replaceHook () { pageOutputs = () => ({ outputName: 'live.txt', content: 'live' }) }`, + }) + await pd.init({ layouts }) + assert.equal((await Array.fromAsync(pd.collectPageOutputs()))[0]?.content, 'live') +}) + test('generated pages skip page, companion and all layout hooks', async t => { const { pd, layouts } = await fixture(t, { module: "throw new Error('must not import source module')", companion: 'export const pageOutputs = 123' }) pd.pageInfo.generated = { pagesFile: { pagesFile: pd.pageInfo.pageFile, path: '', name: 'generated' }, children: 'generated' } diff --git a/lib/build-pages/page/page-data.js b/lib/build-pages/page/page-data.js index ff62807b..abb229d1 100644 --- a/lib/build-pages/page/page-data.js +++ b/lib/build-pages/page/page-data.js @@ -11,7 +11,7 @@ import { readFile } from 'node:fs/promises' import { normalize } from 'node:path' import { toPosix } from '../../helpers/path.js' -import { resolveVars, resolvePostVars } from '../vars/resolve-vars.js' +import { resolvePageCompanion } from './resolve-page-companion.js' import { PageVars } from '../vars/page-vars.js' import { pageBuilders } from '../page-builders/index.js' import { parseMdFileContents } from '../page-builders/md/parse-md.js' @@ -21,7 +21,7 @@ import { PageSubscriptions } from '../global-data/page-subscriptions.js' import pretty from 'pretty' import { resolveLayoutChain } from '../layouts/resolve-layout-chain.js' import { resolveLayoutName } from '../layouts/resolve-layout-name.js' -import { validatePageOutputsHook } from '../outputs/page-outputs.js' +import { resolvePageOutputProvider } from '../outputs/resolve-page-output-provider.js' import { collectPageOutputs } from '../outputs/collect-page-outputs.js' /** @@ -110,7 +110,7 @@ export class PageData { get vars () { if (!this.#initialized) throw new Error(`Initialize PageData before accessing vars for page "${this.pageInfo?.path ?? ''}"`) try { - return this.#vars.get(this) + return this.#vars.getCached(this) } catch (err) { throw new Error( `Failed to resolve vars for page "${this.pageInfo?.path ?? ''}": ${err instanceof Error ? err.message : String(err)}`, @@ -167,36 +167,14 @@ export class PageData { const { pageInfo, globalVars } = this if (!pageInfo) throw new Error('A page is required to initialize') const { pageVars, type } = pageInfo - const resolvedPageVars = await resolveVars({ - varsPath: pageVars?.filepath, - }) - await resolvePostVars({ varsPath: pageVars?.filepath }) // throws if postVars export is detected + const { vars: resolvedPageVars, exports: companionExports } = await resolvePageCompanion(pageVars?.filepath) const builder = pageBuilders[type] const built = await builder({ pageInfo, options: this.builderOptions }) const { vars: builderVars } = built - if (!pageInfo.generated) { - const pageModuleOutputs = type === 'js' ? built.pageOutputs : undefined - const varsCompanionOutputs = pageVars?.filepath - ? validatePageOutputsHook((await import(pageVars.filepath)).pageOutputs, pageVars.filepath) - : undefined - if (pageModuleOutputs && varsCompanionOutputs) { - this.warnings.push({ - code: 'DOM_STACK_WARNING_DUPLICATE_PAGE_OUTPUTS_PROVIDER', - message: `Page "${pageInfo.pageFile.filepath}" and companion "${pageVars?.filepath}" both export pageOutputs; using the page module export and ignoring the companion export`, - }) - } - const hook = pageModuleOutputs ?? varsCompanionOutputs - if (hook) { - this.#pageOutputs = { - hook, - provenance: { - kind: pageModuleOutputs ? 'page' : 'companion', - source: pageModuleOutputs ? pageInfo.pageFile.filepath : /** @type {string} */ (pageVars?.filepath), - }, - } - } - } + const { provider, warning } = resolvePageOutputProvider(pageInfo, built.pageOutputs, companionExports) + this.#pageOutputs = provider + if (warning) this.warnings.push(warning) const layoutName = resolveLayoutName(globalVars, resolvedPageVars, builderVars) @@ -204,7 +182,7 @@ export class PageData { this.layout = this.layoutChain.at(-1) const pageResolution = extractDataDeps(resolvedPageVars, `Page vars "${pageInfo.pageFile.relname}"`) const builderResolution = extractDataDeps(builderVars, `Page "${pageInfo.pageFile.relname}"`) - this.pageVars = pageResolution.vars + this.pageVars = /** @type {Partial} */ (pageResolution.vars) this.builderVars = /** @type {Partial} */ (builderResolution.vars) this.#subscriptions.setPageDependencies(pageResolution.dataDeps, builderResolution.dataDeps) for (const layout of this.layoutChain) { @@ -216,7 +194,7 @@ export class PageData { if (layout.layoutClientPath) this.scripts.push(layout.layoutClientPath) } - this.dataDeps = this.#subscriptions.dependencies + this.dataDeps = this.#subscriptions.collectDependencies() if (pageInfo.pageStyle) { this.styles.push(`./${pageInfo.pageStyle.outputName}`) @@ -236,7 +214,7 @@ export class PageData { // First vars access must still observe source changes made after init. /** @type {object} */ - const finalVars = this.#vars.merge(this) + const finalVars = this.#vars.mergeUncached(this) // disable-eslint-next-line dot-notation if ('defaultStyle' in finalVars && finalVars.defaultStyle) { diff --git a/lib/build-pages/page/resolve-page-companion.js b/lib/build-pages/page/resolve-page-companion.js new file mode 100644 index 00000000..3828cc98 --- /dev/null +++ b/lib/build-pages/page/resolve-page-companion.js @@ -0,0 +1,21 @@ +import { resolveVarsExport } from '../vars/resolve-vars.js' + +/** + * Load the companion once, retaining live exports for later provider selection. + * @param {string | undefined} varsPath + * @returns {Promise<{ vars: Record, exports: Record | undefined }>} + */ +export async function resolvePageCompanion (varsPath) { + if (!varsPath) return { vars: {}, exports: undefined } + + const exports = await import(varsPath) + const vars = await resolveVarsExport(exports.default, 'Var') + if (exports.postVars) { + throw new Error( + `postVars is no longer supported (found in ${varsPath}). ` + + 'Move data aggregation to a global.data.js file instead. ' + + 'See the domstack docs for details.' + ) + } + return { vars, exports } +} diff --git a/lib/build-pages/page/resolve-page-companion.test.js b/lib/build-pages/page/resolve-page-companion.test.js new file mode 100644 index 00000000..af6bc591 --- /dev/null +++ b/lib/build-pages/page/resolve-page-companion.test.js @@ -0,0 +1,59 @@ +/** + * @import { TestContext } from 'node:test' + */ +import assert from 'node:assert/strict' +import { test } from 'node:test' +import { mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { resolvePageCompanion } from './resolve-page-companion.js' + +/** @param {TestContext} t @param {string} source */ +async function companion (t, source) { + const dir = await mkdtemp(join(tmpdir(), 'domstack-companion-')) + t.after(() => rm(dir, { recursive: true, force: true })) + const path = join(dir, 'page.vars.mjs') + await writeFile(path, source) + return path +} + +test('missing companions resolve to empty vars without exports', async () => { + assert.deepEqual(await resolvePageCompanion(undefined), { vars: {}, exports: undefined }) +}) + +test('companions resolve object and async function vars without validating output hooks', async t => { + for (const value of ["{ title: 'page' }", "async () => ({ title: 'page' })"]) { + const path = await companion(t, `export default ${value}; export const pageOutputs = 123`) + const result = await resolvePageCompanion(path) + assert.deepEqual(result.vars, { title: 'page' }) + assert.equal(result.exports?.['pageOutputs'], 123) + } +}) + +test('vars errors precede obsolete postVars errors, and only truthy postVars is rejected', async t => { + const invalidVars = await companion(t, 'export default () => null; export const postVars = true') + await assert.rejects(resolvePageCompanion(invalidVars), /Var function must resolve to a plain object/) + const obsolete = await companion(t, 'export default {}; export const postVars = () => {}') + await assert.rejects(resolvePageCompanion(obsolete), error => { + assert.ok(error instanceof Error) + assert.ok(error.message.includes(`postVars is no longer supported (found in ${obsolete})`)) + return true + }) + const falsy = await companion(t, 'export default {}; export const postVars = false') + assert.deepEqual((await resolvePageCompanion(falsy)).vars, {}) +}) + +test('companion exports stay live after vars resolution without invoking output hooks', async t => { + const path = await companion(t, ` + export let pageOutputs = () => { throw new Error('must stay lazy') } + export default () => ({ title: 'page' }) + export function replaceHook () { pageOutputs = () => [] } + `) + const result = await resolvePageCompanion(path) + const exports = await import(path) + assert.equal(result.exports, exports) + const original = result.exports?.['pageOutputs'] + exports.replaceHook() + assert.notEqual(result.exports?.['pageOutputs'], original) + assert.equal(result.exports?.['pageOutputs'], exports.pageOutputs) +}) diff --git a/lib/build-pages/vars/page-vars.js b/lib/build-pages/vars/page-vars.js index d1ae4755..18e6c0ae 100644 --- a/lib/build-pages/vars/page-vars.js +++ b/lib/build-pages/vars/page-vars.js @@ -16,7 +16,7 @@ export class PageVars { /** @type {(Partial | null)[] | null} */ #sources = null /** @param {PageVarSources} page @returns {T} */ - get (page) { + getCached (page) { if (this.#cache && this.#sourcesUnchanged(page)) return this.#cache const sources = this.#collectSources(page) // Commit both cache fields only after a successful merge. @@ -30,13 +30,17 @@ export class PageVars { * @param {PageVarSources} page * @returns {T} */ - merge (page) { + mergeUncached (page) { return this.#merge(this.#collectSources(page)) } /** @param {PageVarSources} page */ #collectSources (page) { - return [page.globalVars, ...page.layoutVars.map(layout => layout.vars), page.pageVars, page.builderVars] + /** @type {(Partial | null)[]} */ + const sources = [page.globalVars] + for (const layout of page.layoutVars) sources.push(layout.vars) + sources.push(page.pageVars, page.builderVars) + return sources } /** @param {PageVarSources} page */ diff --git a/lib/build-pages/vars/page-vars.test.js b/lib/build-pages/vars/page-vars.test.js index a2213680..92f8039b 100644 --- a/lib/build-pages/vars/page-vars.test.js +++ b/lib/build-pages/vars/page-vars.test.js @@ -13,25 +13,25 @@ function sources () { test('an uncached initialization merge does not establish the first-access snapshot', () => { const page = sources() const vars = new PageVars() - assert.deepEqual(vars.merge(page), { title: 'global' }) + assert.deepEqual(vars.mergeUncached(page), { title: 'global' }) page.globalVars['title'] = 'before first access' - const snapshot = vars.get(page) + const snapshot = vars.getCached(page) assert.equal(snapshot['title'], 'before first access') page.globalVars['title'] = 'after first access' - assert.equal(vars.get(page), snapshot) - assert.equal(vars.merge(page)['title'], 'after first access') - assert.equal(vars.get(page), snapshot, 'uncached merges do not replace an existing snapshot') + assert.equal(vars.getCached(page), snapshot) + assert.equal(vars.mergeUncached(page)['title'], 'after first access') + assert.equal(vars.getCached(page), snapshot, 'uncached merges do not replace an existing snapshot') }) -test('cache hits compare source identities without enumerating or mapping sources', () => { +test('cache hits compare source identities without enumerating or collecting sources', () => { const page = sources() let reads = 0 page.globalVars = { get title () { reads++; return 'global' } } page.layoutVars = [{ vars: { title: 'layout' } }] const vars = new PageVars() - const snapshot = vars.get(page) - page.layoutVars.map = () => { throw new Error('Cache hits must not collect sources') } - for (let index = 0; index < 10; index++) assert.equal(vars.get(page), snapshot) + const snapshot = vars.getCached(page) + page.layoutVars[Symbol.iterator] = () => { throw new Error('Cache hits must not collect sources') } + for (let index = 0; index < 10; index++) assert.equal(vars.getCached(page), snapshot) assert.equal(reads, 1) assert.equal(Object.isFrozen(snapshot), true) assert.equal(snapshot['title'], 'layout') @@ -40,14 +40,14 @@ test('cache hits compare source identities without enumerating or mapping source test('failed merges preserve the previous snapshot and can be retried', () => { const page = sources() const vars = new PageVars() - const first = vars.get(page) + const first = vars.getCached(page) const original = page.globalVars const cause = new Error('Failed getter') page.globalVars = { get title () { throw cause } } - assert.throws(() => vars.get(page), error => error === cause) - assert.throws(() => vars.get(page), error => error === cause) + assert.throws(() => vars.getCached(page), error => error === cause) + assert.throws(() => vars.getCached(page), error => error === cause) page.globalVars = original - assert.equal(vars.get(page), first) + assert.equal(vars.getCached(page), first) page.globalVars = { title: 'recovered' } - assert.equal(vars.get(page)['title'], 'recovered') + assert.equal(vars.getCached(page)['title'], 'recovered') }) diff --git a/lib/build-pages/vars/resolve-vars.js b/lib/build-pages/vars/resolve-vars.js index 40c7ee14..55e7108d 100644 --- a/lib/build-pages/vars/resolve-vars.js +++ b/lib/build-pages/vars/resolve-vars.js @@ -38,29 +38,3 @@ export async function resolveVars ({ const imported = await import(varsPath) return await resolveVarsExport(imported[key], 'Var') } - -/** - * Resolve variables by importing them from a specified path. - * - * @param {object} params - * @param {string | undefined} [params.varsPath] - Path to the file containing the variables. - * @returns {Promise} - */ -export async function resolvePostVars ({ - varsPath, -}) { - if (!varsPath) return null - - const imported = await import(varsPath) - const maybePostVars = imported.postVars - - if (maybePostVars) { - throw new Error( - `postVars is no longer supported (found in ${varsPath}). ` + - 'Move data aggregation to a global.data.js file instead. ' + - 'See the domstack docs for details.' - ) - } - - return null -} diff --git a/test-cases/page-outputs/index.test.js b/test-cases/page-outputs/index.test.js index ebdafe31..985b5b64 100644 --- a/test-cases/page-outputs/index.test.js +++ b/test-cases/page-outputs/index.test.js @@ -10,7 +10,7 @@ export const pageOutputs = async ({ page }) => ({ outputName: './source.txt', co test('builder renders Markdown and exports the unrendered body from its layout at a custom destination', async t => { const body = '# Article\n\nKeep **Markdown**, {{ vars.title }}, and [links](./other.md).\n' - const { build, read, dest } = await setup(t, { + const { build, read, dest, src } = await setup(t, { 'root.layout.js': rawLayout, 'docs/page.md': '---\ntitle: Resolved title\n---\n' + body, }) @@ -22,6 +22,13 @@ test('builder renders Markdown and exports the unrendered body from its layout a assert.ok(record, 'page output is included in the page build report') assert.equal(record.filepath, join(dest, 'docs/source.txt')) assert.equal(record.sourceRelname, 'docs/page.md') + const pageReport = result.pageBuildResults?.report.pages.find(page => page.sourcePageFilePath === join(src, 'docs/page.md')) + assert.ok(pageReport) + assert.equal(pageReport.pageFilePath, join(dest, 'docs/index.html')) + assert.equal(pageReport.pagesFilePath, undefined) + assert.equal(pageReport.layoutName, 'root') + assert.deepEqual(pageReport.layoutNames, ['root']) + assert.ok(pageReport.outputs?.some(output => output.filepath === record.filepath)) }) test('nested hooks run outer -> inner -> companion with isolated renderer data and resolved vars', async t => { diff --git a/test-cases/page-outputs/ownership.test.js b/test-cases/page-outputs/ownership.test.js index 7ecdcebf..97508013 100644 --- a/test-cases/page-outputs/ownership.test.js +++ b/test-cases/page-outputs/ownership.test.js @@ -82,6 +82,13 @@ for (const change of ['recovery', 'source deletion', 'hook removal']) { assert.ok(logs.some(line => line.includes('initial ownership failure'))) assert.equal(await read('article/partial.txt'), 'partial') assert.equal(await read('root-partial.txt'), 'root partial') + const pageReport = result.pageBuildResults?.report.pages.find(page => page.sourcePageFilePath === join(src, 'article/page.html')) + assert.ok(pageReport) + assert.equal(pageReport.pageFilePath, join(dest, 'article/index.html')) + assert.equal(pageReport.pagesFilePath, undefined) + assert.equal(pageReport.layoutName, 'root') + assert.deepEqual(pageReport.layoutNames, ['root']) + assert.deepEqual(pageReport.outputs?.map(output => output.outputRelname), ['article/partial.txt', 'root-partial.txt']) for (const outputRelname of ['article/partial.txt', 'root-partial.txt']) { const record = result.pageBuildResults?.outputs.find(output => output.outputRelname === outputRelname) assert.ok(record, `${outputRelname} is included in the failed page build report`) From d1e0f5d0480f288feb3de14eaa77c910f4a11669 Mon Sep 17 00:00:00 2001 From: Bret Comnes Date: Wed, 16 Sep 2026 20:07:30 -0700 Subject: [PATCH 16/20] Reuse prepared page renderers within each build --- docs/pages/README.md | 5 + .../page/page-data-renderer.test.js | 145 ++++++++++++++++++ lib/build-pages/page/page-data.js | 15 +- lib/watch/prepared-renderers.test.js | 116 ++++++++++++++ .../helpers.js => lib/watch/test-helpers.js | 0 site/layouts/docs/navigation-watch.test.js | 2 +- test-cases/generated-pages/index.test.js | 2 +- test-cases/generated-pages/streaming.test.js | 2 +- test-cases/incremental-global-data/helpers.js | 2 +- test-cases/nested-layouts/index.test.js | 2 +- test-cases/page-outputs/cache.test.js | 2 +- test-cases/page-outputs/ownership.test.js | 2 +- test-cases/page-outputs/watch.test.js | 2 +- test-cases/watch/index.test.js | 2 +- 14 files changed, 283 insertions(+), 16 deletions(-) create mode 100644 lib/build-pages/page/page-data-renderer.test.js create mode 100644 lib/watch/prepared-renderers.test.js rename test-cases/watch/helpers.js => lib/watch/test-helpers.js (100%) diff --git a/docs/pages/README.md b/docs/pages/README.md index a36ccf14..36f0c5d3 100644 --- a/docs/pages/README.md +++ b/docs/pages/README.md @@ -30,6 +30,11 @@ Variables are available in all pages. `ts` pages receive variables as part of the argument passed to them. See the [Variables](../../docs/pages/#variables) section for more info. +DOMStack prepares each page's renderer once per build. +HTML and Markdown source content is captured when the page initializes; repeated renders reuse that content but still receive the current render inputs. +Watch rebuilds prepare fresh renderers, so an edit observed during a build is picked up by a subsequent build. +The `readMarkdownContent()` helper remains a fresh read of the source file rather than a read of the prepared content. + Pages can define a special variable called [`layout`](../layouts/#selecting-a-layout) that determines which layout the page is rendered into. Because pages are just directories, they nest and structure naturally as a filesystem router. diff --git a/lib/build-pages/page/page-data-renderer.test.js b/lib/build-pages/page/page-data-renderer.test.js new file mode 100644 index 00000000..2298f8c4 --- /dev/null +++ b/lib/build-pages/page/page-data-renderer.test.js @@ -0,0 +1,145 @@ +/** + * @import { TestContext } from 'node:test' + * @import { PageInfo } from '../../identify-pages.js' + * @import { ResolvedLayout } from '../layouts/resolve-layout.js' + */ +import assert from 'node:assert/strict' +import { test } from 'node:test' +import { mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { PageData } from './page-data.js' +import { pageBuilders } from '../page-builders/index.js' + +/** @type {Record>} */ +const layouts = { root: { name: 'root', vars: {}, render: ({ children }) => children, layoutStylePath: null, layoutClientPath: null } } + +/** @param {TestContext} t @param {PageInfo['type']} type @param {string} content */ +async function fixture (t, type, content) { + const dir = await mkdtemp(join(tmpdir(), 'domstack-renderer-')) + t.after(() => rm(dir, { recursive: true, force: true })) + const name = `page.${type === 'js' ? 'mjs' : type}` + const filepath = join(dir, name) + await writeFile(filepath, content) + /** @type {PageInfo} */ + const pageInfo = { + pageFile: { root: dir, filepath, relname: name, basename: name, parentName: '' }, + type, + path: '', + url: '/', + outputName: 'index.html', + outputRelname: 'index.html', + draft: false, + } + const createPage = () => new PageData({ + pageInfo, + globalVars: { layout: 'root', handlebars: true, value: 'initial' }, + globalStyle: undefined, + globalClient: undefined, + defaultStyle: null, + defaultClient: null, + builderOptions: {}, + }) + return { page: createPage(), createPage, filepath } +} + +for (const type of /** @type {const} */ (['html', 'md'])) { + test(`${type} prepares once, captures source, and still receives live render inputs`, async t => { + const { page, createPage, filepath } = await fixture(t, type, 'Original {{ vars.value }}') + const builder = t.mock.method(pageBuilders, type) + await assert.rejects(page.renderInnerPage(), /initialized/) + await page.init({ layouts }) + await page.init({ layouts }) + assert.equal(builder.mock.callCount(), 1) + page.globalVars['value'] = 'before first access' + await writeFile(filepath, 'Changed {{ vars.value }}') + assert.match(await page.renderInnerPage(), /Original before first access/) + page.globalVars = { ...page.globalVars, value: 'replacement' } + assert.match(await page.renderFullPage(), /Original replacement/) + assert.equal(builder.mock.callCount(), 1, 'rendering does not repeat builder preparation') + if (type === 'md') assert.equal(await page.readMarkdownContent(), 'Changed {{ vars.value }}', 'raw-content reads remain independent') + const fresh = createPage() + await fresh.init({ layouts }) + assert.match(await fresh.renderInnerPage(), /Changed initial/) + assert.equal(builder.mock.callCount(), 2) + }) +} + +test('prepared JS functions rerun with current data and assets, without rebinding this or invoking hooks', async t => { + const { page, filepath } = await fixture(t, 'js', ` + export const vars = { dataDeps: ['selected'] } + export let calls = 0 + export let hookCalls = 0 + export default function ({ vars, data, styles, scripts, workers }) { + if (this !== undefined) throw new Error('renderer must not be bound to PageData') + return JSON.stringify({ calls: ++calls, value: vars.value, selected: data.selected, styles, scripts, workers }) + } + export function pageOutputs () { hookCalls++; return [] } + `) + const builder = t.mock.method(pageBuilders, 'js') + await page.init({ layouts }) + await assert.rejects(page.renderInnerPage(), /Global data is not available/) + page.setGlobalData({ selected: 'first' }) + assert.equal(JSON.parse(await page.renderInnerPage()).selected, 'first') + page.setGlobalData({ selected: 'second' }) + page.styles = ['/new.css'] + page.scripts = ['/new.js'] + page.workerFiles = { search: '/search.js' } + page.globalVars = { ...page.globalVars, value: 'new vars' } + assert.deepEqual(JSON.parse(await page.renderInnerPage()), { + calls: 2, value: 'new vars', selected: 'second', styles: ['/new.css'], scripts: ['/new.js'], workers: { search: '/search.js' }, + }) + assert.equal(builder.mock.callCount(), 1) + const module = await import(filepath) + assert.equal(module.hookCalls, 0) + await Array.fromAsync(page.collectPageOutputs()) + assert.equal(module.hookCalls, 1) +}) + +test('generated renderers are prepared once without caching their results', async t => { + const { page } = await fixture(t, 'js', "throw new Error('generated pages must not import source')") + let calls = 0 + page.pageInfo.generated = { + pagesFile: { pagesFile: page.pageInfo.pageFile, path: '', name: 'generated' }, + children: () => `render ${++calls}`, + } + const builder = t.mock.method(pageBuilders, 'js') + await page.init({ layouts }) + assert.equal(await page.renderInnerPage(), 'render 1') + page.pageInfo.generated.children = 'replacement' + assert.equal(await page.renderFullPage(), 'render 2') + assert.equal(builder.mock.callCount(), 1) +}) + +test('generated static children are captured at initialization', async t => { + const { page } = await fixture(t, 'js', "throw new Error('generated pages must not import source')") + page.pageInfo.generated = { + pagesFile: { pagesFile: page.pageInfo.pageFile, path: '', name: 'generated' }, + children: 'original', + } + await page.init({ layouts }) + page.pageInfo.generated.children = 'replacement' + assert.equal(await page.renderInnerPage(), 'original') +}) + +test('JS default export selection is fixed at initialization', async t => { + const { page, filepath } = await fixture(t, 'js', ` + let render = () => 'original' + export { render as default } + export function replace () { render = () => 'replacement' } + `) + await page.init({ layouts }) + const module = await import(filepath) + module.replace() + assert.equal(module.default(), 'replacement') + assert.equal(await page.renderInnerPage(), 'original') +}) + +test('failed initialization cannot expose a prepared renderer and can retry preparation', async t => { + const { page, filepath } = await fixture(t, 'html', 'Original') + await assert.rejects(page.init({ layouts: {} }), /layout/i) + await assert.rejects(page.renderInnerPage(), /initialized/) + await writeFile(filepath, 'Recovered') + await page.init({ layouts }) + assert.equal(await page.renderInnerPage(), 'Recovered') +}) diff --git a/lib/build-pages/page/page-data.js b/lib/build-pages/page/page-data.js index abb229d1..b324c266 100644 --- a/lib/build-pages/page/page-data.js +++ b/lib/build-pages/page/page-data.js @@ -48,6 +48,7 @@ export class PageData { /** @type {string[]} Union of the page and entire layout chain, for output invalidation. */ dataDeps = [] /** @type {PageSubscriptions} */ #subscriptions = new PageSubscriptions() /** @type {PageOutputProvider | undefined} */ #pageOutputs + /** @type {PageFunction | null} Renderer prepared for this page's build lifetime. */ #render = null /** @type {string[]} */ styles = [] /** @type {string[]} */ scripts = [] @@ -158,7 +159,8 @@ export class PageData { } /** - * Resolve the page's vars, layout chain, and assets once before rendering. + * Prepare the renderer and resolve vars, layout chain, and assets once before rendering. + * HTML/Markdown source is captured here; render inputs are supplied on each call. * @param {object} params - Parameters required to initialize * @param {Record>} params.layouts - Layouts indexed by name. */ @@ -222,6 +224,8 @@ export class PageData { if (this.#defaultStyle) this.styles.unshift(`/${this.#defaultStyle}`) } + // Discovery selects the builder; the caller's U describes that page's result. + this.#render = /** @type {PageFunction} */ (built.pageLayout) this.#initialized = true } @@ -241,13 +245,10 @@ export class PageData { * @returns {Promise>} The page's render value, before any layout runs. */ async renderInnerPage () { - if (!this.#initialized) throw new Error('Must be initialized before rendering inner pages') - const { pageInfo, styles, scripts, vars, data, builderOptions, workers } = this + const render = this.#render + if (!this.#initialized || !render) throw new Error('Must be initialized before rendering inner pages') + const { pageInfo, styles, scripts, vars, data, workers } = this if (!pageInfo) throw new Error('A page is required to render') - const builder = pageBuilders[pageInfo.type] - const { pageLayout } = await builder({ pageInfo, options: builderOptions }) - // Discovery selects the builder; the caller's U describes that page's result. - const render = /** @type {PageFunction} */ (pageLayout) const results = await render({ vars, data, styles, scripts, page: pageInfo, workers }) return results } diff --git a/lib/watch/prepared-renderers.test.js b/lib/watch/prepared-renderers.test.js new file mode 100644 index 00000000..38560ab4 --- /dev/null +++ b/lib/watch/prepared-renderers.test.js @@ -0,0 +1,116 @@ +/** + * @import { TestContext } from 'node:test' + */ +import assert from 'node:assert/strict' +import { test } from 'node:test' +import { readFile, writeFile, access, mkdtemp, mkdir, rm } from 'node:fs/promises' +import { dirname, join } from 'node:path' +import { tmpdir } from 'node:os' +import { setTimeout as delay } from 'node:timers/promises' +import pino from 'pino' +import { DomStack } from '../../index.js' +import { startWatch, editAndWait } from './test-helpers.js' + +/** @param {TestContext} t @param {Record} files */ +async function setup (t, files) { + const root = await mkdtemp(join(tmpdir(), 'domstack-prepared-renderers-')) + const src = join(root, 'src') + const dest = join(root, 'public') + const site = new DomStack(src, dest, { static: true, domstackManifest: false, logger: pino({ level: 'silent' }) }) + t.after(async () => { + if (site.watching) await site.stopWatching() + await rm(root, { recursive: true, force: true }) + }) + await writeFile(join(root, 'package.json'), JSON.stringify({ type: 'module' })) + for (const [name, content] of Object.entries({ + 'global.vars.js': "export default { layout: 'root' }", + 'root.layout.js': 'export default ({ children }) => children', + ...files, + })) { + const path = join(src, name) + await mkdir(dirname(path), { recursive: true }) + await writeFile(path, content) + } + return { site, src, read: (/** @type {string} */ name) => readFile(join(dest, name), 'utf8') } +} + +/** @param {string} path */ +async function waitForFile (path) { + const deadline = performance.now() + 10_000 + while (await access(path).then(() => false, () => true)) { + assert.ok(performance.now() < deadline, `Timed out waiting for ${path}`) + await delay(10) + } +} + +test('a source edit during producer execution uses the current snapshot then a fresh renderer next batch', { timeout: 20_000 }, async t => { + const { site, src, read } = await setup(t, { + 'page.md': '# Original', + 'global.data.js': `import { existsSync } from 'node:fs' + import { writeFile, unlink, appendFile } from 'node:fs/promises' + import { setTimeout } from 'node:timers/promises' + const control = import.meta.dirname + '/../' + export default async ({ pages }) => { + if (existsSync(control + '.block')) { + await unlink(control + '.block') + await writeFile(control + '.started', '') + while (!existsSync(control + '.release')) await setTimeout(10) + } + const page = pages.find(page => page.pageInfo.type === 'md') + const first = await page.renderInnerPage() + const second = await page.renderInnerPage() + await appendFile(control + '.renders', JSON.stringify([first, second]) + '\\n') + return {} + }`, + }) + await startWatch(t, site, src) + const control = join(src, '..') + await writeFile(join(control, '.renders'), '') + await writeFile(join(control, '.block'), '') + const triggeringBuild = editAndWait(site, join(src, 'global.vars.js'), () => + writeFile(join(src, 'global.vars.js'), "export default { layout: 'root', title: 'trigger' }")) + let sourceEdit = Promise.resolve() + try { + await waitForFile(join(control, '.started')) + const written = Promise.withResolvers() + sourceEdit = editAndWait(site, join(src, 'page.md'), async () => { + try { + await writeFile(join(src, 'page.md'), '# Changed') + written.resolve(undefined) + } catch (error) { + written.reject(error) + throw error + } + }) + await written.promise + } finally { + await writeFile(join(control, '.release'), '') + await Promise.all([triggeringBuild, sourceEdit]) + } + const renders = (await readFile(join(control, '.renders'), 'utf8')).trim().split('\n').map(line => JSON.parse(line)) + assert.ok(renders.length >= 2, 'the source edit triggers a subsequent build') + assert.match(renders[0][0], />Original<\/h1>/) + assert.equal(renders[0][0], renders[0][1], 'producer renders share the prepared source') + assert.match(renders.at(-1)[0], />Changed<\/h1>/) + assert.equal(renders.at(-1)[0], renders.at(-1)[1]) + assert.match(await read('index.html'), /Changed/) +}) + +test('watch prepares fresh renderers for imported helpers, Markdown settings, and data-only subscribers', { timeout: 20_000 }, async t => { + const { site, src, read } = await setup(t, { + 'page.md': '# Heading', + 'markdown-it.settings.js': "export default md => { md.renderer.rules.heading_open = () => '

'; md.renderer.rules.heading_close = () => '

'; return md }", + 'helper.js': "export const text = 'first'", + 'article/page.js': "import { text } from '../helper.js'; export const vars = { dataDeps: ['selected'] }; export default ({ data }) => text + ':' + data.selected", + 'global.data.js': "export default { selected: 'one' }", + }) + await startWatch(t, site, src) + assert.match(await read('index.html'), /

Heading<\/h2>/) + assert.equal(await read('article/index.html'), 'first:one') + await editAndWait(site, join(src, 'helper.js'), () => writeFile(join(src, 'helper.js'), "export const text = 'second'")) + assert.equal(await read('article/index.html'), 'second:one') + await editAndWait(site, join(src, 'global.data.js'), () => writeFile(join(src, 'global.data.js'), "export default { selected: 'two' }")) + assert.equal(await read('article/index.html'), 'second:two') + await editAndWait(site, join(src, 'markdown-it.settings.js'), () => writeFile(join(src, 'markdown-it.settings.js'), "export default md => { md.renderer.rules.heading_open = () => '

'; md.renderer.rules.heading_close = () => '

'; return md }")) + assert.match(await read('index.html'), /

Heading<\/h3>/) +}) diff --git a/test-cases/watch/helpers.js b/lib/watch/test-helpers.js similarity index 100% rename from test-cases/watch/helpers.js rename to lib/watch/test-helpers.js diff --git a/site/layouts/docs/navigation-watch.test.js b/site/layouts/docs/navigation-watch.test.js index abaf226b..486df97b 100644 --- a/site/layouts/docs/navigation-watch.test.js +++ b/site/layouts/docs/navigation-watch.test.js @@ -10,7 +10,7 @@ import { dirname, join, resolve } from 'node:path' import { load } from 'cheerio' import pino from 'pino' import { DomStack } from '../../../index.js' -import { editAndWait, startWatch } from '../../../test-cases/watch/helpers.js' +import { editAndWait, startWatch } from '../../../lib/watch/test-helpers.js' import produceDocsData from '../../globals/global.data.ts' test('heading changes refresh shared navigation; body edits leave other pages alone', { timeout: 30_000 }, async t => { diff --git a/test-cases/generated-pages/index.test.js b/test-cases/generated-pages/index.test.js index 6d7ed36f..9e0a4816 100644 --- a/test-cases/generated-pages/index.test.js +++ b/test-cases/generated-pages/index.test.js @@ -6,7 +6,7 @@ import * as cheerio from 'cheerio' import { DomStack, testBuild } from '../../index.js' import globalData from './src/global.data.js' import { DomStackDataError } from '../../lib/helpers/domstack-error.js' -import { editAndWait, startWatch } from '../watch/helpers.js' +import { editAndWait, startWatch } from '../../lib/watch/test-helpers.js' const __dirname = import.meta.dirname const fixturePrefix = '.tmp-' diff --git a/test-cases/generated-pages/streaming.test.js b/test-cases/generated-pages/streaming.test.js index 9646f42a..fa291894 100644 --- a/test-cases/generated-pages/streaming.test.js +++ b/test-cases/generated-pages/streaming.test.js @@ -11,7 +11,7 @@ import { DomStack } from '../../index.js' import { builder } from '../../lib/builder.js' import { DomStackAggregateError } from '../../lib/helpers/domstack-aggregate-error.js' import { errorText, settle, writeFiles } from '../page-outputs/helpers.js' -import { startWatch } from '../watch/helpers.js' +import { startWatch } from '../../lib/watch/test-helpers.js' /** @param {TestContext} t @param {Record} files @param {boolean} [buildDrafts] */ async function setup (t, files, buildDrafts = false) { diff --git a/test-cases/incremental-global-data/helpers.js b/test-cases/incremental-global-data/helpers.js index 1fdcfdbc..0b89c95f 100644 --- a/test-cases/incremental-global-data/helpers.js +++ b/test-cases/incremental-global-data/helpers.js @@ -20,7 +20,7 @@ import { setImmediate as nextTurn, setTimeout as delay } from 'node:timers/promi import chokidar from 'chokidar' import pino from 'pino' import { DomStack } from '../../index.js' -import { startWatch } from '../watch/helpers.js' +import { startWatch } from '../../lib/watch/test-helpers.js' /** @param {string} heading @param {string} [body] @param {string} [frontmatter] */ export function article (heading, body = 'Original body.', frontmatter = '') { diff --git a/test-cases/nested-layouts/index.test.js b/test-cases/nested-layouts/index.test.js index 6de7034f..bba978e3 100644 --- a/test-cases/nested-layouts/index.test.js +++ b/test-cases/nested-layouts/index.test.js @@ -8,7 +8,7 @@ import { mkdtemp, mkdir, writeFile, readFile, rm, stat, unlink } from 'node:fs/p import { dirname, join } from 'node:path' import pino from 'pino' import { DomStack } from '../../index.js' -import { editAndWait, startWatch, waitForRebuild } from '../watch/helpers.js' +import { editAndWait, startWatch, waitForRebuild } from '../../lib/watch/test-helpers.js' const rootLayout = ` import { label } from './label.js' diff --git a/test-cases/page-outputs/cache.test.js b/test-cases/page-outputs/cache.test.js index 2adf8213..3760f14e 100644 --- a/test-cases/page-outputs/cache.test.js +++ b/test-cases/page-outputs/cache.test.js @@ -3,7 +3,7 @@ import assert from 'node:assert/strict' import { rm, stat, utimes, writeFile } from 'node:fs/promises' import { join } from 'node:path' import { hook, setup, settle } from './helpers.js' -import { startWatch } from '../watch/helpers.js' +import { startWatch } from '../../lib/watch/test-helpers.js' // Install the guard inside each build worker; parent-side reads remain available // for assertions, and syncBuiltinESMExports also guards already-imported bindings. diff --git a/test-cases/page-outputs/ownership.test.js b/test-cases/page-outputs/ownership.test.js index 97508013..f408de58 100644 --- a/test-cases/page-outputs/ownership.test.js +++ b/test-cases/page-outputs/ownership.test.js @@ -3,7 +3,7 @@ import assert from 'node:assert/strict' import { mkdir, readFile, rm, stat, symlink, writeFile } from 'node:fs/promises' import { join } from 'node:path' import { hook, setup, settle } from './helpers.js' -import { startWatch } from '../watch/helpers.js' +import { startWatch } from '../../lib/watch/test-helpers.js' test('data-invalidated pages replace ownership using actual reports', { timeout: 15_000 }, async t => { const { site, src, dest, read, logs } = await setup(t, { diff --git a/test-cases/page-outputs/watch.test.js b/test-cases/page-outputs/watch.test.js index a789bf0a..f2137a48 100644 --- a/test-cases/page-outputs/watch.test.js +++ b/test-cases/page-outputs/watch.test.js @@ -3,7 +3,7 @@ import assert from 'node:assert/strict' import { rename, rm, stat, writeFile } from 'node:fs/promises' import { join } from 'node:path' import { hook, setup, settle, writeFiles } from './helpers.js' -import { startWatch } from '../watch/helpers.js' +import { startWatch } from '../../lib/watch/test-helpers.js' const rawLayout = `export const vars = { dataDeps: ['navigation'] } export default ({ children, data }) => data.navigation + children diff --git a/test-cases/watch/index.test.js b/test-cases/watch/index.test.js index 8d94b544..b4e47276 100644 --- a/test-cases/watch/index.test.js +++ b/test-cases/watch/index.test.js @@ -8,7 +8,7 @@ import assert from 'node:assert' import { DomStack } from '../../index.js' import { cp, rm, writeFile, readFile, unlink, mkdtemp, stat, readdir, mkdir } from 'fs/promises' import * as path from 'path' -import { editAndWait, startWatch, waitForRebuild } from './helpers.js' +import { editAndWait, startWatch, waitForRebuild } from '../../lib/watch/test-helpers.js' const fixtureDir = path.join(import.meta.dirname, '../general-features/src') From c2bec1f9969da2fe738e5508ee5421b39bbbcdc4 Mon Sep 17 00:00:00 2001 From: Bret Comnes Date: Wed, 16 Sep 2026 20:46:42 -0700 Subject: [PATCH 17/20] Separate example-site acceptance tests from subsystem regressions --- .gitignore | 1 - declaration.tsconfig.json | 7 + docs/api/README.md | 2 +- .../index.test.js => index.test.js | 4 +- lib/build-esbuild/settings.test.js | 145 +++ lib/build-pages/generated-pages/build.test.js | 258 ++++++ .../generated-pages/streaming-test-helpers.js | 63 ++ .../generated-pages/streaming.test.js | 130 +-- .../generated-pages/test-helpers.js | 73 ++ .../layouts/nested-test-helpers.js | 94 ++ .../layouts/subscriptions-build.test.js | 17 + lib/build-pages/outputs/build.test.js | 318 +++++++ .../build-pages/outputs/test-helpers.js | 4 +- .../worker/generated-pages.test.js | 135 +++ lib/build-pages/worker/page-outputs.test.js | 40 + .../cli/tests}/commands.test.js | 4 +- .../cli/tests}/eject.test.js | 2 +- .../cli/tests}/index.test.js | 2 +- .../cli/tests}/logging.test.js | 2 +- lib/domstack-manifest/build.test.js | 326 +++++++ lib/watch/generated-page-ownership.test.js | 68 ++ lib/watch/generated-pages.test.js | 211 +++++ .../fixtures/data.json.template.js | 0 .../fixtures/global.data.js | 0 .../fixtures/producer-leaf.js | 0 .../fixtures/producer-middle.js | 0 .../incremental-global-data-tests}/helpers.js | 4 +- .../index.test.js | 0 .../watch/lifecycle-tests}/index.test.js | 2 +- .../watch/lifecycle-tests}/logging.test.js | 2 +- lib/watch/nested-layouts.test.js | 223 +++++ .../watch/output-cache.test.js | 4 +- .../watch/output-ownership.test.js | 4 +- .../watch/page-outputs.test.js | 4 +- .../watch/rebuilds.test.js | 4 +- package.json | 5 +- .../index.test.js => test-build.test.js | 4 +- test-cases/README.md | 25 + test-cases/general-features/index.test.js | 452 +-------- test-cases/generated-pages/index.test.js | 856 ++---------------- test-cases/generated-pages/redirects.test.js | 55 ++ test-cases/nested-layouts/index.test.js | 377 +------- .../nested-layouts/src/archive.pages.js | 1 + .../src/article.layout.client.js | 1 + .../nested-layouts/src/article.layout.css | 1 + .../nested-layouts/src/article.layout.js | 5 + .../nested-layouts/src/global.client.js | 1 + test-cases/nested-layouts/src/global.css | 1 + test-cases/nested-layouts/src/global.vars.js | 1 + test-cases/nested-layouts/src/label.js | 1 + .../nested-layouts/src/markup/page.html | 1 + .../nested-layouts/src/markup/page.vars.js | 1 + test-cases/nested-layouts/src/other-label.js | 1 + test-cases/nested-layouts/src/other.layout.js | 4 + test-cases/nested-layouts/src/plain/page.html | 1 + .../nested-layouts/src/post.layout.client.js | 1 + test-cases/nested-layouts/src/post.layout.css | 1 + test-cases/nested-layouts/src/post.layout.js | 4 + .../nested-layouts/src/root.layout.client.js | 1 + test-cases/nested-layouts/src/root.layout.css | 1 + test-cases/nested-layouts/src/root.layout.js | 10 + .../nested-layouts/src/source/client.js | 1 + test-cases/nested-layouts/src/source/page.md | 5 + .../nested-layouts/src/source/page.vars.js | 1 + .../nested-layouts/src/source/style.css | 1 + test-cases/nested-layouts/src/typed/page.ts | 1 + test-cases/page-outputs/index.test.js | 367 +------- test-cases/page-outputs/src/article/page.md | 6 + .../page-outputs/src/article/page.vars.js | 9 + test-cases/page-outputs/src/global.data.js | 1 + test-cases/page-outputs/src/root.layout.js | 12 + .../test-build}/copyfolder/copied.txt | 0 .../test-build}/src/README.md | 0 tsconfig.json | 3 + .../data-deps-type-checks.ts | 0 .../generated-layout-types.test.ts | 0 .../type-exports => type-tests}/index.test.ts | 6 +- .../layout-registry.test.ts | 0 .../registry-page-outputs.test.ts | 0 .../registry-required-vars.test.ts | 0 .../registry-subscriptions.test.ts | 0 81 files changed, 2262 insertions(+), 2116 deletions(-) rename test-cases/constructor-copy-paths/index.test.js => index.test.js (96%) create mode 100644 lib/build-esbuild/settings.test.js create mode 100644 lib/build-pages/generated-pages/build.test.js create mode 100644 lib/build-pages/generated-pages/streaming-test-helpers.js rename {test-cases => lib/build-pages}/generated-pages/streaming.test.js (63%) create mode 100644 lib/build-pages/generated-pages/test-helpers.js create mode 100644 lib/build-pages/layouts/nested-test-helpers.js create mode 100644 lib/build-pages/layouts/subscriptions-build.test.js create mode 100644 lib/build-pages/outputs/build.test.js rename test-cases/page-outputs/helpers.js => lib/build-pages/outputs/test-helpers.js (97%) create mode 100644 lib/build-pages/worker/generated-pages.test.js create mode 100644 lib/build-pages/worker/page-outputs.test.js rename {test-cases/cli-errors => lib/cli/tests}/commands.test.js (99%) rename {test-cases/cli-errors => lib/cli/tests}/eject.test.js (98%) rename {test-cases/cli-errors => lib/cli/tests}/index.test.js (97%) rename {test-cases/cli-errors => lib/cli/tests}/logging.test.js (92%) create mode 100644 lib/domstack-manifest/build.test.js create mode 100644 lib/watch/generated-page-ownership.test.js create mode 100644 lib/watch/generated-pages.test.js rename {test-cases/incremental-global-data => lib/watch/incremental-global-data-tests}/fixtures/data.json.template.js (100%) rename {test-cases/incremental-global-data => lib/watch/incremental-global-data-tests}/fixtures/global.data.js (100%) rename {test-cases/incremental-global-data => lib/watch/incremental-global-data-tests}/fixtures/producer-leaf.js (100%) rename {test-cases/incremental-global-data => lib/watch/incremental-global-data-tests}/fixtures/producer-middle.js (100%) rename {test-cases/incremental-global-data => lib/watch/incremental-global-data-tests}/helpers.js (98%) rename {test-cases/incremental-global-data => lib/watch/incremental-global-data-tests}/index.test.js (100%) rename {test-cases/watch-lifecycle => lib/watch/lifecycle-tests}/index.test.js (99%) rename {test-cases/watch-lifecycle => lib/watch/lifecycle-tests}/logging.test.js (98%) create mode 100644 lib/watch/nested-layouts.test.js rename test-cases/page-outputs/cache.test.js => lib/watch/output-cache.test.js (98%) rename test-cases/page-outputs/ownership.test.js => lib/watch/output-ownership.test.js (98%) rename test-cases/page-outputs/watch.test.js => lib/watch/page-outputs.test.js (98%) rename test-cases/watch/index.test.js => lib/watch/rebuilds.test.js (99%) rename test-cases/test-build-helper/index.test.js => test-build.test.js (89%) create mode 100644 test-cases/README.md create mode 100644 test-cases/generated-pages/redirects.test.js create mode 100644 test-cases/nested-layouts/src/archive.pages.js create mode 100644 test-cases/nested-layouts/src/article.layout.client.js create mode 100644 test-cases/nested-layouts/src/article.layout.css create mode 100644 test-cases/nested-layouts/src/article.layout.js create mode 100644 test-cases/nested-layouts/src/global.client.js create mode 100644 test-cases/nested-layouts/src/global.css create mode 100644 test-cases/nested-layouts/src/global.vars.js create mode 100644 test-cases/nested-layouts/src/label.js create mode 100644 test-cases/nested-layouts/src/markup/page.html create mode 100644 test-cases/nested-layouts/src/markup/page.vars.js create mode 100644 test-cases/nested-layouts/src/other-label.js create mode 100644 test-cases/nested-layouts/src/other.layout.js create mode 100644 test-cases/nested-layouts/src/plain/page.html create mode 100644 test-cases/nested-layouts/src/post.layout.client.js create mode 100644 test-cases/nested-layouts/src/post.layout.css create mode 100644 test-cases/nested-layouts/src/post.layout.js create mode 100644 test-cases/nested-layouts/src/root.layout.client.js create mode 100644 test-cases/nested-layouts/src/root.layout.css create mode 100644 test-cases/nested-layouts/src/root.layout.js create mode 100644 test-cases/nested-layouts/src/source/client.js create mode 100644 test-cases/nested-layouts/src/source/page.md create mode 100644 test-cases/nested-layouts/src/source/page.vars.js create mode 100644 test-cases/nested-layouts/src/source/style.css create mode 100644 test-cases/nested-layouts/src/typed/page.ts create mode 100644 test-cases/page-outputs/src/article/page.md create mode 100644 test-cases/page-outputs/src/article/page.vars.js create mode 100644 test-cases/page-outputs/src/global.data.js create mode 100644 test-cases/page-outputs/src/root.layout.js rename {test-cases/test-build-helper => test-fixtures/test-build}/copyfolder/copied.txt (100%) rename {test-cases/test-build-helper => test-fixtures/test-build}/src/README.md (100%) rename {test-cases/type-exports => type-tests}/data-deps-type-checks.ts (100%) rename {test-cases/type-exports => type-tests}/generated-layout-types.test.ts (100%) rename {test-cases/type-exports => type-tests}/index.test.ts (98%) rename {test-cases/type-exports => type-tests}/layout-registry.test.ts (100%) rename {test-cases/type-exports => type-tests}/registry-page-outputs.test.ts (100%) rename {test-cases/type-exports => type-tests}/registry-required-vars.test.ts (100%) rename {test-cases/type-exports => type-tests}/registry-subscriptions.test.ts (100%) diff --git a/.gitignore b/.gitignore index c0178960..35cf472c 100644 --- a/.gitignore +++ b/.gitignore @@ -23,4 +23,3 @@ test-results *.d.ts.map !types/**/*.d.ts !types/**/*.d.ts.map -test-cases/generated-pages/.streaming diff --git a/declaration.tsconfig.json b/declaration.tsconfig.json index 16032129..fbdb1d89 100644 --- a/declaration.tsconfig.json +++ b/declaration.tsconfig.json @@ -8,6 +8,13 @@ }, "exclude": [ "**/*.test.js", + "**/*.test.ts", + "**/*test-helpers.js", + "**/fixtures/**/*", + "lib/cli/tests/**/*", + "lib/watch/*-tests/**/*", + "type-tests/**/*", + "test-fixtures/**/*", "test-cases/**/*", "site/**/*", ] diff --git a/docs/api/README.md b/docs/api/README.md index fae661a4..185f8bbd 100644 --- a/docs/api/README.md +++ b/docs/api/README.md @@ -73,6 +73,6 @@ Options are passed through to `DomStack`, including `copy` paths. See these repository tests for complete usage: -- [`test-build-helper/index.test.js`](https://github.com/bcomnes/domstack/blob/master/test-cases/test-build-helper/index.test.js) tests temporary output, `readOutput()`, copied directories, and cleanup. +- [`test-build.test.js`](https://github.com/bcomnes/domstack/blob/master/test-build.test.js) tests temporary output, `readOutput()`, copied directories, and cleanup. - [`default-layout/index.test.js`](https://github.com/bcomnes/domstack/blob/master/test-cases/default-layout/index.test.js) uses `testBuild()` for a focused output assertion. - [`generated-pages/index.test.js`](https://github.com/bcomnes/domstack/blob/master/test-cases/generated-pages/index.test.js) uses it with generated pages, global data, and templates. diff --git a/test-cases/constructor-copy-paths/index.test.js b/index.test.js similarity index 96% rename from test-cases/constructor-copy-paths/index.test.js rename to index.test.js index 61701c7e..5703b221 100644 --- a/test-cases/constructor-copy-paths/index.test.js +++ b/index.test.js @@ -1,9 +1,9 @@ -/** @import { DomStackOpts } from '../../lib/builder.js' */ +/** @import { DomStackOpts } from './lib/builder.js' */ import { test } from 'node:test' import assert from 'node:assert' import { isAbsolute, resolve, join } from 'node:path' import { tmpdir } from 'node:os' -import { DomStack } from '../../index.js' +import { DomStack } from './index.js' const tmpSrc = join(tmpdir(), 'domstack-test-src') const tmpDest = join(tmpdir(), 'domstack-test-dest') diff --git a/lib/build-esbuild/settings.test.js b/lib/build-esbuild/settings.test.js new file mode 100644 index 00000000..afb31283 --- /dev/null +++ b/lib/build-esbuild/settings.test.js @@ -0,0 +1,145 @@ +/** @import { DomstackManifestEntry } from '#types' */ +import { test } from 'node:test' +import assert from 'node:assert/strict' +import { DomStack, testBuild } from '../../index.js' +import * as path from 'node:path' +import { mkdtemp, readFile, rm, stat, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { allFiles } from 'async-folder-walker' +const __dirname = path.resolve(import.meta.dirname, '../../test-cases/general-features') +const src = path.join(__dirname, 'src') + +test('metafile false skips esbuild metadata without breaking domstack manifest', async (t) => { + const noMetaBuild = await testBuild(src, { + copy: [path.join(__dirname, './copyfolder')], + metafile: false, + }) + t.after(async () => { + await noMetaBuild.cleanup() + }) + + const noMetaDest = noMetaBuild.dest + const noMetaResults = noMetaBuild.results + const noMetaEntries = /** @type {DomstackManifestEntry[]} */ (noMetaResults.domstackManifest?.entries ?? []) + + assert.ok(noMetaResults.domstackManifest, 'build returned a domstack manifest with metafile disabled') + assert.ok( + noMetaEntries.some(entry => entry.kind === 'script'), + 'domstack manifest still includes esbuild script outputs' + ) + assert.ok( + noMetaEntries.some(entry => entry.kind === 'sourcemap'), + 'domstack manifest still includes esbuild sourcemap outputs' + ) + assert.ok( + !noMetaEntries.some(entry => entry.kind === 'metadata' && entry.url === '/domstack-esbuild-meta.json'), + 'domstack manifest does not include skipped esbuild metafile' + ) + await assert.rejects( + () => stat(path.join(noMetaDest, 'domstack-esbuild-meta.json')), + 'esbuild metafile was not written' + ) +}) + +test('esbuild settings cannot drop reserved DOMSTACK defines', async (t) => { + const defineSrc = await mkdtemp(path.join(tmpdir(), 'domstack-esbuild-defines-')) + t.after(async () => { + await rm(defineSrc, { recursive: true, force: true }) + }) + + await writeFile(path.join(defineSrc, 'page.js'), 'export default () => "

DOMSTACK defines

"\n') + await writeFile(path.join(defineSrc, 'global.client.js'), ` +console.log( + process.env.DOMSTACK_MANIFEST_URL, + process.env.DOMSTACK_SERVICE_WORKER_URL, + process.env.CUSTOM_DEFINE, + process.env.ESBUILD_SETTINGS_CALL +) +`) + await writeFile(path.join(defineSrc, 'service-worker.js'), ` +console.log( + process.env.DOMSTACK_MANIFEST_URL, + process.env.DOMSTACK_SERVICE_WORKER_SCOPE, + process.env.CUSTOM_DEFINE, + process.env.ESBUILD_SETTINGS_CALL +) +`) + await writeFile(path.join(defineSrc, 'esbuild.settings.js'), ` +let invocationCount = 0 + +export default function esbuildSettings (opts) { + invocationCount += 1 + return { + ...opts, + define: { + 'process.env.CUSTOM_DEFINE': JSON.stringify('from-settings'), + 'process.env.ESBUILD_SETTINGS_CALL': JSON.stringify(\`call-\${invocationCount}\`), + }, + } +} +`) + + const defineBuild = await testBuild(defineSrc) + t.after(async () => { + await defineBuild.cleanup() + }) + + const defineFiles = await allFiles(defineBuild.dest, { shaper: fwData => fwData }) + const globalClientFile = defineFiles.find(file => file.relname.match(/global\.client-.+\.js$/)) + assert.ok(globalClientFile, 'global client bundle was written') + + const globalClientContent = await readFile(path.join(defineBuild.dest, globalClientFile.relname), 'utf8') + const defineServiceWorkerContent = await readFile(path.join(defineBuild.dest, 'service-worker.js'), 'utf8') + + assert.ok(globalClientContent.includes('/domstack-manifest.json'), 'global client keeps domstack manifest URL define') + assert.ok(globalClientContent.includes('/service-worker.js'), 'global client keeps service worker URL define') + assert.ok(globalClientContent.includes('from-settings'), 'global client keeps user esbuild define') + assert.ok(!globalClientContent.includes('process.env.DOMSTACK_'), 'global client has no unreplaced DOMSTACK defines') + assert.ok(defineServiceWorkerContent.includes('/domstack-manifest.json'), 'service worker keeps domstack manifest URL define') + assert.match(defineServiceWorkerContent, /["']\/["']/, 'service worker keeps service worker scope define') + assert.ok(defineServiceWorkerContent.includes('from-settings'), 'service worker keeps user esbuild define') + assert.ok(globalClientContent.includes('call-1'), 'browser build uses the first resolved settings result') + assert.ok(defineServiceWorkerContent.includes('call-1'), 'service worker reuses the resolved browser settings') + assert.ok(!defineServiceWorkerContent.includes('call-2'), 'service worker does not invoke esbuild settings again') + assert.ok(!defineServiceWorkerContent.includes('process.env.DOMSTACK_'), 'service worker has no unreplaced DOMSTACK defines') +}) + +test('esbuild settings cannot override reserved DOMSTACK defines', async (t) => { + const conflictSrc = await mkdtemp(path.join(tmpdir(), 'domstack-esbuild-define-conflict-')) + const conflictDest = await mkdtemp(path.join(tmpdir(), 'domstack-esbuild-define-conflict-public-')) + t.after(async () => { + await rm(conflictSrc, { recursive: true, force: true }) + await rm(conflictDest, { recursive: true, force: true }) + }) + + await writeFile(path.join(conflictSrc, 'page.js'), 'export default () => "

DOMSTACK define conflict

"\n') + await writeFile(path.join(conflictSrc, 'global.client.js'), 'console.log(process.env.DOMSTACK_MANIFEST_URL)\n') + await writeFile(path.join(conflictSrc, 'esbuild.settings.js'), ` +export default function esbuildSettings (opts) { + return { + ...opts, + define: { + ...opts.define, + 'process.env.DOMSTACK_MANIFEST_URL': JSON.stringify('/not-domstack-owned.json'), + }, + } +} +`) + + await assert.rejects( + () => new DomStack(conflictSrc, conflictDest).build(), + error => { + if (!(error instanceof Error)) return false + const buildError = /** @type {Error & { errors?: Array }} */ (error) + return error.message.includes('Prebuild finished but there were errors') && + buildError.errors?.some(err => { + const cause = err.cause + return err.message.includes('Error building JS+CSS with esbuild') && + cause instanceof Error && + cause.message.includes('process.env.DOMSTACK_MANIFEST_URL') && + cause.message.includes('reserved by domstack') + }) === true + }, + 'reserved DOMSTACK define conflicts fail clearly' + ) +}) diff --git a/lib/build-pages/generated-pages/build.test.js b/lib/build-pages/generated-pages/build.test.js new file mode 100644 index 00000000..ba3a5db7 --- /dev/null +++ b/lib/build-pages/generated-pages/build.test.js @@ -0,0 +1,258 @@ +import { test } from 'node:test' +import assert from 'node:assert/strict' +import { DomStack } from '../../../index.js' +import { readFile } from 'node:fs/promises' +import { join } from 'node:path' +import { withTempFixture, minimalRootLayout, minimalGlobalVars, firstGeneratedPagesError } from './test-helpers.js' + +test('supports static object, static array, and async function exports', async () => { + await withTempFixture({ + 'root.layout.js': minimalRootLayout, + 'global.vars.js': minimalGlobalVars, + 'single.pages.js': `export default { + outputName: 'single/index.html', + children: '

single static page

', +} +`, + 'multiple.pages.js': `export default [ + { outputName: 'multiple/one.html', children: '

first static page

' }, + { outputName: 'multiple/two.html', children: '

second static page

' }, +] +`, + 'async.pages.js': `export default async function () { + return { outputName: 'async/index.html', children: '

async function page

' } +} +`, + }, async ({ src, dest }) => { + const domstack = new DomStack(src, dest) + await domstack.build() + + assert.match(await readFile(join(dest, 'single/index.html'), 'utf8'), /single static page/) + assert.match(await readFile(join(dest, 'multiple/one.html'), 'utf8'), /first static page/) + assert.match(await readFile(join(dest, 'multiple/two.html'), 'utf8'), /second static page/) + assert.match(await readFile(join(dest, 'async/index.html'), 'utf8'), /async function page/) + }) +}) + +test('builds generated drafts when buildDrafts is enabled', async () => { + await withTempFixture({ + 'root.layout.js': minimalRootLayout, + 'global.vars.js': minimalGlobalVars, + 'draft.pages.js': `export default { + outputName: 'draft/index.html', + draft: true, + children: '

generated draft

', +} +`, + }, async ({ src, dest }) => { + const domstack = new DomStack(src, dest, { buildDrafts: true }) + await domstack.build() + + assert.match(await readFile(join(dest, 'draft/index.html'), 'utf8'), /generated draft/) + }) +}) + +test('includes generated pages in the domstack manifest as page entries', async () => { + await withTempFixture({ + 'root.layout.js': minimalRootLayout, + 'global.vars.js': minimalGlobalVars, + 'archive.pages.js': `export default { + outputName: 'archive/index.html', + vars: { + layout: 'root', + title: 'Archive', + archiveYear: 2024, + manifestRole: 'generated-index', + }, + children: '

Generated archive

', +} +`, + }, async ({ src, dest }) => { + const domstack = new DomStack(src, dest, { + domstackManifest: { + manifestVars: ['archiveYear'], + }, + }) + const results = await domstack.build() + const entry = results.domstackManifest?.entries.find(entry => entry.outputRelname === 'archive/index.html') + const outputRecord = results.pageBuildResults?.outputs.find(output => output.outputRelname === 'archive/index.html') + + assert.ok(entry, 'generated page is present in the domstack manifest') + assert.equal(outputRecord?.pageVars?.['archiveYear'], 2024, 'copyable page vars are returned from the worker') + assert.equal(entry.kind, 'page') + assert.equal(entry.url, '/archive/') + assert.equal(entry.sourceRelname, 'archive.pages.js#0') + assert.equal(entry.pagePath, 'archive') + assert.equal(entry.pageUrl, '/archive/') + assert.deepEqual(entry.page, { + path: 'archive', + url: '/archive/', + }) + assert.equal(entry.role, 'generated-index', 'generated page vars can override the manifest role') + assert.deepEqual(entry.manifestVars, { + archiveYear: 2024, + }, 'selected generated page vars are exposed in the manifest') + assert.match(entry.revision ?? '', /^[a-f0-9]{64}$/, 'generated page content is revisioned') + }) +}) + +test('supports function manifest transforms with generated vars', async () => { + await withTempFixture({ + 'root.layout.js': minimalRootLayout, + 'global.vars.js': minimalGlobalVars, + 'archive.pages.js': `export default { + outputName: 'archive/index.html', + vars: { + title: 'Archive', + archive: { year: 2024 }, + }, + children ({ vars }) { + vars.archive.year = 2025 + return '

Generated archive

' + }, +} +`, + }, async ({ src, dest }) => { + const domstack = new DomStack(src, dest, { + domstackManifest: { + manifestVars: ({ vars }) => { + const archive = /** @type {{ year: number } | undefined} */ (vars['archive']) + return archive ? { archiveLabel: String(archive.year) } : {} + }, + }, + }) + const results = await domstack.build() + const entry = results.domstackManifest?.entries.find(entry => entry.outputRelname === 'archive/index.html') + const outputRecord = results.pageBuildResults?.outputs.find(output => output.outputRelname === 'archive/index.html') + + assert.deepEqual(entry?.manifestVars, { archiveLabel: '2025' }) + assert.deepEqual(outputRecord?.pageVars?.['archive'], { year: 2025 }, 'function transforms receive complete post-render page vars') + }) +}) + +test('throws a conflict error for generated pages that collide with concrete pages', async () => { + await withTempFixture({ + 'root.layout.js': minimalRootLayout, + 'global.vars.js': minimalGlobalVars, + 'README.md': '# Concrete root page\n', + 'conflict.pages.js': `export default function () { + return { outputName: 'index.html', vars: { title: 'Generated root' }, children: 'generated' } +} +`, + }, async ({ src, dest }) => { + const domstack = new DomStack(src, dest) + await assert.rejects( + () => domstack.build(), + error => { + const generatedError = firstGeneratedPagesError(error) + + assert.match(generatedError.message, /Output path conflict/) + assert.match(generatedError.message, /pages file: "conflict\.pages\.js"/) + assert.equal(generatedError.code, 'DOM_STACK_ERROR_OUTPUT_CONFLICT') + assert.deepEqual(generatedError.conflict, { + outputPath: 'index.html', + a: { type: 'page', path: 'README.md' }, + b: { type: 'page', path: 'conflict.pages.js#0' }, + }) + assert.equal(generatedError.pagesFile?.pagesFile.relname, 'conflict.pages.js') + return true + } + ) + }) +}) + +test('throws a conflict error with both generated page sources', async () => { + await withTempFixture({ + 'root.layout.js': minimalRootLayout, + 'global.vars.js': minimalGlobalVars, + 'first.pages.js': "export default { outputName: 'shared/index.html' }\n", + 'second.pages.js': "export default { outputName: 'shared/index.html' }\n", + }, async ({ src, dest }) => { + const domstack = new DomStack(src, dest) + await assert.rejects( + () => domstack.build(), + error => { + const generatedError = firstGeneratedPagesError(error) + const conflictingSources = [ + generatedError.conflict?.a.path, + generatedError.conflict?.b.path, + ].sort() + + assert.equal(generatedError.code, 'DOM_STACK_ERROR_OUTPUT_CONFLICT') + assert.equal(generatedError.conflict?.outputPath, 'shared/index.html') + assert.deepEqual(conflictingSources, ['first.pages.js#0', 'second.pages.js#0']) + assert.equal(`${generatedError.pagesFile?.pagesFile.relname}#0`, generatedError.conflict?.b.path) + return true + } + ) + }) +}) + +test('rejects invalid definitions returned in arrays', async () => { + await withTempFixture({ + 'root.layout.js': minimalRootLayout, + 'global.vars.js': minimalGlobalVars, + 'invalid.pages.js': 'export default [{ outputName: "valid/index.html", children: "Published before validation failure" }, 42]\n', + }, async ({ src, dest }) => { + const domstack = new DomStack(src, dest) + await assert.rejects( + () => domstack.build(), + error => { + const generatedError = firstGeneratedPagesError(error) + + assert.match(generatedError.message, /Generated page definition must be an object/) + assert.match(generatedError.message, /pages file: "invalid\.pages\.js"/) + assert.equal(generatedError.name, 'TypeError') + assert.equal(generatedError.pagesFile?.pagesFile.relname, 'invalid.pages.js') + return true + } + ) + assert.match(await readFile(join(dest, 'valid/index.html'), 'utf8'), /Published before validation failure/) + }) +}) + +test('throws a clear error for invalid generated page paths', async () => { + await withTempFixture({ + 'root.layout.js': minimalRootLayout, + 'global.vars.js': minimalGlobalVars, + 'invalid.pages.js': `export default function () { + return { outputName: '../outside/index.html', vars: { title: 'Invalid' }, children: 'invalid' } +} +`, + }, async ({ src, dest }) => { + const domstack = new DomStack(src, dest) + await assert.rejects( + () => domstack.build(), + error => { + const generatedError = firstGeneratedPagesError(error) + + assert.match(generatedError.message, /must not contain "\.\." segments/) + assert.match(generatedError.message, /pages file: "invalid\.pages\.js"/) + assert.equal(generatedError.pagesFile?.pagesFile.relname, 'invalid.pages.js') + return true + } + ) + }) +}) + +test('rejects generated output names that do not name a file', async () => { + for (const outputName of ['.', './', 'nested/']) { + await withTempFixture({ + 'root.layout.js': minimalRootLayout, + 'global.vars.js': minimalGlobalVars, + 'invalid.pages.js': `export default { outputName: ${JSON.stringify(outputName)}, children: 'invalid' }\n`, + }, async ({ src, dest }) => { + const domstack = new DomStack(src, dest) + await assert.rejects( + () => domstack.build(), + error => { + const generatedError = firstGeneratedPagesError(error) + + assert.match(generatedError.message, /must not be empty|must name a file/) + assert.match(generatedError.message, /pages file: "invalid\.pages\.js"/) + return true + } + ) + }) + } +}) diff --git a/lib/build-pages/generated-pages/streaming-test-helpers.js b/lib/build-pages/generated-pages/streaming-test-helpers.js new file mode 100644 index 00000000..f06ee400 --- /dev/null +++ b/lib/build-pages/generated-pages/streaming-test-helpers.js @@ -0,0 +1,63 @@ +/** + * @import { TestContext } from 'node:test' + * @import { Results } from '../../builder.js' + */ +import assert from 'node:assert/strict' +import { mkdtemp, readFile, rm } from 'node:fs/promises' +import { join } from 'node:path' +import pino from 'pino' +import { DomStack } from '../../../index.js' +import { builder } from '../../builder.js' +import { writeFiles } from '../outputs/test-helpers.js' + +/** @param {TestContext} t @param {Record} files @param {boolean} [buildDrafts] */ +export async function setup (t, files, buildDrafts = false) { + const root = await mkdtemp(join(import.meta.dirname, '.tmp-streaming-')) + const src = join(root, 'src') + const dest = join(root, 'custom-output') + const logs = /** @type {string[]} */ ([]) + const options = { static: true, domstackManifest: false, buildDrafts, logger: pino({ level: 'debug' }, { write: line => logs.push(line) }) } + const site = new DomStack(src, dest, options) + t.after(async () => { + if (site.watching) await site.stopWatching() + await rm(root, { recursive: true, force: true }) + }) + await writeFiles(src, { + 'global.vars.js': `export default { layout: 'root', title: 'Global', testRoot: ${JSON.stringify(root)}, testDest: ${JSON.stringify(dest)} }`, + 'root.layout.js': "export default ({ children }) => '
' + children + '
'", + ...files, + }) + return { + src, + dest, + root, + site, + logs, + build: () => builder(src, dest, options), + /** @param {string} name */ + read: name => readFile(join(dest, name), 'utf8'), + } +} + +/** + * @param {Results['pageBuildResults']} result + * @param {string} src + * @param {string} dest + * @param {string} outputRelname + * @param {string} owner + * @param {number} index + */ +export function assertReported (result, src, dest, outputRelname, owner, index) { + assert.ok(result, 'page build results survive worker transport') + const output = result.outputs.find(output => output.outputRelname === outputRelname) + assert.ok(output, `${outputRelname} retains output metadata`) + assert.equal(output.kind, 'page') + assert.equal(output.filepath, join(dest, outputRelname)) + assert.equal(output.sourceRelname, `${owner}#${index}`) + const report = result.report.pages.find(page => page.outputs.some(output => output.outputRelname === outputRelname)) + assert.ok(report, `${outputRelname} retains an ownership report`) + assert.equal(report.pagesFilePath, join(src, owner)) + assert.equal(report.sourcePageFilePath, undefined) + assert.equal(report.pageFilePath, join(dest, outputRelname)) + assert.deepEqual(report.outputs.find(record => record.outputRelname === outputRelname), output) +} diff --git a/test-cases/generated-pages/streaming.test.js b/lib/build-pages/generated-pages/streaming.test.js similarity index 63% rename from test-cases/generated-pages/streaming.test.js rename to lib/build-pages/generated-pages/streaming.test.js index fa291894..9f7430b0 100644 --- a/test-cases/generated-pages/streaming.test.js +++ b/lib/build-pages/generated-pages/streaming.test.js @@ -1,70 +1,11 @@ -/** - * @import { TestContext } from 'node:test' - * @import { Results } from '../../lib/builder.js' - */ +/** @import { Results } from '../../builder.js' */ import { test } from 'node:test' import assert from 'node:assert/strict' -import { mkdtemp, readFile, rm, stat, writeFile } from 'node:fs/promises' +import { stat, readFile } from 'node:fs/promises' import { join } from 'node:path' -import pino from 'pino' -import { DomStack } from '../../index.js' -import { builder } from '../../lib/builder.js' -import { DomStackAggregateError } from '../../lib/helpers/domstack-aggregate-error.js' -import { errorText, settle, writeFiles } from '../page-outputs/helpers.js' -import { startWatch } from '../../lib/watch/test-helpers.js' - -/** @param {TestContext} t @param {Record} files @param {boolean} [buildDrafts] */ -async function setup (t, files, buildDrafts = false) { - // index.test.js sweeps .tmp-* directories; these fixtures must survive parallel test files. - const root = await mkdtemp(join(import.meta.dirname, '.streaming-')) - const src = join(root, 'src') - const dest = join(root, 'custom-output') - const logs = /** @type {string[]} */ ([]) - const options = { static: true, domstackManifest: false, buildDrafts, logger: pino({ level: 'debug' }, { write: line => logs.push(line) }) } - const site = new DomStack(src, dest, options) - t.after(async () => { - if (site.watching) await site.stopWatching() - await rm(root, { recursive: true, force: true }) - }) - await writeFiles(src, { - 'global.vars.js': `export default { layout: 'root', title: 'Global', testRoot: ${JSON.stringify(root)}, testDest: ${JSON.stringify(dest)} }`, - 'root.layout.js': "export default ({ children }) => '
' + children + '
'", - ...files, - }) - return { - src, - dest, - root, - site, - logs, - build: () => builder(src, dest, options), - /** @param {string} name */ - read: name => readFile(join(dest, name), 'utf8'), - } -} - -/** - * @param {Results['pageBuildResults']} result - * @param {string} src - * @param {string} dest - * @param {string} outputRelname - * @param {string} owner - * @param {number} index - */ -function assertReported (result, src, dest, outputRelname, owner, index) { - assert.ok(result, 'page build results survive worker transport') - const output = result.outputs.find(output => output.outputRelname === outputRelname) - assert.ok(output, `${outputRelname} retains output metadata`) - assert.equal(output.kind, 'page') - assert.equal(output.filepath, join(dest, outputRelname)) - assert.equal(output.sourceRelname, `${owner}#${index}`) - const report = result.report.pages.find(page => page.outputs.some(output => output.outputRelname === outputRelname)) - assert.ok(report, `${outputRelname} retains an ownership report`) - assert.equal(report.pagesFilePath, join(src, owner)) - assert.equal(report.sourcePageFilePath, undefined) - assert.equal(report.pageFilePath, join(dest, outputRelname)) - assert.deepEqual(report.outputs.find(record => record.outputRelname === outputRelname), output) -} +import { DomStackAggregateError } from '../../helpers/domstack-aggregate-error.js' +import { errorText, writeFiles } from '../outputs/test-helpers.js' +import { setup, assertReported } from './streaming-test-helpers.js' for (const [form, factoryExport] of Object.entries({ 'generator function': 'export default pages', @@ -261,64 +202,3 @@ test('sibling factories publish unique outputs with independent owner metadata', } } }) - -/** @param {string[]} names @param {string} [failure] */ -function watchFactory (names, failure) { - return `export default async function* () { - ${names.map(name => `yield { outputName: '${name}.html', children: '${name}' }`).join('\n')} - ${failure ? `throw Error('${failure}')` : ''} - }` -} - -for (const change of ['recovery', 'deletion', 'empty result']) { - test(`watch ${change} cleans successful and repeated partial factory ownership`, { timeout: 30_000 }, async t => { - const { site, src, dest, read, logs } = await setup(t, { - 'stream.pages.js': watchFactory(['old', 'stale']), - 'sibling.pages.js': watchFactory(['sibling']), - }) - await startWatch(t, site, src) - const sibling = await read('sibling.html') - for (const name of ['partial', 'second-partial']) { - await settle(site, logs, async () => { - await writeFile(join(src, 'stream.pages.js'), watchFactory([name], `${name} failure`)) - }, `${name} failure`) - assert.equal(await read(`${name}.html`), `
${name}
`) - assert.equal(await read('old.html'), '
old
') - assert.equal(await read('stale.html'), '
stale
') - assert.equal(await read('partial.html'), '
partial
', 'repeated failure keeps earlier partial ownership') - } - await settle(site, logs, async () => { - if (change === 'deletion') await rm(join(src, 'stream.pages.js')) - else await writeFile(join(src, 'stream.pages.js'), change === 'empty result' ? 'export default null' : watchFactory(['recovered'])) - }) - for (const name of ['old', 'stale', 'partial', 'second-partial']) { - await assert.rejects(stat(join(dest, `${name}.html`)), { code: 'ENOENT' }) - } - if (change === 'recovery') assert.equal(await read('recovered.html'), '
recovered
') - assert.equal(await read('sibling.html'), sibling, 'cleanup preserves sibling factory output') - await assert.rejects(stat(join(dest, 'domstack-manifest.json')), { code: 'ENOENT' }) - }) -} - -for (const change of ['recovery', 'deletion', 'empty result']) { - test(`initial failed watch retains partial generated page reports for ${change}`, { timeout: 30_000 }, async t => { - const { site, src, dest, read, logs } = await setup(t, { - 'stream.pages.js': watchFactory(['partial', 'nested/partial'], 'initial stream failure'), - }) - const result = await startWatch(t, site, src) - assert.ok(logs.some(line => JSON.parse(line).msg === 'Build Failed!')) - assert.ok(logs.some(line => line.includes('initial stream failure'))) - for (const [index, name] of ['partial', 'nested/partial'].entries()) { - assert.equal(await read(`${name}.html`), `
${name}
`) - assertReported(result.pageBuildResults, src, dest, `${name}.html`, 'stream.pages.js', index) - } - await settle(site, logs, async () => { - if (change === 'deletion') await rm(join(src, 'stream.pages.js')) - else await writeFile(join(src, 'stream.pages.js'), change === 'empty result' ? 'export default []' : watchFactory(['recovered'])) - }) - for (const name of ['partial', 'nested/partial']) { - await assert.rejects(stat(join(dest, `${name}.html`)), { code: 'ENOENT' }) - } - if (change === 'recovery') assert.equal(await read('recovered.html'), '
recovered
') - }) -} diff --git a/lib/build-pages/generated-pages/test-helpers.js b/lib/build-pages/generated-pages/test-helpers.js new file mode 100644 index 00000000..7e0af518 --- /dev/null +++ b/lib/build-pages/generated-pages/test-helpers.js @@ -0,0 +1,73 @@ +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' +import { dirname, join } from 'node:path' +const __dirname = import.meta.dirname +const fixturePrefix = '.tmp-' + +/** + * @param {string} src + * @param {string} relname + * @param {string} content + */ +export async function writeFixtureFile (src, relname, content) { + const filepath = join(src, relname) + await mkdir(dirname(filepath), { recursive: true }) + await writeFile(filepath, content) +} + +/** + * @param {Record} files + * @param {(paths: { src: string, dest: string }) => Promise} run + */ +export async function withTempFixture (files, run) { + const root = await mkdtemp(join(__dirname, fixturePrefix)) + const src = join(root, 'src') + const dest = join(root, 'dist') + await mkdir(src, { recursive: true }) + + for (const [relname, content] of Object.entries(files)) { + await writeFixtureFile(src, relname, content) + } + + try { + await run({ src, dest }) + } finally { + await rm(root, { recursive: true, force: true }) + } +} + +export const minimalRootLayout = `import { html, raw, render } from 'fragtml' + +export default function rootLayout ({ vars, children }) { + return render(html\`\${vars.title}
\${typeof children === 'string' ? raw(children) : children}
\`) +} +` + +export const minimalGlobalVars = `export default { layout: 'root', title: 'Test' } +` + +export const assetAwareRootLayout = `export default function rootLayout ({ styles = [], scripts = [], children }) { + return '' + + styles.map(href => '').join('') + + scripts.map(src => '').join('') + + '' + children + '' +} +` + +/** + * @param {unknown} error + * @returns {Error & { + * code?: string, + * conflict?: { + * outputPath: string, + * a: { type: string, path: string }, + * b: { type: string, path: string } + * }, + * pagesFile?: { pagesFile: { relname: string } } + * }} + */ +export function firstGeneratedPagesError (error) { + if (!(error instanceof AggregateError)) throw new TypeError('Expected an AggregateError') + const generatedError = error.errors[0] + if (!(generatedError instanceof Error)) throw new TypeError('Expected a generated-pages Error') + return generatedError +} diff --git a/lib/build-pages/layouts/nested-test-helpers.js b/lib/build-pages/layouts/nested-test-helpers.js new file mode 100644 index 00000000..3771c832 --- /dev/null +++ b/lib/build-pages/layouts/nested-test-helpers.js @@ -0,0 +1,94 @@ +/** + * @import { TestContext } from 'node:test' + * @import { Logger } from 'pino' + */ +import { cp, mkdtemp, mkdir, writeFile, readFile, rm } from 'node:fs/promises' +import { readFileSync } from 'node:fs' +import { join } from 'node:path' +import pino from 'pino' +import { DomStack } from '../../../index.js' + +export const rootLayout = readFileSync(new URL('../../../test-cases/nested-layouts/src/root.layout.js', import.meta.url), 'utf8') +export const articleLayout = readFileSync(new URL('../../../test-cases/nested-layouts/src/article.layout.js', import.meta.url), 'utf8') + +/** @param {TestContext} t @param {Logger} [logger] */ +export async function setup (t, logger = pino({ level: 'silent' })) { + const dir = await mkdtemp(join(import.meta.dirname, '.tmp-')) + const src = join(dir, 'src') + const dest = join(dir, 'public') + await mkdir(src) + await cp(new URL('../../../test-cases/nested-layouts/src/', import.meta.url), src, { recursive: true }) + const domstack = new DomStack(src, dest, { logger }) + 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), + } +} + +export const globalData = ` +import assert from 'node:assert/strict' +export default async ({ pages }) => { + const source = pages.find(page => page.pageInfo.path === 'source') + await assert.rejects(source.renderFullPage(), /Global data is not available/) + return { + navigation: 'nav-v1', + recentPosts: 'recent-v1', + footer: 'footer-v1', + pageMessage: 'message-v1', + rendered: await source.renderInnerPage() + } +} +` + +/** @param {TestContext} t */ +export async function setupSubscriptions (t) { + const site = await setup(t) + await site.write('global.data.js', globalData) + await site.write('root.layout.js', ` + import assert from 'node:assert/strict' + export const vars = { dataDeps: ['navigation', 'rendered'] } + export default ({ children, data, vars }) => { + assert.deepEqual(Object.keys(data), ['navigation', 'rendered']) + assert.throws(() => data.recentPosts, /undeclared global data key/) + assert.equal(vars.dataDeps, undefined) + return '
' + data.navigation + data.rendered + children + '
' + } + `) + await site.write('article.layout.js', ` + import assert from 'node:assert/strict' + export const parentLayout = 'root' + export const vars = { dataDeps: ['recentPosts'] } + export default ({ children, data }) => { + assert.deepEqual(Object.keys(data), ['recentPosts']) + assert.throws(() => data.navigation, /undeclared global data key/) + return '
' + data.recentPosts + children + '
' + } + `) + await site.write('post.layout.js', ` + import assert from 'node:assert/strict' + export const parentLayout = 'article' + export const vars = { dataDeps: ['footer'] } + export default ({ children, data }) => { + assert.deepEqual(Object.keys(data), ['footer']) + assert.throws(() => data.pageMessage, /undeclared global data key/) + return '
' + data.footer + children + '
' + } + `) + await site.write('typed/page.ts', ` + import assert from 'node:assert/strict' + export const vars = { layout: 'post', dataDeps: ['pageMessage'] } + export default ({ data }) => { + assert.deepEqual(Object.keys(data), ['pageMessage']) + assert.throws(() => data.footer, /undeclared global data key/) + return '

' + data.pageMessage + '

' + } + `) + return site +} diff --git a/lib/build-pages/layouts/subscriptions-build.test.js b/lib/build-pages/layouts/subscriptions-build.test.js new file mode 100644 index 00000000..56a8b79c --- /dev/null +++ b/lib/build-pages/layouts/subscriptions-build.test.js @@ -0,0 +1,17 @@ +import { test } from 'node:test' +import assert from 'node:assert/strict' +import { setupSubscriptions } from './nested-test-helpers.js' + +test('each nested renderer gets only its own subscriptions; global data can render unsubscribed inner content', async t => { + const { domstack, read } = await setupSubscriptions(t) + const results = await domstack.build() + assert.equal(results.pageBuildResults?.errors.length, 0) + for (const file of ['source/index.html', 'markup/index.html', 'typed/index.html', 'archive.html']) { + const html = await read(file) + assert.match(html, /nav-v1/) + assert.match(html, /recent-v1/) + assert.match(html, /footer-v1/) + assert.match(html, /Content/) + } + assert.match(await read('typed/index.html'), /message-v1/) +}) diff --git a/lib/build-pages/outputs/build.test.js b/lib/build-pages/outputs/build.test.js new file mode 100644 index 00000000..f320ff51 --- /dev/null +++ b/lib/build-pages/outputs/build.test.js @@ -0,0 +1,318 @@ +import { test } from 'node:test' +import assert from 'node:assert/strict' +import { stat, utimes } from 'node:fs/promises' +import { join } from 'node:path' +import { errorText, hook, setup, writeFiles } from './test-helpers.js' + +const rawLayout = `export default ({ children }) => '
' + children + '
' +export const pageOutputs = async ({ page }) => ({ outputName: './source.txt', content: await page.readMarkdownContent() })` + +test('builder renders Markdown and exports the unrendered body from its layout at a custom destination', async t => { + const body = '# Article\n\nKeep **Markdown**, {{ vars.title }}, and [links](./other.md).\n' + const { build, read, dest, src } = await setup(t, { + 'root.layout.js': rawLayout, + 'docs/page.md': '---\ntitle: Resolved title\n---\n' + body, + }) + const result = await build() + assert.match(await read('docs/index.html'), /
\s*

Markdown<\/strong>/) + assert.equal(await read('docs/source.txt'), '\n' + body) + const record = result.pageBuildResults?.outputs.find(output => output.outputRelname === 'docs/source.txt') + assert.ok(record, 'page output is included in the page build report') + assert.equal(record.filepath, join(dest, 'docs/source.txt')) + assert.equal(record.sourceRelname, 'docs/page.md') + const pageReport = result.pageBuildResults?.report.pages.find(page => page.sourcePageFilePath === join(src, 'docs/page.md')) + assert.ok(pageReport) + assert.equal(pageReport.pageFilePath, join(dest, 'docs/index.html')) + assert.equal(pageReport.pagesFilePath, undefined) + assert.equal(pageReport.layoutName, 'root') + assert.deepEqual(pageReport.layoutNames, ['root']) + assert.ok(pageReport.outputs?.some(output => output.filepath === record.filepath)) +}) + +test('nested hooks run outer -> inner -> companion with isolated renderer data and resolved vars', async t => { + const { build, read } = await setup(t, { + 'global.vars.js': "export default { layout: 'inner', title: 'global' }; export const pageOutputs = () => { throw Error('global provider ran') }", + 'global.data.js': "export default { outer: 'O', inner: 'I', selected: 'P', secret: 'hidden' }", + 'root.layout.js': `import assert from 'node:assert/strict' + export const vars = { dataDeps: ['outer'] } + export default ({ children, data }) => data.outer + children + export const pageOutputs = ({ page, vars, data }) => { + assert.equal(vars.title, 'page title') + assert.throws(() => data.selected, /undeclared/) + assert.equal('renderFullPage' in page, false) + assert.equal('data' in page, false) + globalThis[page.pageFile.filepath] = ['outer'] + return { outputName: 'outer.txt', content: data.outer } + }`, + 'inner.layout.js': `import assert from 'node:assert/strict' + import { readFile } from 'node:fs/promises' + import { dirname, join } from 'node:path' + export const parentLayout = 'root' + export const vars = { dataDeps: ['inner'] } + export default ({ children, data }) => data.inner + children + export async function* pageOutputs ({ page, data }) { + assert.throws(() => data.outer, /undeclared/) + assert.equal(await readFile(join(dirname(page.pageFile.filepath), '../../custom-output/docs/outer.txt'), 'utf8'), 'O') + globalThis[page.pageFile.filepath].push('inner') + yield { outputName: './inner.txt', content: data.inner } + }`, + 'docs/page.md': '---\ntitle: page title\n---\n# Body\n', + 'docs/page.vars.js': `import assert from 'node:assert/strict' + import { readFile } from 'node:fs/promises' + import { dirname, join } from 'node:path' + export default { dataDeps: ['selected'] } + export const pageOutputs = async ({ page, vars, data }) => { + assert.throws(() => data.secret, /undeclared/) + assert.throws(() => data.inner, /undeclared/) + assert.equal(Object.isFrozen(page), true) + assert.equal(Object.isFrozen(vars), true) + const outputDir = join(dirname(page.pageFile.filepath), '../../custom-output/docs') + assert.equal(await readFile(join(outputDir, 'outer.txt'), 'utf8'), 'O') + assert.equal(await readFile(join(outputDir, 'inner.txt'), 'utf8'), 'I') + const order = globalThis[page.pageFile.filepath] + delete globalThis[page.pageFile.filepath] + return [ + { outputName: '/metadata.json', content: JSON.stringify({ title: vars.title, selected: data.selected, order: [...order, 'page'] }) }, + { outputName: '../source/article.txt', content: await page.readMarkdownContent() }, + ] + }`, + }) + await build() + assert.deepEqual(JSON.parse(await read('metadata.json')), { title: 'page title', selected: 'P', order: ['outer', 'inner', 'page'] }) + assert.equal(await read('docs/outer.txt'), 'O') + assert.equal(await read('docs/inner.txt'), 'I') + assert.equal(await read('source/article.txt'), '\n# Body\n') + assert.match(await read('docs/index.html'), /^OI\s*

{ + const { build, read } = await setup(t, { + 'global.data.js': "export default { selected: 'subscribed', secret: 'private' }", + [`article/page.${extension}`]: extension === 'html' ? '

{{ vars.title }}

' : 'export default ({ vars, data }) => vars.title + data.selected', + 'article/page.vars.js': `import assert from 'node:assert/strict' + export default { title: 'Companion', dataDeps: ['selected'] } + export async function pageOutputs ({ page, vars, data }) { + await assert.rejects(page.readMarkdownContent()) + assert.throws(() => data.secret, /undeclared/) + return { outputName: 'metadata.json', content: JSON.stringify({ title: vars.title, value: data.selected }) } + }`, + }) + await build() + assert.match(await read('article/index.html'), /Companion/) + assert.deepEqual(JSON.parse(await read('article/metadata.json')), { title: 'Companion', value: 'subscribed' }) + }) +} + +test('JS page modules support promised async iterables, arrays, and empty results', async t => { + const { build, read } = await setup(t, { + 'page.js': `export default () => 'main'; export const pageOutputs = async () => (async function* () { + yield { outputName: 'one.txt', content: 'one' }; yield { outputName: './two.txt', content: 'two' } + })()`, + 'array/page.js': "export default () => 'array'; export const pageOutputs = () => [{ outputName: 'array.txt', content: 'array' }]", + 'empty/page.js': "export default () => 'empty'; export const pageOutputs = () => []", + 'iterator/page.js': "export default () => 'empty iterator'; export async function* pageOutputs () {}", + }) + await build() + for (const name of ['one', 'two']) assert.equal(await read(`${name}.txt`), name) + assert.equal(await read('array/array.txt'), 'array') + assert.equal(await read('empty/index.html'), 'empty') + assert.equal(await read('iterator/index.html'), 'empty iterator') +}) + +test('async generators publish each record before requesting the next at a custom destination', async t => { + const { build, dest, read, mtime } = await setup(t, { + 'page.js': `import assert from 'node:assert/strict' + import { readFile, stat } from 'node:fs/promises' + import { dirname, join } from 'node:path' + export default () => 'main' + export async function* pageOutputs ({ page }) { + const dest = join(dirname(page.pageFile.filepath), '../custom-output') + yield { outputName: 'replaced.txt', content: 'replacement' } + assert.equal(await readFile(join(dest, 'replaced.txt'), 'utf8'), 'replacement') + yield { outputName: 'nested/new.txt', content: 'new sidecar' } + assert.equal(await readFile(join(dest, 'nested/new.txt'), 'utf8'), 'new sidecar') + const unchangedTime = (await stat(join(dest, 'unchanged.txt'))).mtimeMs + yield { outputName: 'unchanged.txt', content: 'same bytes' } + assert.equal(await readFile(join(dest, 'unchanged.txt'), 'utf8'), 'same bytes') + assert.notEqual((await stat(join(dest, 'unchanged.txt'))).mtimeMs, unchangedTime, 'a cold build writes even identical bytes') + }`, + }) + await writeFiles(dest, { 'replaced.txt': 'old sidecar', 'unchanged.txt': 'same bytes' }) + await utimes(join(dest, 'unchanged.txt'), 1, 1) + const unchangedTime = await mtime('unchanged.txt') + const result = await build() + assert.equal(await read('replaced.txt'), 'replacement') + assert.equal(await read('nested/new.txt'), 'new sidecar') + assert.notEqual(await mtime('unchanged.txt'), unchangedTime) + assert.equal(await read('index.html'), 'main') + for (const outputRelname of ['replaced.txt', 'nested/new.txt', 'unchanged.txt']) { + assert.ok(result.pageBuildResults?.outputs.some(output => output.outputRelname === outputRelname), `${outputRelname} is reported, including unchanged content`) + } +}) + +test('JS page outputs take precedence over companion outputs while layouts remain additive', async t => { + const { build, read, src } = await setup(t, { + 'root.layout.js': 'export default ({ children }) => children; ' + hook('layout.txt', 'layout'), + 'page.js': "export default () => 'main'; " + hook('page.txt', 'page'), + 'page.vars.js': "export default {}; export const pageOutputs = () => { throw Error('ignored companion must not run') }", + }) + const result = await build() + const warnings = result.pageBuildResults?.warnings.filter(warning => 'code' in warning && warning.code === 'DOM_STACK_WARNING_DUPLICATE_PAGE_OUTPUTS_PROVIDER') + assert.equal(warnings?.length, 1) + const warning = warnings?.[0] + assert.ok(warning && 'message' in warning) + assert.ok(warning.message.includes(join(src, 'page.js'))) + assert.ok(warning.message.includes(join(src, 'page.vars.js'))) + assert.ok(result.warnings.includes(warning), 'worker warnings propagate to the aggregate build result') + assert.equal(await read('index.html'), 'main') + assert.equal(await read('layout.txt'), 'layout') + assert.equal(await read('page.txt'), 'page') +}) + +for (const scenario of [ + { name: 'own HTML', output: 'index.html', files: {} }, + { name: 'other page HTML', output: 'other/index.html', files: { 'other/page.html': 'Other' } }, + { name: 'template', output: 'shared.txt', files: { 'shared.txt.template.js': "export default () => 'template'" } }, + { name: 'asset', output: 'shared.txt', files: { 'shared.txt': 'asset' } }, + { name: 'bundle', output: 'client.js', files: { 'client.js': 'console.log(1)', 'esbuild.settings.js': "export default opts => ({ ...opts, entryNames: '[dir]/[name]' })" } }, + { name: 'layout hook', output: 'shared.txt', files: { 'root.layout.js': 'export default ({ children }) => children; ' + hook('shared.txt') } }, + { name: 'other page hook', output: 'shared.txt', files: { 'other/page.js': "export default () => 'other'; " + hook('/shared.txt') } }, +]) { + test(`builder warns about a duplicate sidecar destination with ${scenario.name}`, async t => { + const { build } = await setup(t, { + 'page.js': "export default () => 'new main'; " + hook(scenario.output), + ...scenario.files, + }) + const result = await build() + assert.ok(result.warnings.some(warning => { + const message = errorText(warning) + return /duplicate|conflict/i.test(message) && message.includes(scenario.output) + }), `expected a duplicate destination warning for ${scenario.output}: ${errorText(result.warnings)}`) + }) +} + +for (const result of [ + "'bare string'", + "{ outputName: 'bad.txt', content: 42 }", + + "{ outputName: '../escape.txt', content: 'bad' }", + "{ outputName: '/', content: 'bad' }", +]) { + test(`builder rejects invalid page output: ${result}`, async t => { + const { build, dest, read } = await setup(t, { + 'page.js': `export default () => 'new'; export const pageOutputs = () => (${result})`, + }) + await writeFiles(dest, { 'index.html': 'old' }) + await assert.rejects(build()) + assert.equal(await read('index.html'), 'old') + await assert.rejects(stat(join(dest, '../escape.txt')), { code: 'ENOENT' }) + }) +} + +test('iterator failure retains earlier sidecar writes and the previous HTML', async t => { + const { build, dest, read } = await setup(t, { + 'a/page.js': "export default () => 'new sibling'; " + hook('sibling.txt', 'new sibling sidecar'), + 'z/page.js': `export default () => 'new main'; export async function* pageOutputs () { + yield { outputName: 'old.txt', content: 'replacement' } + yield { outputName: 'partial.txt', content: 'published before failure' } + throw Error('iterator exploded') + }`, + }) + const previous = { 'z/index.html': 'old main', 'z/old.txt': 'old sidecar', 'z/stale.txt': 'retain on failure' } + await writeFiles(dest, previous) + await assert.rejects(build(), error => { + assert.match(errorText(error), /iterator exploded/) + return true + }) + assert.equal(await read('z/index.html'), 'old main') + assert.equal(await read('z/old.txt'), 'replacement') + assert.equal(await read('z/partial.txt'), 'published before failure') + assert.equal(await read('z/stale.txt'), 'retain on failure') +}) + +for (const provider of ['layout', 'page']) { + test(`a later ${provider} provider failure retains earlier layout files`, async t => { + const failingHook = 'export const pageOutputs = () => { throw Error(\'later provider exploded\') }' + const { build, dest, read } = await setup(t, { + 'global.vars.js': "export default { layout: 'inner' }", + 'root.layout.js': `export default ({ children }) => children + export async function* pageOutputs () { + yield { outputName: 'old.txt', content: 'replacement' } + yield { outputName: 'partial.txt', content: 'partial' } + }`, + 'inner.layout.js': `export const parentLayout = 'root'; export default ({ children }) => children; + ${provider === 'layout' ? failingHook : 'export const pageOutputs = () => []'}`, + 'page.js': `export default () => 'new main'; + ${provider === 'page' ? failingHook : "export const pageOutputs = () => { throw Error('page provider must not run') }"}`, + }) + await writeFiles(dest, { 'index.html': 'old main', 'old.txt': 'old sidecar' }) + await assert.rejects(build(), error => { + assert.match(errorText(error), /later provider exploded/) + assert.doesNotMatch(errorText(error), /page provider must not run/) + return true + }) + assert.equal(await read('index.html'), 'old main') + assert.equal(await read('old.txt'), 'replacement') + assert.equal(await read('partial.txt'), 'partial') + }) +} + +for (const invalid of [ + { record: "{ outputName: '../escape.txt', content: 'invalid' }", message: /escapes dest/ }, + { record: "{ outputName: 'invalid.txt', content: 42 }", message: /content.*string/i }, +]) { + test(`a later invalid record stops the stream without requesting following yields: ${invalid.record}`, async t => { + const { build, dest, read } = await setup(t, { + 'page.js': `import { writeFile } from 'node:fs/promises' + import { dirname, join } from 'node:path' + export default () => 'new main' + export async function* pageOutputs ({ page }) { + yield { outputName: 'first.txt', content: 'published' } + yield ${invalid.record} + await writeFile(join(dirname(page.pageFile.filepath), '../following-yield-requested'), 'requested') + yield { outputName: 'following.txt', content: 'must not publish' } + }`, + }) + await writeFiles(dest, { 'index.html': 'old main' }) + await assert.rejects(build(), error => { + assert.match(errorText(error), invalid.message) + return true + }) + assert.equal(await read('first.txt'), 'published') + assert.equal(await read('index.html'), 'old main') + for (const name of ['../escape.txt', 'invalid.txt', '../following-yield-requested', 'following.txt']) { + await assert.rejects(stat(join(dest, name)), { code: 'ENOENT' }) + } + }) +} + +test('identical duplicate records from one hook warn rather than reject the build', async t => { + const { build, read } = await setup(t, { + 'page.js': `export default () => 'main'; export const pageOutputs = () => [ + { outputName: 'same.txt', content: 'same' }, + { outputName: './same.txt', content: 'same' }, + ]`, + }) + const result = await build() + assert.equal(await read('index.html'), 'main') + assert.equal(await read('same.txt'), 'same') + assert.ok(result.warnings.some(warning => { + const message = errorText(warning) + return /duplicate|conflict/i.test(message) && message.includes('same.txt') + }), `expected a duplicate destination warning: ${errorText(result.warnings)}`) +}) + +test('render failure leaves the owning page HTML and sidecars unchanged', async t => { + const { build, dest, read } = await setup(t, { + 'page.js': "export default () => { throw Error('render exploded') }; " + hook('old.txt', 'replacement'), + }) + await writeFiles(dest, { 'index.html': 'old main', 'old.txt': 'old sidecar' }) + await assert.rejects(build(), error => { + assert.match(errorText(error), /render exploded/) + return true + }) + assert.equal(await read('index.html'), 'old main') + assert.equal(await read('old.txt'), 'old sidecar') +}) diff --git a/test-cases/page-outputs/helpers.js b/lib/build-pages/outputs/test-helpers.js similarity index 97% rename from test-cases/page-outputs/helpers.js rename to lib/build-pages/outputs/test-helpers.js index 58d9e524..53f59337 100644 --- a/test-cases/page-outputs/helpers.js +++ b/lib/build-pages/outputs/test-helpers.js @@ -5,8 +5,8 @@ import { mkdir, mkdtemp, readFile, rm, stat, writeFile } from 'node:fs/promises' import { dirname, join } from 'node:path' import { inspect } from 'node:util' import pino from 'pino' -import { DomStack } from '../../index.js' -import { builder } from '../../lib/builder.js' +import { DomStack } from '../../../index.js' +import { builder } from '../../builder.js' /** @param {string} root @param {Record} files */ export async function writeFiles (root, files) { diff --git a/lib/build-pages/worker/generated-pages.test.js b/lib/build-pages/worker/generated-pages.test.js new file mode 100644 index 00000000..4233ee85 --- /dev/null +++ b/lib/build-pages/worker/generated-pages.test.js @@ -0,0 +1,135 @@ +import { test } from 'node:test' +import assert from 'node:assert/strict' +import { DomStack } from '../../../index.js' +import { readFile } from 'node:fs/promises' +import { join } from 'node:path' +import { withTempFixture, minimalRootLayout, minimalGlobalVars, firstGeneratedPagesError } from '../generated-pages/test-helpers.js' + +import { DomStackDataError } from '../../helpers/domstack-error.js' + +test('subscription errors preserve their subtype and metadata across the worker boundary', async t => { + const cases = [ + { name: 'page declaration', reason: 'INVALID_DECLARATION', consumer: 'Page "page.js"', files: { 'page.js': "export const vars = { dataDeps: 'value' }; export default () => ''" } }, + { name: 'missing page key', reason: 'MISSING_KEY', consumer: 'Page "page.js"', key: 'missing', files: { 'page.js': "export const vars = { dataDeps: ['missing'] }; export default () => ''" } }, + { name: 'undeclared page access', reason: 'UNDECLARED_KEY', consumer: 'Page "page.js"', key: 'value', files: { 'page.js': 'export default ({data}) => data.value' } }, + { name: 'undeclared layout access', reason: 'UNDECLARED_KEY', consumer: 'Layout "root"', key: 'value', files: { 'page.html': 'Page', 'root.layout.js': 'export default ({data}) => data.value' } }, + { name: 'undeclared template access', reason: 'UNDECLARED_KEY', consumer: 'Template "value.template.js"', key: 'value', files: { 'value.template.js': 'export default ({data}) => data.value' } }, + { name: 'missing factory key', reason: 'MISSING_KEY', consumer: 'Pages file "value.pages.js"', key: 'missing', files: { 'value.pages.js': "export const dataDeps = ['missing']; export default () => []" } }, + { name: 'generated declaration', reason: 'INVALID_DECLARATION', consumer: 'Page "value.pages.js#0"', files: { 'value.pages.js': 'export default { vars: { dataDeps: false } }' } }, + { name: 'data dependency cycle', reason: 'NOT_READY', consumer: 'Page "page.js"', files: { 'page.js': "export const vars = { dataDeps: ['value'] }; export default ({data}) => data.value", 'global.data.js': 'export default async ({pages}) => ({ value: await pages[0].renderInnerPage() })' } }, + { name: 'global vars declaration', reason: 'INVALID_DECLARATION', consumer: 'Global vars', files: { 'global.vars.js': "export default { layout: 'root', dataDeps: ['value'] }" } }, + ] + for (const scenario of cases) { + await t.test(scenario.name, async () => { + await withTempFixture({ + 'root.layout.js': minimalRootLayout, + 'global.vars.js': minimalGlobalVars, + 'global.data.js': "export default { value: 'hello' }", + ...scenario.files, + }, async ({ src, dest }) => { + await assert.rejects(new DomStack(src, dest).build(), error => { + assert.ok(error instanceof AggregateError) + const dataError = error.errors.find(err => err instanceof DomStackDataError) + assert.ok(dataError, 'a DomStackDataError survives worker transport') + assert.equal(dataError.code, 'DOM_STACK_ERROR_DATA') + assert.deepEqual(dataError.dataDependency, { + reason: scenario.reason, + consumer: scenario.consumer, + ...(scenario.key === undefined ? {} : { key: scenario.key }), + }) + return true + }) + }) + }) + } +}) + +test('returns copyable generated vars derived from serializable global data', async () => { + await withTempFixture({ + 'root.layout.js': minimalRootLayout, + 'global.vars.js': minimalGlobalVars, + 'global.data.js': `export default function globalData ({ pages }) { + return { posts: pages.map(page => ({ title: page.vars.title, url: page.pageInfo.url })) } +} +`, + 'README.md': '# Concrete page\n', + 'indexes.pages.js': `export const dataDeps = ['posts'] +export default function indexesPages ({ data }) { + return { + outputName: 'generated-index/index.html', + vars: { + title: 'Generated index', + posts: data.posts, + }, + children: ({ vars }) => \`

\${vars.posts.length}

\`, + } +} +`, + }, async ({ src, dest }) => { + const domstack = new DomStack(src, dest) + const results = await domstack.build() + const output = await readFile(join(dest, 'generated-index/index.html'), 'utf8') + const outputRecord = results.pageBuildResults?.outputs.find(output => output.outputRelname === 'generated-index/index.html') + + assert.match(output, /

1<\/p>/, 'generated page renders with declared global data') + assert.ok(outputRecord, 'generated page emits an output record') + assert.equal(outputRecord.pageVars?.['title'], 'Generated index', 'copyable page vars are returned') + assert.equal(/** @type {unknown[]} */ (outputRecord.pageVars?.['posts']).length, 1, 'serializable derived data remains available in generated page vars') + }) +}) + +test('returns generated render errors without sending render state', async () => { + await withTempFixture({ + 'root.layout.js': minimalRootLayout, + 'global.vars.js': minimalGlobalVars, + 'broken.pages.js': `export default { + outputName: 'broken/index.html', + children () { + throw new Error('generated boom', { cause: () => {} }) + }, +} +`, + }, async ({ src, dest }) => { + const domstack = new DomStack(src, dest) + await assert.rejects( + () => domstack.build(), + error => { + const aggregate = /** @type {Error & { errors?: Array }} */ (error) + const generatedError = aggregate.errors?.find(error => error.page?.generated) + + assert.ok(generatedError, 'build includes the generated page error') + assert.match(generatedError.message, /page: "broken"/) + assert.equal(generatedError.page?.generated?.pagesFile?.pagesFile?.relname, 'broken.pages.js') + assert.notEqual(generatedError.name, 'DataCloneError') + assert.equal(/** @type {{ message?: string } | undefined} */ (generatedError.cause)?.message, 'generated boom') + return true + } + ) + }) +}) + +test('returns pages-file context when a generated-pages function throws', async () => { + await withTempFixture({ + 'root.layout.js': minimalRootLayout, + 'global.vars.js': minimalGlobalVars, + 'broken.pages.js': `export default function () { + throw new Error('pages factory boom', { cause: () => {} }) +} +`, + }, async ({ src, dest }) => { + const domstack = new DomStack(src, dest) + await assert.rejects( + () => domstack.build(), + error => { + const generatedError = firstGeneratedPagesError(error) + + assert.match(generatedError.message, /pages factory boom/) + assert.match(generatedError.message, /pages file: "broken\.pages\.js"/) + assert.equal(generatedError.pagesFile?.pagesFile.relname, 'broken.pages.js') + assert.notEqual(generatedError.name, 'DataCloneError') + assert.equal(/** @type {{ message?: string } | undefined} */ (generatedError.cause)?.message, 'pages factory boom') + return true + } + ) + }) +}) diff --git a/lib/build-pages/worker/page-outputs.test.js b/lib/build-pages/worker/page-outputs.test.js new file mode 100644 index 00000000..e3ce34a3 --- /dev/null +++ b/lib/build-pages/worker/page-outputs.test.js @@ -0,0 +1,40 @@ +import { test } from 'node:test' +import assert from 'node:assert/strict' +import { join } from 'node:path' +import { DomStackDataError } from '../../helpers/domstack-error.js' +import { setup, errorText } from '../outputs/test-helpers.js' + +for (const provider of ['page', 'companion', 'layout']) { + for (const scenario of [ + { name: 'synchronous', declaration: 'function', body: "return { outputName: 'secret.txt', content: data.secret }" }, + { name: 'asynchronous', declaration: 'async function', body: "await Promise.resolve(); return { outputName: 'secret.txt', content: data.secret }" }, + { name: 'iterator', declaration: 'async function*', body: "yield { outputName: 'first.txt', content: 'written' }; yield { outputName: 'secret.txt', content: data.secret }" }, + ]) { + test(`subscription errors survive worker transport from ${scenario.name} ${provider} pageOutputs`, async t => { + const providerFile = provider === 'layout' ? 'root.layout.js' : provider === 'companion' ? 'page.vars.js' : 'page.js' + const render = provider === 'layout' ? 'export default ({ children }) => children' : provider === 'companion' ? 'export default {}' : "export default () => 'main'" + const { build, src, read } = await setup(t, { + 'global.data.js': "export default { secret: 'private' }", + 'page.js': "export default () => 'main'", + [providerFile]: `${render}; export ${scenario.declaration} pageOutputs ({ data }) { ${scenario.body} }`, + }) + await assert.rejects(build(), error => { + assert.ok(error instanceof AggregateError) + const dataError = error.errors.find(err => err instanceof DomStackDataError) + assert.ok(dataError, 'a DomStackDataError survives worker transport') + assert.equal(dataError.name, 'DomStackDataError') + assert.equal(dataError.code, 'DOM_STACK_ERROR_DATA') + assert.deepEqual(dataError.dataDependency, { + reason: 'UNDECLARED_KEY', + consumer: provider === 'layout' ? 'Layout "root"' : 'Page "page.js"', + key: 'secret', + }) + assert.ok(dataError.message.includes(`pageOutputs for page "page.js" from ${provider} "${join(src, providerFile)}"`)) + assert.ok(dataError.cause instanceof Error) + assert.match(errorText(dataError.cause), /undeclared global data key "secret"/) + return true + }) + if (scenario.name === 'iterator') assert.equal(await read('first.txt'), 'written') + }) + } +} diff --git a/test-cases/cli-errors/commands.test.js b/lib/cli/tests/commands.test.js similarity index 99% rename from test-cases/cli-errors/commands.test.js rename to lib/cli/tests/commands.test.js index 4652c5c4..b0e6f306 100644 --- a/test-cases/cli-errors/commands.test.js +++ b/lib/cli/tests/commands.test.js @@ -11,7 +11,7 @@ import { join, resolve } from 'node:path' import test from 'node:test' import { setTimeout as delay } from 'node:timers/promises' -const bin = resolve(import.meta.dirname, '../../bin.js') +const bin = resolve(import.meta.dirname, '../../../bin.js') const commands = ['build', 'watch', 'serve', 'eject'] /** @type {Array<[string, string]>} */ const legacyModes = [ @@ -117,7 +117,7 @@ test('command help aliases and legacy help use the target command options withou test('root and every command expose the package version without a project', async t => { const cwd = await workspace(t) - const { version } = JSON.parse(await readFile(resolve(import.meta.dirname, '../../package.json'), 'utf8')) + const { version } = JSON.parse(await readFile(resolve(import.meta.dirname, '../../../package.json'), 'utf8')) for (const args of [[], ...commands.map(command => [command]), ...legacyModes.map(([flag]) => [flag])]) { const result = cli(cwd, [...args, '--version']) assert.equal(result.status, 0, result.stdout + result.stderr) diff --git a/test-cases/cli-errors/eject.test.js b/lib/cli/tests/eject.test.js similarity index 98% rename from test-cases/cli-errors/eject.test.js rename to lib/cli/tests/eject.test.js index 2a7391cd..93925637 100644 --- a/test-cases/cli-errors/eject.test.js +++ b/lib/cli/tests/eject.test.js @@ -7,7 +7,7 @@ import { promisify } from 'node:util' import { test } from 'node:test' const exec = promisify(execFile) -const project = resolve(import.meta.dirname, '../..') +const project = resolve(import.meta.dirname, '../../..') const bin = join(project, 'bin.js') for (const mode of ['eject', '--eject']) { diff --git a/test-cases/cli-errors/index.test.js b/lib/cli/tests/index.test.js similarity index 97% rename from test-cases/cli-errors/index.test.js rename to lib/cli/tests/index.test.js index e39585ec..4b9cd951 100644 --- a/test-cases/cli-errors/index.test.js +++ b/lib/cli/tests/index.test.js @@ -5,7 +5,7 @@ import { join, resolve } from 'node:path' import { stripVTControlCharacters } from 'node:util' import test from 'node:test' -const bin = resolve(import.meta.dirname, '../../bin.js') +const bin = resolve(import.meta.dirname, '../../../bin.js') for (const mode of ['build', 'watch']) { test(`CLI ${mode} failures print complete diagnostic arrays and locations`, async t => { diff --git a/test-cases/cli-errors/logging.test.js b/lib/cli/tests/logging.test.js similarity index 92% rename from test-cases/cli-errors/logging.test.js rename to lib/cli/tests/logging.test.js index 74744c61..2597a73c 100644 --- a/test-cases/cli-errors/logging.test.js +++ b/lib/cli/tests/logging.test.js @@ -12,7 +12,7 @@ test('CLI prints the build tree with and without --verbose', async t => { for (const verbose of [false, true]) { const result = spawnSync(process.execPath, [ - resolve(import.meta.dirname, '../../bin.js'), '--src', 'src', '--dest', 'dest', + resolve(import.meta.dirname, '../../../bin.js'), '--src', 'src', '--dest', 'dest', ...(verbose ? ['--verbose'] : []), ], { cwd: root, encoding: 'utf8', timeout: 15000 }) assert.ifError(result.error) diff --git a/lib/domstack-manifest/build.test.js b/lib/domstack-manifest/build.test.js new file mode 100644 index 00000000..2bec8995 --- /dev/null +++ b/lib/domstack-manifest/build.test.js @@ -0,0 +1,326 @@ +/** @import { DomstackManifestEntry } from '#types' */ +import { test } from 'node:test' +import assert from 'node:assert/strict' +import { DomStack, testBuild, reconcileDomstackManifest } from '../../index.js' +import * as path from 'node:path' +import { mkdir, mkdtemp, readFile, rm, stat, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' + +const __dirname = path.resolve(import.meta.dirname, '../../test-cases/general-features') +const src = path.join(__dirname, 'src') + +test('domstackManifest version includes cache-relevant metadata', async (t) => { + const dest = await mkdtemp(path.join(tmpdir(), 'domstack-manifest-test-')) + t.after(() => rm(dest, { recursive: true, force: true })) + const basePage = { + path: '', + url: '/', + } + /** @type {DomstackManifestEntry} */ + const baseEntry = { + outputRelname: 'index.html', + kind: 'page', + url: '/', + revision: 'same-file-revision', + bytes: 42, + sourceRelname: 'pages/index.js', + page: basePage, + } + + const { manifest: baseManifest } = await reconcileDomstackManifest({ dest, entries: [baseEntry] }) + const { manifest: sourceOnlyManifest } = await reconcileDomstackManifest({ + dest, + entries: [{ + ...baseEntry, + sourceRelname: 'pages/renamed-index.js', + }], + }) + const { manifest: kindChangedManifest } = await reconcileDomstackManifest({ + dest, + entries: [{ + ...baseEntry, + kind: 'template', + }], + }) + const { manifest: offlineChangedManifest } = await reconcileDomstackManifest({ + dest, + entries: [{ + ...baseEntry, + manifestVars: { + offline: false, + }, + }], + }) + const { manifest: manifestVarsManifest } = await reconcileDomstackManifest({ + dest, + entries: [{ + ...baseEntry, + manifestVars: { + precache: true, + }, + }], + }) + const { manifest: policyManifest } = await reconcileDomstackManifest({ + dest, + entries: [{ + ...baseEntry, + manifestVars: { + precache: true, + }, + }], + options: { + policy ({ entries }) { + return { + precacheUrls: entries + .filter(entry => entry.manifestVars?.['precache'] === true) + .map(entry => entry.url), + } + }, + }, + }) + const { manifest: objectPrecacheManifest } = await reconcileDomstackManifest({ + dest, + entries: [{ + ...baseEntry, + manifestVars: { + precache: { core: true, priority: 1 }, + }, + }], + }) + const { manifest: reorderedObjectPrecacheManifest } = await reconcileDomstackManifest({ + dest, + entries: [{ + ...baseEntry, + manifestVars: { + precache: { priority: 1, core: true }, + }, + }], + }) + + assert.strictEqual( + sourceOnlyManifest.version, + baseManifest.version, + 'source metadata does not affect domstack manifest version' + ) + assert.notStrictEqual( + kindChangedManifest.version, + baseManifest.version, + 'kind affects domstack manifest version' + ) + assert.notStrictEqual( + offlineChangedManifest.version, + baseManifest.version, + 'configured manifest vars affect domstack manifest version' + ) + assert.deepStrictEqual( + manifestVarsManifest.entries[0]?.manifestVars, + { precache: true }, + 'manifestVars exposes selected application policy' + ) + assert.deepStrictEqual( + policyManifest.policy, + { precacheUrls: ['/'] }, + 'policy transform exposes derived root manifest policy' + ) + assert.notStrictEqual( + manifestVarsManifest.version, + baseManifest.version, + 'manifestVars affect domstack manifest version' + ) + assert.notStrictEqual( + policyManifest.version, + baseManifest.version, + 'policy affects domstack manifest version' + ) + assert.strictEqual( + reorderedObjectPrecacheManifest.version, + objectPrecacheManifest.version, + 'object-valued manifest policy uses stable key ordering' + ) +}) + +test('domstackManifest warns when output producers conflict', async (t) => { + const dest = await mkdtemp(path.join(tmpdir(), 'domstack-manifest-test-')) + t.after(() => rm(dest, { recursive: true, force: true })) + /** @type {DomstackManifestEntry} */ + const copyEntry = { + outputRelname: 'index.html', + kind: 'copy', + url: '/', + revision: 'same-file-revision', + bytes: 42, + sourceRelname: 'static/index.html', + } + const pageEntry = { + ...copyEntry, + kind: /** @type {const} */ ('page'), + sourceRelname: 'pages/index.js', + } + + const { manifest, warnings } = await reconcileDomstackManifest({ + dest, + entries: [copyEntry, pageEntry], + }) + const { warnings: equivalentWarnings } = await reconcileDomstackManifest({ + dest, + entries: [copyEntry, { ...copyEntry }], + }) + + assert.strictEqual(manifest.entries[0]?.kind, 'page', 'kind priority still selects the winning record') + assert.deepStrictEqual(warnings, [{ + code: 'DOM_STACK_WARNING_CONFLICTING_MANIFEST_OUTPUT', + message: 'Conflicting manifest records target "index.html" (kind, sourceRelname differ); keeping page record from "pages/index.js".', + }]) + assert.deepStrictEqual(equivalentWarnings, [], 'equivalent duplicate observations remain quiet') +}) + +test('domstackManifest exclude handles root page URL', async (t) => { + const excludeBuild = await testBuild(src, { + copy: [path.join(__dirname, './copyfolder')], + domstackManifest: { + exclude: ['oldsite/**'], + includeEntry: entry => entry.kind !== 'copy', + }, + }) + t.after(async () => { + await excludeBuild.cleanup() + }) + + const excludeEntries = /** @type {DomstackManifestEntry[]} */ (excludeBuild.results.domstackManifest?.entries ?? []) + + assert.ok( + excludeEntries.some(entry => entry.url === '/'), + 'root page URL survives non-root exclude filters' + ) + assert.ok( + !excludeEntries.some(entry => entry.url.startsWith('/oldsite/')), + 'exclude filters still remove matching output paths' + ) + assert.ok( + !excludeEntries.some(entry => entry.kind === 'copy'), + 'programmatic domstackManifest includeEntry is applied' + ) +}) + +test('domstack-manifest.settings.js filters domstack manifest entries', async (t) => { + const settingsSrc = await mkdtemp(path.join(tmpdir(), 'domstack-manifest-settings-')) + const settingsDest = await mkdtemp(path.join(tmpdir(), 'domstack-manifest-settings-public-')) + t.after(async () => { + await rm(settingsSrc, { recursive: true, force: true }) + await rm(settingsDest, { recursive: true, force: true }) + }) + + await mkdir(path.join(settingsSrc, 'kept'), { recursive: true }) + await mkdir(path.join(settingsSrc, 'programmatic'), { recursive: true }) + await mkdir(path.join(settingsSrc, 'settings'), { recursive: true }) + await writeFile(path.join(settingsSrc, 'page.js'), 'export default () => "

Domstack manifest settings

"\n') + await writeFile(path.join(settingsSrc, 'service-worker.js'), 'console.log(process.env.DOMSTACK_MANIFEST_URL, DOMSTACK_TEST_SERVICE_WORKER_POLICY.message)\n') + await writeFile(path.join(settingsSrc, 'kept/page.js'), 'export default () => "

Kept

"\n') + await writeFile(path.join(settingsSrc, 'programmatic/page.js'), 'export default () => "

Programmatic exclude

"\n') + await writeFile(path.join(settingsSrc, 'settings/page.js'), 'export default () => "

Settings exclude

"\n') + await writeFile(path.join(settingsSrc, 'domstack-manifest.settings.js'), ` +export default async function domstackManifestSettings () { + return { + exclude: ['settings/**'], + includeEntry (entry) { + return entry.kind !== 'sourcemap' + }, + hooks: { + manifestBuilt: [context => { + context.defineServiceWorkerConstant('DOMSTACK_TEST_SERVICE_WORKER_POLICY', { + message: 'from-manifest-hook', + version: context.manifest.version, + }) + }], + }, + } +} +`) + + const settingsSite = new DomStack(settingsSrc, settingsDest, { + domstackManifest: { + exclude: ['programmatic/**'], + includeEntry: () => false, + write: true, + }, + }) + + const settingsResults = await settingsSite.build() + const settingsEntries = /** @type {DomstackManifestEntry[]} */ (settingsResults.domstackManifest?.entries ?? []) + + const settingsServiceWorkerContent = await readFile(path.join(settingsDest, 'service-worker.js'), 'utf8') + + await stat(path.join(settingsDest, 'domstack-manifest.json')) + assert.ok( + settingsServiceWorkerContent.includes('/domstack-manifest.json'), + 'service worker define receives the standard domstack manifest URL' + ) + assert.ok( + settingsServiceWorkerContent.includes('from-manifest-hook'), + 'manifestBuilt hook can define constants for the final service-worker bundle' + ) + assert.ok( + settingsEntries.some(entry => entry.url === '/'), + 'root page survives domstack manifest settings filters' + ) + assert.ok( + settingsEntries.some(entry => entry.url === '/kept/'), + 'settings-file includeEntry takes precedence over the programmatic hook' + ) + assert.ok( + !settingsEntries.some(entry => entry.url === '/programmatic/'), + 'programmatic domstackManifest exclude is applied' + ) + assert.ok( + !settingsEntries.some(entry => entry.url === '/settings/'), + 'domstack-manifest.settings.js exclude is applied' + ) + assert.ok( + !settingsEntries.some(entry => entry.kind === 'sourcemap'), + 'domstack-manifest.settings.js includeEntry is applied' + ) +}) + +test('domstackManifest true writes the default manifest file', async (t) => { + const writtenManifestBuild = await testBuild(src, { domstackManifest: true }) + t.after(async () => { + await writtenManifestBuild.cleanup() + }) + + const writtenManifestDest = writtenManifestBuild.dest + /** @type {{ version: string, entries: Record[] }} */ + const writtenManifest = JSON.parse(await readFile(path.join(writtenManifestDest, 'domstack-manifest.json'), 'utf8')) + assert.strictEqual( + writtenManifest.version, + writtenManifestBuild.results.domstackManifest?.version, + 'domstackManifest true writes the returned manifest to disk' + ) + assert.ok( + writtenManifest.entries.every(entry => !('filepath' in entry)), + 'written domstack manifest does not expose absolute filesystem paths' + ) +}) + +test('build without manifest settings or write request skips domstack manifest pipeline', async (t) => { + const noManifestSrc = await mkdtemp(path.join(tmpdir(), 'domstack-no-manifest-')) + const noManifestDest = await mkdtemp(path.join(tmpdir(), 'domstack-no-manifest-public-')) + t.after(async () => { + await rm(noManifestSrc, { recursive: true, force: true }) + await rm(noManifestDest, { recursive: true, force: true }) + }) + + await writeFile(path.join(noManifestSrc, 'page.js'), 'export default () => "

No manifest settings

"\n') + await writeFile(path.join(noManifestSrc, 'service-worker.js'), 'console.log(process.env.DOMSTACK_MANIFEST_URL, process.env.DOMSTACK_MANIFEST_ENABLED)\n') + const noManifestResults = await new DomStack(noManifestSrc, noManifestDest).build() + const noManifestServiceWorkerContent = await readFile(path.join(noManifestDest, 'service-worker.js'), 'utf8') + + assert.strictEqual(noManifestResults.domstackManifest, undefined, 'build does not return a domstack manifest without a consumer') + await assert.rejects( + () => stat(path.join(noManifestDest, 'domstack-manifest.json')), + 'domstack manifest is not written without an explicit write request' + ) + assert.ok( + noManifestServiceWorkerContent.includes('/domstack-manifest.json'), + 'standard domstack manifest URL define remains stable even when the pipeline is disabled' + ) +}) diff --git a/lib/watch/generated-page-ownership.test.js b/lib/watch/generated-page-ownership.test.js new file mode 100644 index 00000000..8ce2574b --- /dev/null +++ b/lib/watch/generated-page-ownership.test.js @@ -0,0 +1,68 @@ +import { test } from 'node:test' +import assert from 'node:assert/strict' +import { rm, stat, writeFile } from 'node:fs/promises' +import { join } from 'node:path' +import { setup, assertReported } from '../build-pages/generated-pages/streaming-test-helpers.js' +import { settle } from '../build-pages/outputs/test-helpers.js' +import { startWatch } from './test-helpers.js' + +/** @param {string[]} names @param {string} [failure] */ +function watchFactory (names, failure) { + return `export default async function* () { + ${names.map(name => `yield { outputName: '${name}.html', children: '${name}' }`).join('\n')} + ${failure ? `throw Error('${failure}')` : ''} + }` +} + +for (const change of ['recovery', 'deletion', 'empty result']) { + test(`watch ${change} cleans successful and repeated partial factory ownership`, { timeout: 30_000 }, async t => { + const { site, src, dest, read, logs } = await setup(t, { + 'stream.pages.js': watchFactory(['old', 'stale']), + 'sibling.pages.js': watchFactory(['sibling']), + }) + await startWatch(t, site, src) + const sibling = await read('sibling.html') + for (const name of ['partial', 'second-partial']) { + await settle(site, logs, async () => { + await writeFile(join(src, 'stream.pages.js'), watchFactory([name], `${name} failure`)) + }, `${name} failure`) + assert.equal(await read(`${name}.html`), `
${name}
`) + assert.equal(await read('old.html'), '
old
') + assert.equal(await read('stale.html'), '
stale
') + assert.equal(await read('partial.html'), '
partial
', 'repeated failure keeps earlier partial ownership') + } + await settle(site, logs, async () => { + if (change === 'deletion') await rm(join(src, 'stream.pages.js')) + else await writeFile(join(src, 'stream.pages.js'), change === 'empty result' ? 'export default null' : watchFactory(['recovered'])) + }) + for (const name of ['old', 'stale', 'partial', 'second-partial']) { + await assert.rejects(stat(join(dest, `${name}.html`)), { code: 'ENOENT' }) + } + if (change === 'recovery') assert.equal(await read('recovered.html'), '
recovered
') + assert.equal(await read('sibling.html'), sibling, 'cleanup preserves sibling factory output') + await assert.rejects(stat(join(dest, 'domstack-manifest.json')), { code: 'ENOENT' }) + }) +} + +for (const change of ['recovery', 'deletion', 'empty result']) { + test(`initial failed watch retains partial generated page reports for ${change}`, { timeout: 30_000 }, async t => { + const { site, src, dest, read, logs } = await setup(t, { + 'stream.pages.js': watchFactory(['partial', 'nested/partial'], 'initial stream failure'), + }) + const result = await startWatch(t, site, src) + assert.ok(logs.some(line => JSON.parse(line).msg === 'Build Failed!')) + assert.ok(logs.some(line => line.includes('initial stream failure'))) + for (const [index, name] of ['partial', 'nested/partial'].entries()) { + assert.equal(await read(`${name}.html`), `
${name}
`) + assertReported(result.pageBuildResults, src, dest, `${name}.html`, 'stream.pages.js', index) + } + await settle(site, logs, async () => { + if (change === 'deletion') await rm(join(src, 'stream.pages.js')) + else await writeFile(join(src, 'stream.pages.js'), change === 'empty result' ? 'export default []' : watchFactory(['recovered'])) + }) + for (const name of ['partial', 'nested/partial']) { + await assert.rejects(stat(join(dest, `${name}.html`)), { code: 'ENOENT' }) + } + if (change === 'recovery') assert.equal(await read('recovered.html'), '
recovered
') + }) +} diff --git a/lib/watch/generated-pages.test.js b/lib/watch/generated-pages.test.js new file mode 100644 index 00000000..9b9b1e6f --- /dev/null +++ b/lib/watch/generated-pages.test.js @@ -0,0 +1,211 @@ +import { test } from 'node:test' +import assert from 'node:assert/strict' +import { DomStack } from '../../index.js' +import { readFile, stat, writeFile, rm } from 'node:fs/promises' +import { join } from 'node:path' +import { editAndWait, startWatch } from './test-helpers.js' +import { withTempFixture, minimalRootLayout, minimalGlobalVars, assetAwareRootLayout } from '../build-pages/generated-pages/test-helpers.js' + +test('rebuilds declared subscribers when a global-data key changes', { timeout: 15_000 }, async t => { + await withTempFixture({ + 'root.layout.js': minimalRootLayout, + 'global.vars.js': minimalGlobalVars, + 'page.js': 'export default ({ vars }) => vars.title\n', + 'page.vars.js': "export default { title: 'First title' }\n", + 'global.data.js': `export default function ({ pages }) { + return { sourceTitle: pages[0].vars.title } +} +`, + 'watch-indexes.pages.js': `export const dataDeps = ['sourceTitle'] +export default function ({ data }) { + const title = data.sourceTitle + const outputName = title === 'First title' + ? 'watch-first/index.html' + : 'watch-updated/index.html' + return { outputName, vars: { title }, children: () => title } +} +`, + 'summary.template.js': `export const dataDeps = ['sourceTitle'] +export default function ({ data }) { + const outputName = data.sourceTitle === 'First title' + ? 'watch-first/index.html' + : 'watch-updated/index.html' + return outputName + ':' + data.sourceTitle +} +`, + 'unrelated.pages.js': `import { appendFileSync } from 'node:fs' + +export default function unrelatedPages () { + appendFileSync(new URL('../unrelated-factory-runs', import.meta.url), 'run\\n') + return { outputName: 'unrelated/index.html', children: 'Unrelated' } +} +`, + }, async ({ src, dest }) => { + const domstack = new DomStack(src, dest) + const factoryRuns = join(src, '../unrelated-factory-runs') + try { + await startWatch(t, domstack, src, { + serve: false, + async onInitialBuild () { + assert.equal(await readFile(factoryRuns, 'utf8'), 'run\n', 'the unrelated factory ran during the initial build') + }, + }) + const initialOutputPath = join(dest, 'watch-first/index.html') + const updatedOutputPath = join(dest, 'watch-updated/index.html') + assert.match(await readFile(initialOutputPath, 'utf8'), /First title/) + assert.equal(await readFile(join(dest, 'summary'), 'utf8'), 'watch-first/index.html:First title') + const startupFactoryRuns = await readFile(factoryRuns, 'utf8') + + await editAndWait(domstack, join(src, 'page.vars.js'), () => writeFile(join(src, 'page.vars.js'), "export default { title: 'Updated title' }\n")) + + const updatedOutput = await readFile(updatedOutputPath, 'utf8') + assert.match(updatedOutput, /Updated title/) + assert.doesNotMatch(updatedOutput, /First title/) + assert.equal(await readFile(join(dest, 'summary'), 'utf8'), 'watch-updated/index.html:Updated title') + await assert.rejects(() => stat(initialOutputPath), { code: 'ENOENT' }, 'obsolete dependency-driven output is removed') + assert.equal(await readFile(factoryRuns, 'utf8'), startupFactoryRuns, 'an unrelated factory is not executed during a subscriber rebuild') + } finally { + if (domstack.watching) await domstack.stopWatching() + } + }) +}) + +test('rebuilds generated pages when Markdown settings change in watch mode', { timeout: 15_000 }, async t => { + const markdownSettings = (/** @type {string} */ version) => `export default function (md) { + md.renderer.rules.paragraph_open = () => '

' + return md +} +` + + await withTempFixture({ + 'root.layout.js': minimalRootLayout, + 'global.vars.js': minimalGlobalVars, + 'post.md': 'Rendered post\n', + 'markdown-it.settings.js': markdownSettings('first'), + 'global.data.js': `export default async function ({ pages }) { + const post = pages.find(page => page.pageInfo.pageFile.relname === 'post.md') + if (!post) throw new Error('Missing Markdown post') + return { renderedPost: await post.renderInnerPage() } +} +`, + 'markdown-summary.pages.js': `export const dataDeps = ['renderedPost'] +export default function ({ data }) { + return { outputName: 'summary/index.html', children: data.renderedPost } +} +`, + }, async ({ src, dest }) => { + const domstack = new DomStack(src, dest) + const outputPath = join(dest, 'summary/index.html') + + try { + await startWatch(t, domstack, src) + assert.match(await readFile(outputPath, 'utf8'), /data-version="first"/) + + await editAndWait(domstack, join(src, 'markdown-it.settings.js'), () => writeFile(join(src, 'markdown-it.settings.js'), markdownSettings('second'))) + + const updatedOutput = await readFile(outputPath, 'utf8') + assert.match(updatedOutput, /data-version="second"/) + assert.doesNotMatch(updatedOutput, /data-version="first"/) + } finally { + if (domstack.watching) await domstack.stopWatching() + } + }) +}) + +test('rebuilds generated pages when layout assets are added or removed in watch mode', { timeout: 25_000 }, async t => { + await withTempFixture({ + 'root.layout.js': assetAwareRootLayout, + 'global.vars.js': minimalGlobalVars, + 'page.js': "export default () => 'Regular page'\n", + 'layout-assets.pages.js': `export default { + outputName: 'generated/index.html', + children: 'Generated page', +} +`, + }, async ({ src, dest }) => { + const domstack = new DomStack(src, dest) + const regularOutputPath = join(dest, 'index.html') + const generatedOutputPath = join(dest, 'generated/index.html') + + /** + * @param {string} assetName + * @param {boolean} expected + */ + const assertAssetReference = async (assetName, expected) => { + const [regularHtml, generatedHtml] = await Promise.all([ + readFile(regularOutputPath, 'utf8'), + readFile(generatedOutputPath, 'utf8'), + ]) + assert.equal(regularHtml.includes(assetName), expected, `regular page ${expected ? 'includes' : 'omits'} ${assetName}`) + assert.equal(generatedHtml.includes(assetName), expected, `generated page ${expected ? 'includes' : 'omits'} ${assetName}`) + } + + try { + await startWatch(t, domstack, src) + await assertAssetReference('root.layout.css', false) + await assertAssetReference('root.layout.client.js', false) + + await editAndWait(domstack, join(src, 'root.layout.css'), () => writeFile(join(src, 'root.layout.css'), 'body { color: red }\n')) + await assertAssetReference('root.layout.css', true) + + await editAndWait(domstack, join(src, 'root.layout.css'), () => rm(join(src, 'root.layout.css'))) + await assertAssetReference('root.layout.css', false) + + await editAndWait(domstack, join(src, 'root.layout.client.js'), () => writeFile(join(src, 'root.layout.client.js'), 'globalThis.layoutClientLoaded = true\n')) + await assertAssetReference('root.layout.client.js', true) + + await editAndWait(domstack, join(src, 'root.layout.client.js'), () => rm(join(src, 'root.layout.client.js'))) + await assertAssetReference('root.layout.client.js', false) + } finally { + if (domstack.watching) await domstack.stopWatching() + } + }) +}) + +test('removes obsolete regular and generated page outputs in watch mode', { timeout: 20_000 }, async t => { + await withTempFixture({ + 'root.layout.js': minimalRootLayout, + 'global.vars.js': minimalGlobalVars, + 'regular/page.html': '

Regular page

', + 'changing.pages.js': `export default [ + { outputName: 'old/index.html', children: 'Old generated page' }, + { outputName: 'removed/index.html', children: 'Removed generated page' }, + { outputName: 'drafted/index.html', children: 'Published generated page' }, +] +`, + }, async ({ src, dest }) => { + const domstack = new DomStack(src, dest) + try { + await startWatch(t, domstack, src) + const oldOutputPath = join(dest, 'old/index.html') + const newOutputPath = join(dest, 'new/index.html') + const removedOutputPath = join(dest, 'removed/index.html') + const draftedOutputPath = join(dest, 'drafted/index.html') + const regularOutputPath = join(dest, 'regular/index.html') + + assert.match(await readFile(oldOutputPath, 'utf8'), /Old generated page/) + assert.match(await readFile(removedOutputPath, 'utf8'), /Removed generated page/) + assert.match(await readFile(draftedOutputPath, 'utf8'), /Published generated page/) + assert.match(await readFile(regularOutputPath, 'utf8'), /Regular page/) + + await editAndWait(domstack, join(src, 'changing.pages.js'), () => writeFile(join(src, 'changing.pages.js'), `export default [ + { outputName: 'new/index.html', children: 'Renamed generated page' }, + { outputName: 'drafted/index.html', children: 'Draft generated page', draft: true }, +] +`)) + + assert.match(await readFile(newOutputPath, 'utf8'), /Renamed generated page/) + await assert.rejects(() => readFile(oldOutputPath, 'utf8'), { code: 'ENOENT' }) + await assert.rejects(() => readFile(removedOutputPath, 'utf8'), { code: 'ENOENT' }) + await assert.rejects(() => readFile(draftedOutputPath, 'utf8'), { code: 'ENOENT' }) + + await editAndWait(domstack, join(src, 'regular/page.html'), () => rm(join(src, 'regular/page.html'))) + await assert.rejects(() => readFile(regularOutputPath, 'utf8'), { code: 'ENOENT' }) + + await editAndWait(domstack, join(src, 'changing.pages.js'), () => rm(join(src, 'changing.pages.js'))) + await assert.rejects(() => readFile(newOutputPath, 'utf8'), { code: 'ENOENT' }) + } finally { + if (domstack.watching) await domstack.stopWatching() + } + }) +}) diff --git a/test-cases/incremental-global-data/fixtures/data.json.template.js b/lib/watch/incremental-global-data-tests/fixtures/data.json.template.js similarity index 100% rename from test-cases/incremental-global-data/fixtures/data.json.template.js rename to lib/watch/incremental-global-data-tests/fixtures/data.json.template.js diff --git a/test-cases/incremental-global-data/fixtures/global.data.js b/lib/watch/incremental-global-data-tests/fixtures/global.data.js similarity index 100% rename from test-cases/incremental-global-data/fixtures/global.data.js rename to lib/watch/incremental-global-data-tests/fixtures/global.data.js diff --git a/test-cases/incremental-global-data/fixtures/producer-leaf.js b/lib/watch/incremental-global-data-tests/fixtures/producer-leaf.js similarity index 100% rename from test-cases/incremental-global-data/fixtures/producer-leaf.js rename to lib/watch/incremental-global-data-tests/fixtures/producer-leaf.js diff --git a/test-cases/incremental-global-data/fixtures/producer-middle.js b/lib/watch/incremental-global-data-tests/fixtures/producer-middle.js similarity index 100% rename from test-cases/incremental-global-data/fixtures/producer-middle.js rename to lib/watch/incremental-global-data-tests/fixtures/producer-middle.js diff --git a/test-cases/incremental-global-data/helpers.js b/lib/watch/incremental-global-data-tests/helpers.js similarity index 98% rename from test-cases/incremental-global-data/helpers.js rename to lib/watch/incremental-global-data-tests/helpers.js index 0b89c95f..5d244cd7 100644 --- a/test-cases/incremental-global-data/helpers.js +++ b/lib/watch/incremental-global-data-tests/helpers.js @@ -19,8 +19,8 @@ import { dirname, join } from 'node:path' import { setImmediate as nextTurn, setTimeout as delay } from 'node:timers/promises' import chokidar from 'chokidar' import pino from 'pino' -import { DomStack } from '../../index.js' -import { startWatch } from '../../lib/watch/test-helpers.js' +import { DomStack } from '../../../index.js' +import { startWatch } from '../test-helpers.js' /** @param {string} heading @param {string} [body] @param {string} [frontmatter] */ export function article (heading, body = 'Original body.', frontmatter = '') { diff --git a/test-cases/incremental-global-data/index.test.js b/lib/watch/incremental-global-data-tests/index.test.js similarity index 100% rename from test-cases/incremental-global-data/index.test.js rename to lib/watch/incremental-global-data-tests/index.test.js diff --git a/test-cases/watch-lifecycle/index.test.js b/lib/watch/lifecycle-tests/index.test.js similarity index 99% rename from test-cases/watch-lifecycle/index.test.js rename to lib/watch/lifecycle-tests/index.test.js index 11fb0f68..f50a9d2b 100644 --- a/test-cases/watch-lifecycle/index.test.js +++ b/lib/watch/lifecycle-tests/index.test.js @@ -11,7 +11,7 @@ import { Server } from 'node:net' import { join, resolve } from 'node:path' import { setImmediate, setTimeout as delay } from 'node:timers/promises' import chokidar from 'chokidar' -import { DomStack } from '../../index.js' +import { DomStack } from '../../../index.js' /** * @param {TestContext} t diff --git a/test-cases/watch-lifecycle/logging.test.js b/lib/watch/lifecycle-tests/logging.test.js similarity index 98% rename from test-cases/watch-lifecycle/logging.test.js rename to lib/watch/lifecycle-tests/logging.test.js index a286e607..3db8ef72 100644 --- a/test-cases/watch-lifecycle/logging.test.js +++ b/lib/watch/lifecycle-tests/logging.test.js @@ -5,7 +5,7 @@ import { pathToFileURL } from 'node:url' import { setTimeout } from 'node:timers/promises' import test from 'node:test' import pino from 'pino' -import { DomStack } from '../../index.js' +import { DomStack } from '../../../index.js' /** * @param {() => boolean} check diff --git a/lib/watch/nested-layouts.test.js b/lib/watch/nested-layouts.test.js new file mode 100644 index 00000000..2b58e526 --- /dev/null +++ b/lib/watch/nested-layouts.test.js @@ -0,0 +1,223 @@ +import { test } from 'node:test' +import assert from 'node:assert/strict' +import { mkdir, stat, unlink } from 'node:fs/promises' +import { join } from 'node:path' +import pino from 'pino' +import { editAndWait, startWatch, waitForRebuild } from './test-helpers.js' +import { setup, setupSubscriptions, rootLayout, articleLayout, globalData } from '../build-pages/layouts/nested-test-helpers.js' + +test('watch follows ancestor edits, imports, reparenting, and asset membership', { timeout: 60_000 }, async t => { + const { domstack, read, write, dest, src } = await setup(t) + await startWatch(t, domstack, src) + const unrelatedTime = (await stat(join(dest, 'plain/index.html'))).mtimeMs + await editAndWait(domstack, join(src, 'label.js'), () => write('label.js', "export const label = 'v2'")) + for (const file of ['source/index.html', 'typed/index.html', 'markup/index.html', 'archive.html']) { + assert.match(await read(file), /data-root="v2"/) + } + await editAndWait(domstack, join(src, 'root.layout.js'), () => write('root.layout.js', rootLayout.replace('data-root', 'data-updated-root'))) + assert.match(await read('archive.html'), /data-updated-root="v2"/) + assert.equal((await stat(join(dest, 'plain/index.html'))).mtimeMs, unrelatedTime) + + // Import relationships also refresh when an ancestor changes its helpers. + await editAndWait(domstack, join(src, 'root.layout.js'), () => write('root.layout.js', rootLayout.replace("'./label.js'", "'./other-label.js'"))) + await editAndWait(domstack, join(src, 'other-label.js'), () => write('other-label.js', "export const label = 'alternate v2'")) + assert.match(await read('archive.html'), /data-root="alternate v2"/) + + // Failed builds retain the successful chain so fixing an ancestor retries it. + await editAndWait(domstack, join(src, 'root.layout.js'), () => write('root.layout.js', "export const parentLayout = 'post'; export default () => ''")) + assert.match(await read('archive.html'), /data-root="alternate v2"/) + await editAndWait(domstack, join(src, 'root.layout.js'), () => write('root.layout.js', rootLayout)) + assert.match(await read('archive.html'), /data-root="v2"/) + + await editAndWait(domstack, join(src, 'article.layout.css'), () => unlink(join(src, 'article.layout.css'))) + assert.doesNotMatch(await read('archive.html'), /article\.layout\.css/) + await editAndWait(domstack, join(src, 'article.layout.css'), () => write('article.layout.css', 'article { color: red }')) + assert.match(await read('archive.html'), /article\.layout\.css/) + + await editAndWait(domstack, join(src, 'article.layout.js'), () => write('article.layout.js', articleLayout.replace("'root'", "'other'"))) + assert.match(await read('source/index.html'), /

`), `${to} is backed by a concrete page`) - assert.match(destinationHtml, //, `${to} receives its layout's subscribed global data`) - } - - const blog2024IndexDoc = cheerio.load(await readOutput('blog/2024/index.html')) - const blog2024Links = blog2024IndexDoc('.blog-entry-link').toArray().map(link => ({ - href: blog2024IndexDoc(link).attr('href'), - title: blog2024IndexDoc(link).text().trim(), - })) - const blog2024Dates = blog2024IndexDoc('.blog-entry-date').toArray().map(time => blog2024IndexDoc(time).text().trim()) - assert.deepEqual(blog2024Links, [ - { href: '/blog/2024/post-two/', title: 'Post Two' }, - { href: '/blog/2024/post-one/', title: 'Post One' }, - ], 'generated yearly indexes link concrete posts newest-first') - assert.deepEqual(blog2024Dates, ['2024-06-15', '2024-01-02'], 'generated yearly indexes render publication dates') - - const blog2023IndexDoc = cheerio.load(await readOutput('blog/2023/index.html')) - assert.deepEqual(blog2023IndexDoc('.blog-entry-link').toArray().map(link => ({ - href: blog2023IndexDoc(link).attr('href'), - title: blog2023IndexDoc(link).text().trim(), - })), [ - { href: '/blog/2023/older-post/', title: 'Older Post' }, - ], 'a generated index is created for each year with posts') - - const introspectionHtml = await readOutput('generated-introspection/index.html') - const introspectionDoc = cheerio.load(introspectionHtml) - assert.equal(introspectionDoc('#has-pages').text(), 'false', 'pages files do not receive the raw page collection') - assert.equal(introspectionDoc('#has-site-data').text(), 'false', 'pages files do not receive the discovery registry') - assert.equal(introspectionDoc('meta[name="source-page-count"]').attr('content'), '7', 'global.data sees source-backed pages before pages files run') - - const stylesheetHrefs = Array.from(introspectionDoc('link[rel="stylesheet"]')).map(link => introspectionDoc(link).attr('href') ?? '') - assert.ok(stylesheetHrefs.some(href => href.startsWith('/global-') && href.endsWith('.css')), 'generated page includes global stylesheet') - assert.ok(stylesheetHrefs.some(href => href.startsWith('/root.layout-') && href.endsWith('.css')), 'generated page includes layout stylesheet') - assert.ok(!stylesheetHrefs.some(href => href.startsWith('./style-')), 'generated page does not include page-local stylesheet') - - const scriptSrcs = Array.from(introspectionDoc('script[type="module"]')).map(script => introspectionDoc(script).attr('src') ?? '') - assert.ok(scriptSrcs.some(src => src.startsWith('/global.client-') && src.endsWith('.js')), 'generated page includes global client') - assert.ok(scriptSrcs.some(src => src.startsWith('/root.layout.client-') && src.endsWith('.js')), 'generated page includes layout client') - assert.ok(!scriptSrcs.some(src => src.startsWith('./client-')), 'generated page does not include page-local client') - - const asyncHtml = await readOutput('async-generated/index.html') - assert.match(asyncHtml, /async generated page/, 'async iterable pages files are supported') - - const summary = JSON.parse(await readOutput('summary.json')) - assert.equal(summary.sourcePageCount, 7, 'template data includes the subscribed source page count') - assert.equal(summary.blogPostCount, 3, 'template data includes the subscribed blog collection') - }) - - test('supports static object, static array, and async function exports', async () => { - await withTempFixture({ - 'root.layout.js': minimalRootLayout, - 'global.vars.js': minimalGlobalVars, - 'single.pages.js': `export default { - outputName: 'single/index.html', - children: '

single static page

', -} -`, - 'multiple.pages.js': `export default [ - { outputName: 'multiple/one.html', children: '

first static page

' }, - { outputName: 'multiple/two.html', children: '

second static page

' }, -] -`, - 'async.pages.js': `export default async function () { - return { outputName: 'async/index.html', children: '

async function page

' } -} -`, - }, async ({ src, dest }) => { - const domstack = new DomStack(src, dest) - await domstack.build() - - assert.match(await readFile(join(dest, 'single/index.html'), 'utf8'), /single static page/) - assert.match(await readFile(join(dest, 'multiple/one.html'), 'utf8'), /first static page/) - assert.match(await readFile(join(dest, 'multiple/two.html'), 'utf8'), /second static page/) - assert.match(await readFile(join(dest, 'async/index.html'), 'utf8'), /async function page/) - }) - }) - - test('builds generated drafts when buildDrafts is enabled', async () => { - await withTempFixture({ - 'root.layout.js': minimalRootLayout, - 'global.vars.js': minimalGlobalVars, - 'draft.pages.js': `export default { - outputName: 'draft/index.html', - draft: true, - children: '

generated draft

', -} -`, - }, async ({ src, dest }) => { - const domstack = new DomStack(src, dest, { buildDrafts: true }) - await domstack.build() - - assert.match(await readFile(join(dest, 'draft/index.html'), 'utf8'), /generated draft/) - }) - }) + const redirectCases = [ + { from: 'old-url', to: '/new-url/', destination: 'new-url/index.html', heading: 'New URL' }, + { from: 'legacy-url', to: '/new-url/', destination: 'new-url/index.html', heading: 'New URL' }, + { from: 'docs/old-guide', to: '/guides/current/', destination: 'guides/current/index.html', heading: 'Current Guide' }, + { from: 'company', to: '/about/', destination: 'about/index.html', heading: 'About' }, + ] - test('returns copyable generated vars derived from serializable global data', async () => { - await withTempFixture({ - 'root.layout.js': minimalRootLayout, - 'global.vars.js': minimalGlobalVars, - 'global.data.js': `export default function globalData ({ pages }) { - return { posts: pages.map(page => ({ title: page.vars.title, url: page.pageInfo.url })) } -} -`, - 'README.md': '# Concrete page\n', - 'indexes.pages.js': `export const dataDeps = ['posts'] -export default function indexesPages ({ data }) { - return { - outputName: 'generated-index/index.html', - vars: { - title: 'Generated index', - posts: data.posts, - }, - children: ({ vars }) => \`

\${vars.posts.length}

\`, + for (const { from, to, destination, heading } of redirectCases) { + const redirectHtml = await readOutput(`${from}/index.html`) + assert.match(redirectHtml, new RegExp(``), `${from} renders through the redirect layout`) + assert.match(redirectHtml, new RegExp(`${to}`), `${from} links to its canonical destination`) + const destinationHtml = await readOutput(destination) + assert.match(destinationHtml, new RegExp(`]*>${heading}

`), `${to} is backed by a concrete page`) + assert.match(destinationHtml, //, `${to} receives its layout's subscribed global data`) } -} -`, - }, async ({ src, dest }) => { - const domstack = new DomStack(src, dest) - const results = await domstack.build() - const output = await readFile(join(dest, 'generated-index/index.html'), 'utf8') - const outputRecord = results.pageBuildResults?.outputs.find(output => output.outputRelname === 'generated-index/index.html') - - assert.match(output, /

1<\/p>/, 'generated page renders with declared global data') - assert.ok(outputRecord, 'generated page emits an output record') - assert.equal(outputRecord.pageVars?.['title'], 'Generated index', 'copyable page vars are returned') - assert.equal(/** @type {unknown[]} */ (outputRecord.pageVars?.['posts']).length, 1, 'serializable derived data remains available in generated page vars') - }) - }) - - test('returns generated render errors without sending render state', async () => { - await withTempFixture({ - 'root.layout.js': minimalRootLayout, - 'global.vars.js': minimalGlobalVars, - 'broken.pages.js': `export default { - outputName: 'broken/index.html', - children () { - throw new Error('generated boom', { cause: () => {} }) - }, -} -`, - }, async ({ src, dest }) => { - const domstack = new DomStack(src, dest) - await assert.rejects( - () => domstack.build(), - error => { - const aggregate = /** @type {Error & { errors?: Array }} */ (error) - const generatedError = aggregate.errors?.find(error => error.page?.generated) - - assert.ok(generatedError, 'build includes the generated page error') - assert.match(generatedError.message, /page: "broken"/) - assert.equal(generatedError.page?.generated?.pagesFile?.pagesFile?.relname, 'broken.pages.js') - assert.notEqual(generatedError.name, 'DataCloneError') - assert.equal(/** @type {{ message?: string } | undefined} */ (generatedError.cause)?.message, 'generated boom') - return true - } - ) - }) - }) - - test('returns pages-file context when a generated-pages function throws', async () => { - await withTempFixture({ - 'root.layout.js': minimalRootLayout, - 'global.vars.js': minimalGlobalVars, - 'broken.pages.js': `export default function () { - throw new Error('pages factory boom', { cause: () => {} }) -} -`, - }, async ({ src, dest }) => { - const domstack = new DomStack(src, dest) - await assert.rejects( - () => domstack.build(), - error => { - const generatedError = firstGeneratedPagesError(error) - - assert.match(generatedError.message, /pages factory boom/) - assert.match(generatedError.message, /pages file: "broken\.pages\.js"/) - assert.equal(generatedError.pagesFile?.pagesFile.relname, 'broken.pages.js') - assert.notEqual(generatedError.name, 'DataCloneError') - assert.equal(/** @type {{ message?: string } | undefined} */ (generatedError.cause)?.message, 'pages factory boom') - return true - } - ) - }) - }) - - test('includes generated pages in the domstack manifest as page entries', async () => { - await withTempFixture({ - 'root.layout.js': minimalRootLayout, - 'global.vars.js': minimalGlobalVars, - 'archive.pages.js': `export default { - outputName: 'archive/index.html', - vars: { - layout: 'root', - title: 'Archive', - archiveYear: 2024, - manifestRole: 'generated-index', - }, - children: '

Generated archive

', -} -`, - }, async ({ src, dest }) => { - const domstack = new DomStack(src, dest, { - domstackManifest: { - manifestVars: ['archiveYear'], - }, - }) - const results = await domstack.build() - const entry = results.domstackManifest?.entries.find(entry => entry.outputRelname === 'archive/index.html') - const outputRecord = results.pageBuildResults?.outputs.find(output => output.outputRelname === 'archive/index.html') - - assert.ok(entry, 'generated page is present in the domstack manifest') - assert.equal(outputRecord?.pageVars?.['archiveYear'], 2024, 'copyable page vars are returned from the worker') - assert.equal(entry.kind, 'page') - assert.equal(entry.url, '/archive/') - assert.equal(entry.sourceRelname, 'archive.pages.js#0') - assert.equal(entry.pagePath, 'archive') - assert.equal(entry.pageUrl, '/archive/') - assert.deepEqual(entry.page, { - path: 'archive', - url: '/archive/', - }) - assert.equal(entry.role, 'generated-index', 'generated page vars can override the manifest role') - assert.deepEqual(entry.manifestVars, { - archiveYear: 2024, - }, 'selected generated page vars are exposed in the manifest') - assert.match(entry.revision ?? '', /^[a-f0-9]{64}$/, 'generated page content is revisioned') - }) - }) - - test('supports function manifest transforms with generated vars', async () => { - await withTempFixture({ - 'root.layout.js': minimalRootLayout, - 'global.vars.js': minimalGlobalVars, - 'archive.pages.js': `export default { - outputName: 'archive/index.html', - vars: { - title: 'Archive', - archive: { year: 2024 }, - }, - children ({ vars }) { - vars.archive.year = 2025 - return '

Generated archive

' - }, -} -`, - }, async ({ src, dest }) => { - const domstack = new DomStack(src, dest, { - domstackManifest: { - manifestVars: ({ vars }) => { - const archive = /** @type {{ year: number } | undefined} */ (vars['archive']) - return archive ? { archiveLabel: String(archive.year) } : {} - }, - }, - }) - const results = await domstack.build() - const entry = results.domstackManifest?.entries.find(entry => entry.outputRelname === 'archive/index.html') - const outputRecord = results.pageBuildResults?.outputs.find(output => output.outputRelname === 'archive/index.html') - - assert.deepEqual(entry?.manifestVars, { archiveLabel: '2025' }) - assert.deepEqual(outputRecord?.pageVars?.['archive'], { year: 2025 }, 'function transforms receive complete post-render page vars') - }) - }) - - test('throws a conflict error for generated pages that collide with concrete pages', async () => { - await withTempFixture({ - 'root.layout.js': minimalRootLayout, - 'global.vars.js': minimalGlobalVars, - 'README.md': '# Concrete root page\n', - 'conflict.pages.js': `export default function () { - return { outputName: 'index.html', vars: { title: 'Generated root' }, children: 'generated' } -} -`, - }, async ({ src, dest }) => { - const domstack = new DomStack(src, dest) - await assert.rejects( - () => domstack.build(), - error => { - const generatedError = firstGeneratedPagesError(error) - - assert.match(generatedError.message, /Output path conflict/) - assert.match(generatedError.message, /pages file: "conflict\.pages\.js"/) - assert.equal(generatedError.code, 'DOM_STACK_ERROR_OUTPUT_CONFLICT') - assert.deepEqual(generatedError.conflict, { - outputPath: 'index.html', - a: { type: 'page', path: 'README.md' }, - b: { type: 'page', path: 'conflict.pages.js#0' }, - }) - assert.equal(generatedError.pagesFile?.pagesFile.relname, 'conflict.pages.js') - return true - } - ) - }) - }) - - test('throws a conflict error with both generated page sources', async () => { - await withTempFixture({ - 'root.layout.js': minimalRootLayout, - 'global.vars.js': minimalGlobalVars, - 'first.pages.js': "export default { outputName: 'shared/index.html' }\n", - 'second.pages.js': "export default { outputName: 'shared/index.html' }\n", - }, async ({ src, dest }) => { - const domstack = new DomStack(src, dest) - await assert.rejects( - () => domstack.build(), - error => { - const generatedError = firstGeneratedPagesError(error) - const conflictingSources = [ - generatedError.conflict?.a.path, - generatedError.conflict?.b.path, - ].sort() - - assert.equal(generatedError.code, 'DOM_STACK_ERROR_OUTPUT_CONFLICT') - assert.equal(generatedError.conflict?.outputPath, 'shared/index.html') - assert.deepEqual(conflictingSources, ['first.pages.js#0', 'second.pages.js#0']) - assert.equal(`${generatedError.pagesFile?.pagesFile.relname}#0`, generatedError.conflict?.b.path) - return true - } - ) - }) - }) - - test('rejects invalid definitions returned in arrays', async () => { - await withTempFixture({ - 'root.layout.js': minimalRootLayout, - 'global.vars.js': minimalGlobalVars, - 'invalid.pages.js': 'export default [{ outputName: "valid/index.html", children: "Published before validation failure" }, 42]\n', - }, async ({ src, dest }) => { - const domstack = new DomStack(src, dest) - await assert.rejects( - () => domstack.build(), - error => { - const generatedError = firstGeneratedPagesError(error) - - assert.match(generatedError.message, /Generated page definition must be an object/) - assert.match(generatedError.message, /pages file: "invalid\.pages\.js"/) - assert.equal(generatedError.name, 'TypeError') - assert.equal(generatedError.pagesFile?.pagesFile.relname, 'invalid.pages.js') - return true - } - ) - assert.match(await readFile(join(dest, 'valid/index.html'), 'utf8'), /Published before validation failure/) - }) - }) - - test('throws a clear error for invalid generated page paths', async () => { - await withTempFixture({ - 'root.layout.js': minimalRootLayout, - 'global.vars.js': minimalGlobalVars, - 'invalid.pages.js': `export default function () { - return { outputName: '../outside/index.html', vars: { title: 'Invalid' }, children: 'invalid' } -} -`, - }, async ({ src, dest }) => { - const domstack = new DomStack(src, dest) - await assert.rejects( - () => domstack.build(), - error => { - const generatedError = firstGeneratedPagesError(error) - - assert.match(generatedError.message, /must not contain "\.\." segments/) - assert.match(generatedError.message, /pages file: "invalid\.pages\.js"/) - assert.equal(generatedError.pagesFile?.pagesFile.relname, 'invalid.pages.js') - return true - } - ) - }) - }) - - test('rejects generated output names that do not name a file', async () => { - for (const outputName of ['.', './', 'nested/']) { - await withTempFixture({ - 'root.layout.js': minimalRootLayout, - 'global.vars.js': minimalGlobalVars, - 'invalid.pages.js': `export default { outputName: ${JSON.stringify(outputName)}, children: 'invalid' }\n`, - }, async ({ src, dest }) => { - const domstack = new DomStack(src, dest) - await assert.rejects( - () => domstack.build(), - error => { - const generatedError = firstGeneratedPagesError(error) - - assert.match(generatedError.message, /must not be empty|must name a file/) - assert.match(generatedError.message, /pages file: "invalid\.pages\.js"/) - return true - } - ) - }) - } - }) - - test('rebuilds declared subscribers when a global-data key changes', { timeout: 15_000 }, async t => { - await withTempFixture({ - 'root.layout.js': minimalRootLayout, - 'global.vars.js': minimalGlobalVars, - 'page.js': 'export default ({ vars }) => vars.title\n', - 'page.vars.js': "export default { title: 'First title' }\n", - 'global.data.js': `export default function ({ pages }) { - return { sourceTitle: pages[0].vars.title } -} -`, - 'watch-indexes.pages.js': `export const dataDeps = ['sourceTitle'] -export default function ({ data }) { - const title = data.sourceTitle - const outputName = title === 'First title' - ? 'watch-first/index.html' - : 'watch-updated/index.html' - return { outputName, vars: { title }, children: () => title } -} -`, - 'summary.template.js': `export const dataDeps = ['sourceTitle'] -export default function ({ data }) { - const outputName = data.sourceTitle === 'First title' - ? 'watch-first/index.html' - : 'watch-updated/index.html' - return outputName + ':' + data.sourceTitle -} -`, - 'unrelated.pages.js': `import { appendFileSync } from 'node:fs' - -export default function unrelatedPages () { - appendFileSync(new URL('../unrelated-factory-runs', import.meta.url), 'run\\n') - return { outputName: 'unrelated/index.html', children: 'Unrelated' } -} -`, - }, async ({ src, dest }) => { - const domstack = new DomStack(src, dest) - const factoryRuns = join(src, '../unrelated-factory-runs') - try { - await startWatch(t, domstack, src, { - serve: false, - async onInitialBuild () { - assert.equal(await readFile(factoryRuns, 'utf8'), 'run\n', 'the unrelated factory ran during the initial build') - }, - }) - const initialOutputPath = join(dest, 'watch-first/index.html') - const updatedOutputPath = join(dest, 'watch-updated/index.html') - assert.match(await readFile(initialOutputPath, 'utf8'), /First title/) - assert.equal(await readFile(join(dest, 'summary'), 'utf8'), 'watch-first/index.html:First title') - const startupFactoryRuns = await readFile(factoryRuns, 'utf8') - await editAndWait(domstack, join(src, 'page.vars.js'), () => writeFile(join(src, 'page.vars.js'), "export default { title: 'Updated title' }\n")) - - const updatedOutput = await readFile(updatedOutputPath, 'utf8') - assert.match(updatedOutput, /Updated title/) - assert.doesNotMatch(updatedOutput, /First title/) - assert.equal(await readFile(join(dest, 'summary'), 'utf8'), 'watch-updated/index.html:Updated title') - await assert.rejects(() => stat(initialOutputPath), { code: 'ENOENT' }, 'obsolete dependency-driven output is removed') - assert.equal(await readFile(factoryRuns, 'utf8'), startupFactoryRuns, 'an unrelated factory is not executed during a subscriber rebuild') - } finally { - if (domstack.watching) await domstack.stopWatching() - } - }) - }) - - test('rebuilds generated pages when Markdown settings change in watch mode', { timeout: 15_000 }, async t => { - const markdownSettings = (/** @type {string} */ version) => `export default function (md) { - md.renderer.rules.paragraph_open = () => '

' - return md -} -` - - await withTempFixture({ - 'root.layout.js': minimalRootLayout, - 'global.vars.js': minimalGlobalVars, - 'post.md': 'Rendered post\n', - 'markdown-it.settings.js': markdownSettings('first'), - 'global.data.js': `export default async function ({ pages }) { - const post = pages.find(page => page.pageInfo.pageFile.relname === 'post.md') - if (!post) throw new Error('Missing Markdown post') - return { renderedPost: await post.renderInnerPage() } -} -`, - 'markdown-summary.pages.js': `export const dataDeps = ['renderedPost'] -export default function ({ data }) { - return { outputName: 'summary/index.html', children: data.renderedPost } -} -`, - }, async ({ src, dest }) => { - const domstack = new DomStack(src, dest) - const outputPath = join(dest, 'summary/index.html') - - try { - await startWatch(t, domstack, src) - assert.match(await readFile(outputPath, 'utf8'), /data-version="first"/) - - await editAndWait(domstack, join(src, 'markdown-it.settings.js'), () => writeFile(join(src, 'markdown-it.settings.js'), markdownSettings('second'))) - - const updatedOutput = await readFile(outputPath, 'utf8') - assert.match(updatedOutput, /data-version="second"/) - assert.doesNotMatch(updatedOutput, /data-version="first"/) - } finally { - if (domstack.watching) await domstack.stopWatching() - } - }) - }) - - test('rebuilds generated pages when layout assets are added or removed in watch mode', { timeout: 25_000 }, async t => { - await withTempFixture({ - 'root.layout.js': assetAwareRootLayout, - 'global.vars.js': minimalGlobalVars, - 'page.js': "export default () => 'Regular page'\n", - 'layout-assets.pages.js': `export default { - outputName: 'generated/index.html', - children: 'Generated page', -} -`, - }, async ({ src, dest }) => { - const domstack = new DomStack(src, dest) - const regularOutputPath = join(dest, 'index.html') - const generatedOutputPath = join(dest, 'generated/index.html') - - /** - * @param {string} assetName - * @param {boolean} expected - */ - const assertAssetReference = async (assetName, expected) => { - const [regularHtml, generatedHtml] = await Promise.all([ - readFile(regularOutputPath, 'utf8'), - readFile(generatedOutputPath, 'utf8'), - ]) - assert.equal(regularHtml.includes(assetName), expected, `regular page ${expected ? 'includes' : 'omits'} ${assetName}`) - assert.equal(generatedHtml.includes(assetName), expected, `generated page ${expected ? 'includes' : 'omits'} ${assetName}`) - } - - try { - await startWatch(t, domstack, src) - await assertAssetReference('root.layout.css', false) - await assertAssetReference('root.layout.client.js', false) - - await editAndWait(domstack, join(src, 'root.layout.css'), () => writeFile(join(src, 'root.layout.css'), 'body { color: red }\n')) - await assertAssetReference('root.layout.css', true) - - await editAndWait(domstack, join(src, 'root.layout.css'), () => rm(join(src, 'root.layout.css'))) - await assertAssetReference('root.layout.css', false) - - await editAndWait(domstack, join(src, 'root.layout.client.js'), () => writeFile(join(src, 'root.layout.client.js'), 'globalThis.layoutClientLoaded = true\n')) - await assertAssetReference('root.layout.client.js', true) - - await editAndWait(domstack, join(src, 'root.layout.client.js'), () => rm(join(src, 'root.layout.client.js'))) - await assertAssetReference('root.layout.client.js', false) - } finally { - if (domstack.watching) await domstack.stopWatching() - } - }) - }) - - test('removes obsolete regular and generated page outputs in watch mode', { timeout: 20_000 }, async t => { - await withTempFixture({ - 'root.layout.js': minimalRootLayout, - 'global.vars.js': minimalGlobalVars, - 'regular/page.html': '

Regular page

', - 'changing.pages.js': `export default [ - { outputName: 'old/index.html', children: 'Old generated page' }, - { outputName: 'removed/index.html', children: 'Removed generated page' }, - { outputName: 'drafted/index.html', children: 'Published generated page' }, -] -`, - }, async ({ src, dest }) => { - const domstack = new DomStack(src, dest) - try { - await startWatch(t, domstack, src) - const oldOutputPath = join(dest, 'old/index.html') - const newOutputPath = join(dest, 'new/index.html') - const removedOutputPath = join(dest, 'removed/index.html') - const draftedOutputPath = join(dest, 'drafted/index.html') - const regularOutputPath = join(dest, 'regular/index.html') - - assert.match(await readFile(oldOutputPath, 'utf8'), /Old generated page/) - assert.match(await readFile(removedOutputPath, 'utf8'), /Removed generated page/) - assert.match(await readFile(draftedOutputPath, 'utf8'), /Published generated page/) - assert.match(await readFile(regularOutputPath, 'utf8'), /Regular page/) - - await editAndWait(domstack, join(src, 'changing.pages.js'), () => writeFile(join(src, 'changing.pages.js'), `export default [ - { outputName: 'new/index.html', children: 'Renamed generated page' }, - { outputName: 'drafted/index.html', children: 'Draft generated page', draft: true }, -] -`)) - - assert.match(await readFile(newOutputPath, 'utf8'), /Renamed generated page/) - await assert.rejects(() => readFile(oldOutputPath, 'utf8'), { code: 'ENOENT' }) - await assert.rejects(() => readFile(removedOutputPath, 'utf8'), { code: 'ENOENT' }) - await assert.rejects(() => readFile(draftedOutputPath, 'utf8'), { code: 'ENOENT' }) - - await editAndWait(domstack, join(src, 'regular/page.html'), () => rm(join(src, 'regular/page.html'))) - await assert.rejects(() => readFile(regularOutputPath, 'utf8'), { code: 'ENOENT' }) - - await editAndWait(domstack, join(src, 'changing.pages.js'), () => rm(join(src, 'changing.pages.js'))) - await assert.rejects(() => readFile(newOutputPath, 'utf8'), { code: 'ENOENT' }) - } finally { - if (domstack.watching) await domstack.stopWatching() - } - }) - }) + const blog2024IndexDoc = cheerio.load(await readOutput('blog/2024/index.html')) + const blog2024Links = blog2024IndexDoc('.blog-entry-link').toArray().map(link => ({ + href: blog2024IndexDoc(link).attr('href'), + title: blog2024IndexDoc(link).text().trim(), + })) + const blog2024Dates = blog2024IndexDoc('.blog-entry-date').toArray().map(time => blog2024IndexDoc(time).text().trim()) + assert.deepEqual(blog2024Links, [ + { href: '/blog/2024/post-two/', title: 'Post Two' }, + { href: '/blog/2024/post-one/', title: 'Post One' }, + ], 'generated yearly indexes link concrete posts newest-first') + assert.deepEqual(blog2024Dates, ['2024-06-15', '2024-01-02'], 'generated yearly indexes render publication dates') + + const blog2023IndexDoc = cheerio.load(await readOutput('blog/2023/index.html')) + assert.deepEqual(blog2023IndexDoc('.blog-entry-link').toArray().map(link => ({ + href: blog2023IndexDoc(link).attr('href'), + title: blog2023IndexDoc(link).text().trim(), + })), [ + { href: '/blog/2023/older-post/', title: 'Older Post' }, + ], 'a generated index is created for each year with posts') + + const introspectionHtml = await readOutput('generated-introspection/index.html') + const introspectionDoc = cheerio.load(introspectionHtml) + assert.equal(introspectionDoc('#has-pages').text(), 'false', 'pages files do not receive the raw page collection') + assert.equal(introspectionDoc('#has-site-data').text(), 'false', 'pages files do not receive the discovery registry') + assert.equal(introspectionDoc('meta[name="source-page-count"]').attr('content'), '7', 'global.data sees source-backed pages before pages files run') + + const stylesheetHrefs = Array.from(introspectionDoc('link[rel="stylesheet"]')).map(link => introspectionDoc(link).attr('href') ?? '') + assert.ok(stylesheetHrefs.some(href => href.startsWith('/global-') && href.endsWith('.css')), 'generated page includes global stylesheet') + assert.ok(stylesheetHrefs.some(href => href.startsWith('/root.layout-') && href.endsWith('.css')), 'generated page includes layout stylesheet') + assert.ok(!stylesheetHrefs.some(href => href.startsWith('./style-')), 'generated page does not include page-local stylesheet') + + const scriptSrcs = Array.from(introspectionDoc('script[type="module"]')).map(script => introspectionDoc(script).attr('src') ?? '') + assert.ok(scriptSrcs.some(src => src.startsWith('/global.client-') && src.endsWith('.js')), 'generated page includes global client') + assert.ok(scriptSrcs.some(src => src.startsWith('/root.layout.client-') && src.endsWith('.js')), 'generated page includes layout client') + assert.ok(!scriptSrcs.some(src => src.startsWith('./client-')), 'generated page does not include page-local client') + + const asyncHtml = await readOutput('async-generated/index.html') + assert.match(asyncHtml, /async generated page/, 'async iterable pages files are supported') + + const summary = JSON.parse(await readOutput('summary.json')) + assert.equal(summary.sourcePageCount, 7, 'template data includes the subscribed source page count') + assert.equal(summary.blogPostCount, 3, 'template data includes the subscribed blog collection') }) diff --git a/test-cases/generated-pages/redirects.test.js b/test-cases/generated-pages/redirects.test.js new file mode 100644 index 00000000..554c6d62 --- /dev/null +++ b/test-cases/generated-pages/redirects.test.js @@ -0,0 +1,55 @@ +import { test } from 'node:test' +import assert from 'node:assert/strict' +import globalData from './src/global.data.js' + +/** + * @param {any[]} pages + */ +function collectRedirects (pages) { + const data = globalData(/** @type {any} */ ({ pages })) + if (data instanceof Promise) throw new TypeError('Expected synchronous global data') + return data.redirects +} + +test('validates page-owned redirect metadata with destination context', () => { + /** + * @param {string} relname + * @param {string} url + * @param {unknown} redirectFrom + */ + const page = (relname, url, redirectFrom) => /** @type {any} */ ({ + vars: { redirectFrom }, + pageInfo: { path: relname.replace(/\/README\.md$/, ''), url, pageFile: { relname } }, + }) + + assert.deepEqual(collectRedirects([ + page('current/README.md', '/current/', ['/old/', '/older/']), + ]), [ + { from: '/old/', to: '/current/' }, + { from: '/older/', to: '/current/' }, + ]) + + assert.throws( + () => collectRedirects([page('string/README.md', '/string/', '/old/')]), + /redirectFrom on "string\/README\.md" must be an array/ + ) + assert.throws( + () => collectRedirects([page('number/README.md', '/number/', [42])]), + /redirectFrom entries on "number\/README\.md" must be strings/ + ) + + for (const redirectFrom of ['https://example.com/old/', '//example.com/old/', '/old/?draft=true', '/../escape/']) { + assert.throws( + () => collectRedirects([page('invalid/README.md', '/invalid/', [redirectFrom])]), + error => error instanceof Error && error.message.includes(redirectFrom) && error.message.includes('invalid/README.md') + ) + } + + assert.throws( + () => collectRedirects([ + page('first/README.md', '/first/', ['/shared-old/']), + page('second/README.md', '/second/', ['/shared-old/']), + ]), + /redirectFrom "\/shared-old\/" is declared by both "first\/README\.md" and "second\/README\.md"/ + ) +}) diff --git a/test-cases/nested-layouts/index.test.js b/test-cases/nested-layouts/index.test.js index bba978e3..98e82868 100644 --- a/test-cases/nested-layouts/index.test.js +++ b/test-cases/nested-layouts/index.test.js @@ -1,88 +1,12 @@ -/** - * @import { TestContext } from 'node:test' - * @import { Logger } from 'pino' - */ 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' -import { editAndWait, startWatch, waitForRebuild } from '../../lib/watch/test-helpers.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 @param {Logger} [logger] */ -async function setup (t, logger = pino({ level: 'silent' })) { - 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 }) - 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), - } -} +import { testBuild } from '../../index.js' +import { join } from 'node:path' 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() + const build = await testBuild(join(import.meta.dirname, 'src')) + t.after(() => build.cleanup()) + const { results, readOutput: read } = 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)) @@ -98,294 +22,3 @@ test('nested layouts render source and generated pages, cascade vars and preserv } assert.match(await read('plain/index.html'), /