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/CONTRIBUTING.md b/CONTRIBUTING.md index ae648d1e..a9c65ec5 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -45,4 +45,3 @@ If for some reason that isn't working or a local release is preferred, follow th Generation belongs in `version`, not `preversion` (which runs before the version update) or `postversion` (which runs after the commit and tag). The release workflow's pre-version reset/clean is safe because the initial generated JavaScript is tracked and the version hook rebuilds it before staging. -Run `npm run test:version-build` to verify generation, staging, tagging, and cleanup in a disposable repository without versioning this checkout. diff --git a/agents.md b/agents.md index 604a0ef3..432763d2 100644 --- a/agents.md +++ b/agents.md @@ -11,5 +11,11 @@ - After running a type build, clean up the generated build files and do not leave them sitting around. - Use the cleanup scripts in `package.json` for generated type build files. - The generated `lib/defaults/default.root.layout.js` is versioned runtime code, not temporary declaration output; regenerate it with `npm run build:defaults` after editing its TypeScript source and never remove it during cleanup. +- Keep `test-cases/` focused on checked-in example sites paired with acceptance tests that build through the public API and assert on outputs or expected build errors. +- Example sites should be understandable by browsing their source files, not reconstructed from JavaScript strings embedded in tests. +- Put detailed behavioral and regression tests beside the subsystem they exercise under `lib/`, even when they build temporary sites or run real watchers. +- Keep public facade tests at the repository root and public TypeScript contract tests in `type-tests/`. +- Behavioral tests may copy example sites into temporary directories, but must not mutate checked-in fixtures or depend on another test's output directory. +- Keep test-only files out of published packages and declaration builds; see `test-cases/README.md` for the test organization guidelines. - For formatting-only ESLint failures, use `npx eslint --fix` for a quick targeted fix before rerunning lint. - When handling PR review comments, validate that each comment is correct before making changes; maintainer comments are almost always valid, but review bot comments may be wrong, and after addressing a comment, always reply with what was done. 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/docs/implementation/README.md b/docs/implementation/README.md index 6514d50e..1b1c0ecc 100644 --- a/docs/implementation/README.md +++ b/docs/implementation/README.md @@ -167,6 +167,7 @@ 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. + 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). @@ -193,7 +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. -`DomStack` owns the watch session, serializes events, executes plans, and releases its watchers, esbuild context, and server on shutdown. +DOMStack manages the watch session, queues changes, rebuilds affected outputs, and shuts down the watchers and server when stopped.
 flowchart TD
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/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/index.js b/index.js
index 31b284ba..d1536d62 100644
--- a/index.js
+++ b/index.js
@@ -1,59 +1,19 @@
 /// 
 
 /**
- * @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 { 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'
-// @ts-expect-error
-import makeArray from 'make-array'
-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 { PageData } from './lib/build-pages/page/page-data.js'
 export {
   DOMSTACK_MANIFEST_SCHEMA_ID,
   DOMSTACK_MANIFEST_SCHEMA_PATH,
@@ -79,61 +39,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.
@@ -152,7 +58,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 ?? []
@@ -166,11 +72,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 () {
@@ -188,742 +97,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()
   }
 
   /**
@@ -931,83 +116,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()
   }
 }
 
@@ -1031,16 +140,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
@@ -1058,100 +157,7 @@ function normalizeDomStackOpts (opts, dest) {
       ...DEFAULT_IGNORES,
       basename(dest),
       ...copyDirs.map(dir => basename(dir)),
-      ...makeArray(buildOpts.ignore),
+      ...[buildOpts.ignore ?? []].flat(),
     ],
   }
 }
-
-/**
- * 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/test-cases/constructor-copy-paths/index.test.js b/index.test.js
similarity index 56%
rename from test-cases/constructor-copy-paths/index.test.js
rename to index.test.js
index cea92167..5703b221 100644
--- a/test-cases/constructor-copy-paths/index.test.js
+++ b/index.test.js
@@ -1,12 +1,33 @@
+/** @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')
 
+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, {
diff --git a/lib/build-esbuild/index.js b/lib/build-esbuild/index.js
index 7dacd392..db1c69c1 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,
@@ -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 }>}
  */
@@ -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-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/build.js b/lib/build-pages/build.js new file mode 100644 index 00000000..e73f4c75 --- /dev/null +++ b/lib/build-pages/build.js @@ -0,0 +1,349 @@ +/** + * @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' + * @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 } 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 './global-data/watch-dependencies.js' +import { outputWarnings } from '../helpers/output-warnings.js' +import { createGlobalDataState } from './global-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, + }) + } + + const pagesToWrite = pageFilterSet + ? concretePages.filter(page => pageFilterSet.has(page.pageInfo.pageFile.filepath)) + : concretePages + + /** @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 + * @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 { + const buildResult = await pageWriter({ + dest, + page, + outputCache, + }) + + 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) { + 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 + } + } + + // 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/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/index.js b/lib/build-pages/generated-pages/index.js new file mode 100644 index 00000000..d4f3eacd --- /dev/null +++ b/lib/build-pages/generated-pages/index.js @@ -0,0 +1,201 @@ +/** + * @import { SiteData } from '../../builder.js' + * @import { PageInfo, PagesFileInfo } from '../../identify-pages.js' + * @import { GeneratedPageDefinition } from '../index.js' + * @import { WatchDependencyTracker } from '../global-data/watch-dependencies.js' + */ + +import { basename, dirname, isAbsolute, join, normalize, resolve } from 'path' +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 '../global-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 }) { + 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() + + 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(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 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/generated-pages/index.test.js b/lib/build-pages/generated-pages/index.test.js new file mode 100644 index 00000000..d39f4923 --- /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 '../global-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/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 9646f42a..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 '../watch/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/data-deps.js b/lib/build-pages/global-data/data-deps.js similarity index 97% rename from lib/build-pages/data-deps.js rename to lib/build-pages/global-data/data-deps.js index d33802d1..1bc1fa91 100644 --- a/lib/build-pages/data-deps.js +++ b/lib/build-pages/global-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/global-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/global-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/global-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/global-data/global-data-state.js similarity index 97% rename from lib/build-pages/global-data-state.js rename to lib/build-pages/global-data/global-data-state.js index 0364daea..2359d870 100644 --- a/lib/build-pages/global-data-state.js +++ b/lib/build-pages/global-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/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/global-data/global-data-state.test.js similarity index 98% rename from lib/build-pages/global-data-state.test.js rename to lib/build-pages/global-data/global-data-state.test.js index 9c12cce7..dd3805bf 100644 --- a/lib/build-pages/global-data-state.test.js +++ b/lib/build-pages/global-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-global-data.js' /** @param {TestContext} t @param {string} producer */ async function fixture (t, producer) { 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..1d6fefc9 --- /dev/null +++ b/lib/build-pages/global-data/page-subscriptions.js @@ -0,0 +1,69 @@ +/** + * @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) { + const keys = new Set(companionKeys) + for (const key of builderKeys) keys.add(key) + this.#pageKeys = [...keys].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. */ + collectDependencies () { + 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..0c0b9836 --- /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.collectDependencies(), ['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.collectDependencies(), pageInfo), /Global data is not available/) + subscriptions.bind({ navigation: [] }, pageInfo) + assert.doesNotThrow(() => subscriptions.assertReady(subscriptions.collectDependencies(), 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/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/watch-dependencies.js b/lib/build-pages/global-data/watch-dependencies.js similarity index 83% rename from lib/build-pages/watch-dependencies.js rename to lib/build-pages/global-data/watch-dependencies.js index 91c5eb29..85ea3047 100644 --- a/lib/build-pages/watch-dependencies.js +++ b/lib/build-pages/global-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 @@ -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/watch-dependencies.test.js b/lib/build-pages/global-data/watch-dependencies.test.js similarity index 55% rename from lib/build-pages/watch-dependencies.test.js rename to lib/build-pages/global-data/watch-dependencies.test.js index 3f3547af..f6514909 100644 --- a/lib/build-pages/watch-dependencies.test.js +++ b/lib/build-pages/global-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/index.js b/lib/build-pages/index.js index 29351b90..243ca039 100644 --- a/lib/build-pages/index.js +++ b/lib/build-pages/index.js @@ -1,36 +1,20 @@ /** - * @import { BuilderOptions, PageFunction } from './page-builders/page-writer.js' - * @import { TemplateReport } from './page-builders/template-builder.js' - * @import { BuildStep, SiteData, DomStackOpts } from '../builder.js' - * @import { PageInfo, TemplateInfo, PagesFileInfo } from '../identify-pages.js' - * @import { ResolvedLayout } from './page-data.js' + * @import { PageFunction } from './outputs/page-writer.js' + * @import { TemplateReport } from './templates/template-builder.js' + * @import { BuildStep, DomStackOpts } from '../builder.js' + * @import { PagesFileInfo } from '../identify-pages.js' + * @import { PageData } from './page/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 './global-data/watch-dependencies.js' + * @import { PageOutputCache } from './outputs/page-output-writer.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 { 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' - -const MAX_CONCURRENCY = Math.min(cpus().length, 24) - -const __dirname = import.meta.dirname +export { buildPages } from './worker/index.js' +export { buildPagesDirect } from './build.js' +export { serializeBuildError } from './worker/protocol.js' +export { pageBuilders } from './page-builders/index.js' /** * @typedef {object} PageReport @@ -91,19 +75,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,666 +142,6 @@ 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} - */ -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. - * - * @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}`)) } - }) - }) -} - -/** - * 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 + * @typedef {ProtocolWorkerErrorData} WorkerErrorData + * @typedef {ProtocolWorkerBuildStepResult} WorkerBuildStepResult */ -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/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/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..eb56dd6f --- /dev/null +++ b/lib/build-pages/layouts/resolve-layout.js @@ -0,0 +1,120 @@ +/** + * @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: 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) + 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. + */ + +/** + * 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 {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. + * @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/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/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-builders/page-output-writer.js b/lib/build-pages/outputs/page-output-writer.js similarity index 99% rename from lib/build-pages/page-builders/page-output-writer.js rename to lib/build-pages/outputs/page-output-writer.js index d650dfaa..7a2a1927 100644 --- a/lib/build-pages/page-builders/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/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 91% rename from lib/build-pages/page-outputs.js rename to lib/build-pages/outputs/page-outputs.js index f8f8668f..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,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/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 88% rename from lib/build-pages/page-builders/page-writer.js rename to lib/build-pages/outputs/page-writer.js index 99c8f1b7..f143d1a8 100644 --- a/lib/build-pages/page-builders/page-writer.js +++ b/lib/build-pages/outputs/page-writer.js @@ -1,8 +1,8 @@ /** * @import { PageInfo } from '../../identify-pages.js' - * @import { PageData as PageDataClass } from '../page-data.js' + * @import { PageData } from '../page/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' */ @@ -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/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/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/page-builders/html/index.js b/lib/build-pages/page-builders/html/index.js index 2cc9355e..834c5948 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' @@ -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/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-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/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/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-data.js b/lib/build-pages/page-data.js deleted file mode 100644 index e4167e76..00000000 --- a/lib/build-pages/page-data.js +++ /dev/null @@ -1,520 +0,0 @@ -/** - * @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 { 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-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 { pathToFileURL } from 'node:url' - -/** - * @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 - * @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. - */ -export class PageData { - /** @type {PageInfo} */ pageInfo - /** @type {DomStackWarning[]} */ warnings = [] - /** @type {DomstackManifestRecord[]} Successfully emitted files, including partial builds; never populated by rendering alone. */ outputRecords = [] - /** @type {ResolvedLayout | null | undefined} */ layout - /** @type {ResolvedLayout[]} Each parent has its own render and data contracts. */ layoutChain = [] - /** @type {Partial} */ globalVars - /** @type {{ name: string, vars: Partial }[]} Layout variable sources, outermost to innermost, with dataDeps extracted. */ layoutVars = [] - /** @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 {{ 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 - - /** - * Creates an instance of PageData. - * - * @param {object} options - The options object. - * @param {PageInfo} options.pageInfo - Page-specific data. - * @param {NoInfer>} options.globalVars - Global variables available to all pages. - * @param {string | undefined} options.globalStyle - Global style path. - * @param {string | undefined} options.globalClient - Global client-side script path. - * @param {string?} options.defaultStyle - Default style path. - * @param {string?} options.defaultClient - Default client-side script path. - * @param {BuilderOptions} options.builderOptions - Options for page builders. - */ - constructor ({ - pageInfo, - globalVars, - globalStyle, - globalClient, - defaultStyle, - defaultClient, - builderOptions, - }) { - this.pageInfo = pageInfo - this.globalVars = globalVars - this.#defaultStyle = defaultStyle - this.#defaultClient = defaultClient - this.builderOptions = builderOptions - - if (globalStyle) { - this.styles.push(`/${globalStyle}`) - } - if (globalClient) { - this.scripts.push(`/${globalClient}`) - } - } - - #varSources () { - return [ - this.globalVars, - ...this.layoutVars.map(layout => layout.vars), - this.pageVars, - this.builderVars, - ] - } - - /** - * Source-root-relative identity with POSIX separators, independent of checkout - * location and output URL. Generated pages use their synthetic factory relname. - * @returns {string} - */ - get sourceId () { - return toPosix(normalize(this.pageInfo.pageFile.relname)) - } - - /** - * Returns the cached, shallow-frozen variable set for the page. Requires initialization. - * @return {T} default/global, layout, page, and builder vars merged together - */ - get vars () { - if (!this.#initialized) throw new Error(`Initialize PageData before accessing vars for page "${this.pageInfo?.path ?? ''}"`) - 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.#varsCacheSources = sources - return this.#varsCache - } catch (err) { - throw new Error( - `Failed to resolve vars for page "${this.pageInfo?.path ?? ''}": ${err instanceof Error ? err.message : String(err)}`, - { cause: err } - ) - } - } - - /** - * Access web worker file paths associated with this page - * @return {WorkerFiles} Map of worker names to their output paths - */ - get workers () { - return this.workerFiles - } - - /** - * Return only the global-data values declared by the page itself. - * - * @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}"` } - ) - } - - /** - * Select the declared global-data values after global.data has resolved. - * - * @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 - } - - /** - * Read the raw markdown body for a markdown page, excluding front matter. - * @returns {Promise} - */ - async readMarkdownContent () { - if (!this.pageInfo) throw new Error('A page is required to read markdown content') - if (this.pageInfo.type !== 'md') throw new Error('Markdown content can only be read from markdown pages') - - const fileContents = await readFile(this.pageInfo.pageFile.filepath, 'utf8') - return parseMdFileContents(fileContents).markdownContent - } - - /** - * Resolve the page's vars, layout chain, and assets once before rendering. - * @param {object} params - Parameters required to initialize - * @param {Record>} params.layouts - Layouts indexed by name. - */ - async init ({ layouts }) { - if (this.#initialized) return - 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 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 layoutName = resolveLayoutName(globalVars, resolvedPageVars, builderVars) - - this.layoutChain = resolveLayoutChain(layoutName, layouts) - 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.builderVars = /** @type {Partial} */ (builderResolution.vars) - this.#pageDataDeps = [...new Set([...pageResolution.dataDeps, ...builderResolution.dataDeps])].sort() - const dependencies = new Set(this.#pageDataDeps) - 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.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() - - if (pageInfo.pageStyle) { - this.styles.push(`./${pageInfo.pageStyle.outputName}`) - } - if (pageInfo.clientBundle) { - this.scripts.push(`./${pageInfo.clientBundle.outputName}`) - } - // Initialize web workers if they exist - if (pageInfo.workers) { - /** @type {WorkerFiles} */ - for (const [workerName, workerFile] of Object.entries(pageInfo.workers)) { - if (workerFile.outputRelname) { - this.workerFiles[workerName] = `./${workerFile.outputName}` - } - } - } - - /** @type {object} */ - const finalVars = this.#varSources().reduce((merged, vars) => ({ ...merged, ...vars }), {}) - - // disable-eslint-next-line dot-notation - if ('defaultStyle' in finalVars && finalVars.defaultStyle) { - if (this.#defaultClient) this.scripts.unshift(`/${this.#defaultClient}`) - if (this.#defaultStyle) this.styles.unshift(`/${this.#defaultStyle}`) - } - - this.#initialized = true - } - - /** - * Run hooks lazily as the output phase consumes each record. - * Layouts run outermost first, followed by the single page-level provider. - * @returns {AsyncGenerator} - */ - 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) { - throw new Error(`pageOutputs for page "${pageData.pageInfo.pageFile.relname}" from ${provenance.kind} "${provenance.source}" failed: ${cause instanceof Error ? cause.message : String(cause)}`, { 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 }, () => { - const subscription = this.#layoutSubscriptions.get(layout.name) - if (!this.#dataReady && subscription?.keys.length) throw this.#dataNotReadyError() - return subscription?.data ?? Object.freeze({}) - }) - } - if (this.#pageOutputs) { - const { hook, provenance } = this.#pageOutputs - yield * collect(hook, provenance, () => this.data) - } - } - - /** - * Render the inner contents of a page. - * @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 - 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 {InternalPageFunction} */ (pageLayout) - const results = await render({ vars, data, styles, scripts, page: pageInfo, workers }) - return results - } - - /** - * Render the full contents of a page with its layout - */ - 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() - } - 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') - /** @type {unknown} */ - let rendered = await this.renderInnerPage() - for (const currentLayout of layoutChain.toReversed()) { - rendered = await currentLayout.render({ - vars, - styles, - scripts, - page: pageInfo, - data: this.#layoutSubscriptions.get(currentLayout.name)?.data, - children: rendered, - workers: this.workers - }) - } - 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-page-outputs.test.js b/lib/build-pages/page/page-data-page-outputs.test.js similarity index 92% rename from lib/build-pages/page-data-page-outputs.test.js rename to lib/build-pages/page/page-data-page-outputs.test.js index 3cf8121b..779e9c28 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 './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' 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 './page-builders/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 @@ -384,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-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-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/page-data.js b/lib/build-pages/page/page-data.js new file mode 100644 index 00000000..b324c266 --- /dev/null +++ b/lib/build-pages/page/page-data.js @@ -0,0 +1,280 @@ +/** + * @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, PageFunction } from '../outputs/page-writer.js' + * @import { CollectedPageOutput } from '../outputs/page-outputs.js' + * @import { PageOutputProvider } from '../outputs/collect-page-outputs.js' + */ + +import { readFile } from 'node:fs/promises' +import { normalize } from 'node:path' +import { toPosix } from '../../helpers/path.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' +import { extractDataDeps } from '../global-data/data-deps.js' +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 { resolvePageOutputProvider } from '../outputs/resolve-page-output-provider.js' +import { collectPageOutputs } from '../outputs/collect-page-outputs.js' + +/** + * @typedef {Object} WorkerFiles + */ + +/** + * Represents the data for a page. + * @template {Record} T - The type of variables for the page data + * @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. + */ +export class PageData { + /** @type {PageInfo} */ pageInfo + /** @type {DomStackWarning[]} */ warnings = [] + /** @type {DomstackManifestRecord[]} Successfully emitted files, including partial builds; never populated by rendering alone. */ outputRecords = [] + /** @type {ResolvedLayout | null | undefined} */ layout + /** @type {ResolvedLayout[]} Each parent has its own render and data contracts. */ layoutChain = [] + /** @type {Partial} */ globalVars + /** @type {{ name: string, vars: Partial }[]} Layout variable sources, outermost to innermost, with dataDeps extracted. */ layoutVars = [] + /** @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 {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 = [] + /** @type {WorkerFiles} */ workerFiles = {} + /** @type {boolean} */ #initialized = false + /** @type {PageVars} */ #vars = new PageVars() + + /** @type {string?} */ #defaultStyle = null + /** @type {string?} */ #defaultClient = null + /** @type {BuilderOptions} */ builderOptions + + /** + * Creates an instance of PageData. + * + * @param {object} options - The options object. + * @param {PageInfo} options.pageInfo - Page-specific data. + * @param {NoInfer>} options.globalVars - Global variables available to all pages. + * @param {string | undefined} options.globalStyle - Global style path. + * @param {string | undefined} options.globalClient - Global client-side script path. + * @param {string?} options.defaultStyle - Default style path. + * @param {string?} options.defaultClient - Default client-side script path. + * @param {BuilderOptions} options.builderOptions - Options for page builders. + */ + constructor ({ + pageInfo, + globalVars, + globalStyle, + globalClient, + defaultStyle, + defaultClient, + builderOptions, + }) { + this.pageInfo = pageInfo + this.globalVars = globalVars + this.#defaultStyle = defaultStyle + this.#defaultClient = defaultClient + this.builderOptions = builderOptions + + if (globalStyle) { + this.styles.push(`/${globalStyle}`) + } + if (globalClient) { + this.scripts.push(`/${globalClient}`) + } + } + + /** + * Source-root-relative identity with POSIX separators, independent of checkout + * location and output URL. Generated pages use their synthetic factory relname. + * @returns {string} + */ + get sourceId () { + return toPosix(normalize(this.pageInfo.pageFile.relname)) + } + + /** + * Returns the cached, shallow-frozen variable set for the page. Requires initialization. + * @return {T} default/global, layout, page, and builder vars merged together + */ + get vars () { + if (!this.#initialized) throw new Error(`Initialize PageData before accessing vars for page "${this.pageInfo?.path ?? ''}"`) + try { + 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)}`, + { cause: err } + ) + } + } + + /** + * Access web worker file paths associated with this page + * @return {WorkerFiles} Map of worker names to their output paths + */ + get workers () { + return this.workerFiles + } + + /** + * Return only the global-data values declared by the page itself. + * + * @returns {D} + */ + get data () { + return this.#subscriptions.getPageData(this.pageInfo) + } + + /** + * Select the declared global-data values after global.data has resolved. + * + * @param {Record} globalData + */ + setGlobalData (globalData) { + this.#subscriptions.bind(globalData, this.pageInfo) + } + + /** + * Read the raw markdown body for a markdown page, excluding front matter. + * @returns {Promise} + */ + async readMarkdownContent () { + if (!this.pageInfo) throw new Error('A page is required to read markdown content') + if (this.pageInfo.type !== 'md') throw new Error('Markdown content can only be read from markdown pages') + + const fileContents = await readFile(this.pageInfo.pageFile.filepath, 'utf8') + return parseMdFileContents(fileContents).markdownContent + } + + /** + * 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. + */ + async init ({ layouts }) { + if (this.#initialized) return + const { pageInfo, globalVars } = this + if (!pageInfo) throw new Error('A page is required to initialize') + const { pageVars, type } = pageInfo + 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 + const { provider, warning } = resolvePageOutputProvider(pageInfo, built.pageOutputs, companionExports) + this.#pageOutputs = provider + if (warning) this.warnings.push(warning) + + const layoutName = resolveLayoutName(globalVars, resolvedPageVars, builderVars) + + this.layoutChain = resolveLayoutChain(layoutName, layouts) + 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 = /** @type {Partial} */ (pageResolution.vars) + this.builderVars = /** @type {Partial} */ (builderResolution.vars) + this.#subscriptions.setPageDependencies(pageResolution.dataDeps, builderResolution.dataDeps) + for (const layout of this.layoutChain) { + const resolution = extractDataDeps(layout.vars, `Layout "${layout.name}"`) + this.#subscriptions.addLayout(layout.name, resolution.dataDeps) + this.layoutVars.push({ name: layout.name, vars: /** @type {Partial} */ (resolution.vars) }) + + if (layout.layoutStylePath) this.styles.push(layout.layoutStylePath) + if (layout.layoutClientPath) this.scripts.push(layout.layoutClientPath) + } + + this.dataDeps = this.#subscriptions.collectDependencies() + + if (pageInfo.pageStyle) { + this.styles.push(`./${pageInfo.pageStyle.outputName}`) + } + if (pageInfo.clientBundle) { + this.scripts.push(`./${pageInfo.clientBundle.outputName}`) + } + // Initialize web workers if they exist + if (pageInfo.workers) { + /** @type {WorkerFiles} */ + for (const [workerName, workerFile] of Object.entries(pageInfo.workers)) { + if (workerFile.outputRelname) { + this.workerFiles[workerName] = `./${workerFile.outputName}` + } + } + } + + // First vars access must still observe source changes made after init. + /** @type {object} */ + const finalVars = this.#vars.mergeUncached(this) + + // disable-eslint-next-line dot-notation + if ('defaultStyle' in finalVars && finalVars.defaultStyle) { + if (this.#defaultClient) this.scripts.unshift(`/${this.#defaultClient}`) + 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 + } + + /** + * Run hooks lazily as the output phase consumes each record. + * Layouts run outermost first, followed by the single page-level provider. + * @returns {AsyncGenerator} + */ + async * collectPageOutputs () { + if (!this.#initialized) throw new Error('Must be initialized before collecting pageOutputs') + if (this.pageInfo.generated) return + yield * collectPageOutputs(this, this.#subscriptions, this.#pageOutputs) + } + + /** + * Render the inner contents of a page. + * @returns {Promise>} The page's render value, before any layout runs. + */ + async renderInnerPage () { + 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 results = await render({ vars, data, styles, scripts, page: pageInfo, workers }) + return results + } + + /** + * Render the full contents of a page with its layout + */ + async renderFullPage () { + if (!this.#initialized) throw new Error('Must be initialized before rendering full pages') + 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') + /** @type {unknown} */ + let rendered = await this.renderInnerPage() + for (const currentLayout of layoutChain.toReversed()) { + rendered = await currentLayout.render({ + vars, + styles, + scripts, + page: pageInfo, + data: this.#subscriptions.getLayoutData(currentLayout.name, this.pageInfo), + children: rendered, + workers: this.workers + }) + } + return pretty(String(rendered)) + } +} diff --git a/lib/build-pages/page-data.test.js b/lib/build-pages/page/page-data.test.js similarity index 58% rename from lib/build-pages/page-data.test.js rename to lib/build-pages/page/page-data.test.js index a769bc45..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 './page-builders/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 @@ -141,30 +140,253 @@ 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('resolves layout vars exports', async () => { - const dir = await mkdtemp(join(tmpdir(), 'domstack-resolve-layout-vars-test-')) - const layoutFile = join(dir, 'test.layout.mjs') + 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) + }) - 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('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('preserves every layout vars layer while merging between global and page vars', async () => { @@ -432,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/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/resolve-vars.js b/lib/build-pages/resolve-vars.js deleted file mode 100644 index 91d46074..00000000 --- a/lib/build-pages/resolve-vars.js +++ /dev/null @@ -1,101 +0,0 @@ -/** - - * @import { GlobalDataFunctionParams } from './index.js' - */ - -import { isFunction, isObject, isPlainObject } from '../helpers/type-guards.js' - -/** - * Resolve an object-or-function vars export. - * - * @param {unknown} maybeVars - * @param {string} errorLabel - * @returns {Promise>} - */ -export async function resolveVarsExport (maybeVars, errorLabel) { - if (!maybeVars) return {} - - if (isPlainObject(maybeVars)) { - return maybeVars - } else if (isFunction(maybeVars)) { - const resolvedVars = await maybeVars() - if (isPlainObject(resolvedVars)) return resolvedVars - throw new Error(`${errorLabel} function must resolve to a plain object`) - } else { - return {} - } -} - -/** - * Resolve variables by importing them from a specified path. - * - * @param {object} params - * @param {string | undefined} [params.varsPath] - Path to the file containing the variables. - * @param {string} [params.key='default'] - The key to extract from the imported module. Default: 'default' - * @returns {Promise} - Returns the resolved variables. If the imported variable is a function, it executes and returns its result. Otherwise, it returns the variable directly. - */ -export async function resolveVars ({ - varsPath, - key = 'default', -}) { - if (!varsPath) return {} - - const imported = await import(varsPath) - 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. - * - * @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/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 f5d91efb..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 '../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-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 71a72d8f..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 '../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/vars/page-vars.js b/lib/build-pages/vars/page-vars.js new file mode 100644 index 00000000..18e6c0ae --- /dev/null +++ b/lib/build-pages/vars/page-vars.js @@ -0,0 +1,74 @@ +/** + * @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} */ + 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. + 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} + */ + mergeUncached (page) { + return this.#merge(this.#collectSources(page)) + } + + /** @param {PageVarSources} page */ + #collectSources (page) { + /** @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 */ + #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..92f8039b --- /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.mergeUncached(page), { title: 'global' }) + page.globalVars['title'] = 'before first access' + const snapshot = vars.getCached(page) + assert.equal(snapshot['title'], 'before first access') + page.globalVars['title'] = 'after first access' + 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 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.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') +}) + +test('failed merges preserve the previous snapshot and can be retried', () => { + const page = sources() + const vars = new PageVars() + const first = vars.getCached(page) + const original = page.globalVars + const cause = new Error('Failed getter') + page.globalVars = { get title () { throw cause } } + assert.throws(() => vars.getCached(page), error => error === cause) + assert.throws(() => vars.getCached(page), error => error === cause) + page.globalVars = original + assert.equal(vars.getCached(page), first) + page.globalVars = { 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 new file mode 100644 index 00000000..55e7108d --- /dev/null +++ b/lib/build-pages/vars/resolve-vars.js @@ -0,0 +1,40 @@ +import { isFunction, isPlainObject } from '../../helpers/type-guards.js' + +/** + * Resolve an object-or-function vars export. + * + * @param {unknown} maybeVars + * @param {string} errorLabel + * @returns {Promise>} + */ +export async function resolveVarsExport (maybeVars, errorLabel) { + if (!maybeVars) return {} + + if (isPlainObject(maybeVars)) { + return maybeVars + } else if (isFunction(maybeVars)) { + const resolvedVars = await maybeVars() + if (isPlainObject(resolvedVars)) return resolvedVars + throw new Error(`${errorLabel} function must resolve to a plain object`) + } else { + return {} + } +} + +/** + * Resolve variables by importing them from a specified path. + * + * @param {object} params + * @param {string | undefined} [params.varsPath] - Path to the file containing the variables. + * @param {string} [params.key='default'] - The key to extract from the imported module. Default: 'default' + * @returns {Promise} - Returns the resolved variables. If the imported variable is a function, it executes and returns its result. Otherwise, it returns the variable directly. + */ +export async function resolveVars ({ + varsPath, + key = 'default', +}) { + if (!varsPath) return {} + + const imported = await import(varsPath) + return await resolveVarsExport(imported[key], 'Var') +} diff --git a/lib/build-pages/resolve-vars.test.js b/lib/build-pages/vars/resolve-vars.test.js similarity index 64% rename from lib/build-pages/resolve-vars.test.js rename to lib/build-pages/vars/resolve-vars.test.js index cb4b8a23..8cdc455e 100644 --- a/lib/build-pages/resolve-vars.test.js +++ b/lib/build-pages/vars/resolve-vars.test.js @@ -8,11 +8,11 @@ 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 }) - // @ts-ignore + // @ts-expect-error resolveVars returns object; this fixture exports the known foo property. assert.equal(vars.foo, 'global') }) }) 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/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/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/lib/build-pages/worker/protocol.js b/lib/build-pages/worker/protocol.js new file mode 100644 index 00000000..f78ee0cf --- /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 '../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' + +/** + * 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..a35a1aa1 --- /dev/null +++ b/lib/build-pages/worker/protocol.test.js @@ -0,0 +1,189 @@ +/** + * @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 { 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) { + 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.buildPages, buildPages) + 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/worker.js similarity index 87% rename from lib/build-pages/worker.js rename to lib/build-pages/worker/worker.js index 41dfce30..969c1fc4 100644 --- a/lib/build-pages/worker.js +++ b/lib/build-pages/worker/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 './protocol.js' async function run () { if (!parentPort) throw new Error('parentPort returned null') 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/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/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 2ba86995..35aa4d8b 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 @@ -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 published:false when set to true * @throws When the `src` argument is not provided or something else goes wrong. */ export async function identifyPages (src, opts = {}) { 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/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 1fdcfdbc..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 '../watch/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/lib/watch/index.js b/lib/watch/index.js new file mode 100644 index 00000000..7e17bd59 --- /dev/null +++ b/lib/watch/index.js @@ -0,0 +1,595 @@ +/// + +/** + * @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 { 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 {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/test-cases/watch-lifecycle/index.test.js b/lib/watch/lifecycle-tests/index.test.js similarity index 93% rename from test-cases/watch-lifecycle/index.test.js rename to lib/watch/lifecycle-tests/index.test.js index 94c09397..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 @@ -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/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/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/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'), /