From a561a59c5414b73690a20fd222175e265342efd2 Mon Sep 17 00:00:00 2001 From: Bret Comnes Date: Sat, 5 Sep 2026 21:21:33 -0700 Subject: [PATCH] Extract pure watch plans and keep execution in DomStack --- index.js | 285 ++++++++++++----------------------------- lib/watch-plan.js | 165 ++++++++++++++++++++++++ lib/watch-plan.test.js | 166 ++++++++++++++++++++++++ 3 files changed, 410 insertions(+), 206 deletions(-) create mode 100644 lib/watch-plan.js create mode 100644 lib/watch-plan.test.js diff --git a/index.js b/index.js index 1cfab3f..7c78504 100644 --- a/index.js +++ b/index.js @@ -9,6 +9,7 @@ * @import { Logger as PinoLogger } from 'pino' * @import { DomstackManifestRecord } from './lib/domstack-manifest/index.js' * @import { WatchDependencyState } from './lib/build-pages/watch-dependencies.js' + * @import { WatchSnapshot, WatchEvent, WatchPlan } from './lib/watch-plan.js' * @typedef {{ dispose: () => Promise }} DisposableBuildContext * @typedef {{ pageFilePath: string, sourcePageFilePath?: string | undefined, pagesFilePath?: string | undefined, layoutNames: string[], outputs?: DomstackManifestRecord[] | undefined }} WatchedPageReport */ @@ -17,7 +18,7 @@ import assert from 'node:assert' import { mkdtemp, readFile, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import chokidar from 'chokidar' -import { basename, dirname, join, relative, resolve } from 'node:path' +import { basename, join, relative, resolve } from 'node:path' // @ts-expect-error import makeArray from 'make-array' import ignore from 'ignore' @@ -29,22 +30,12 @@ import { find } from '@11ty/dependency-tree-typescript' import { assertInsideDest } from './lib/helpers/path.js' import { getCopyGlob } from './lib/build-static/index.js' import { getCopyDirs } from './lib/build-copy/index.js' -import { classifyFile, isProcessedFile, globalBundleAssets, pageBundleAssets, layoutBundleAssets } from './lib/file-conventions.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, - layoutStyleSuffix, - globalVarsNames, - esbuildSettingsNames, - markdownItSettingsNames, - domstackManifestSettingsNames, - layoutClientSuffixs, - globalClientNames, - globalStyleNames, - serviceWorkerNames, -} from './lib/identify-pages.js' +import { identifyPages } from './lib/identify-pages.js' +import { classifyWatchEvent, planWatchEvent, 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' @@ -329,15 +320,15 @@ export class DomStack { } watcher.on('add', path => { - enqueue(() => this.#handleAddUnlink(path, 'added')) + enqueue(() => this.#handleWatchEvent(path, 'added')) }) watcher.on('change', path => { assert(this.#src) assert(this.#dest) - enqueue(() => this.#handleChange(path)) + enqueue(() => this.#handleWatchEvent(path, 'change')) }) watcher.on('unlink', path => { - enqueue(() => this.#handleAddUnlink(path, 'removed')) + enqueue(() => this.#handleWatchEvent(path, 'removed')) }) watcher.on('error', err => errorLogger(err, this.#logger)) @@ -374,87 +365,82 @@ ${siteData.errors.map(err => ` ${err.message}`).join('\n')}`) await this.#rebuildMaps(siteData) } + /** @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, + pageBuildFailed: this.#pageBuildFailed, + esbuildEntryPoints: this.#esbuildEntryPoints, + } + } + /** - * Handle file add/unlink events. Categorizes the file to determine the minimal rebuild: - * - esbuild entry point added/removed: restart esbuild + targeted page rebuild - * - Otherwise: full rebuild (structural change to the page/layout/template set) - * - * @param {string} changedPath - Absolute path of the added/removed file. - * @param {'added' | 'removed'} event - The type of event. + * @param {string} changedPath + * @param {'change' | 'added' | 'removed'} type */ - async #handleAddUnlink (changedPath, event) { - const changedBasename = basename(changedPath) - const changedDir = relative(this.#src, dirname(changedPath)) - - // Check if this is an esbuild entry point by basename pattern - const isEsbuildEntry = classifyFile(changedBasename)?.bundleScope - - if (isEsbuildEntry) { - this.#logger.info(`"${changedBasename}" ${event}, restarting esbuild...`) - - // Re-identify pages to discover the new/removed entry point - const siteData = await identifyPages(this.#src, this.opts) - if (siteData.errors.length > 0) { - this.#logger.error(`identifyPages errors:\n${siteData.errors.map(err => ` ${err.message}`).join('\n')}`) - return - } - - await ensureDest(this.#dest, siteData) + async #handleWatchEvent (changedPath, type) { + const snapshot = this.#watchSnapshot() + if (!snapshot) return + const event = classifyWatchEvent(type, changedPath) + await this.#executeWatchPlan(planWatchEvent(snapshot, event), event) + } - // Restart esbuild with updated entry points - if (this.#esbuildContext) { - await this.#esbuildContext.dispose() - this.#esbuildContext = null - } - const { context } = await buildEsbuildWatch(this.#src, this.#dest, siteData, this.opts) - this.#esbuildContext = context - this.#siteData = siteData - - // Determine which pages are affected by this entry point change - if (serviceWorkerNames.includes(changedBasename)) { - // Service workers are site-level esbuild entries and do not affect page HTML. - this.#logger.info(`"${changedBasename}" ${event}, no page rebuild needed.`) - } else if (globalClientNames.includes(changedBasename) || globalStyleNames.includes(changedBasename)) { - // Global asset: rebuild all pages - logRebuildTree(changedBasename, this.#logger, new Set(siteData.pages)) - await this.#runPageBuild(siteData) - } else if (layoutClientSuffixs.some(s => changedBasename.endsWith(s)) || changedBasename.endsWith(layoutStyleSuffix)) { - // Layout asset: rebuild pages using that layout - const layoutName = Object.values(siteData.layouts).find(l => - l.layoutClient?.filepath === changedPath || l.layoutStyle?.filepath === changedPath - )?.layoutName - if (layoutName) { - // Rebuild maps first so layoutPageMap is current - await this.#rebuildMaps(siteData) - const affectedPages = this.#layoutPageMap.get(layoutName) - const pagesFileFilterPaths = this.#getPagesFilePathsUsingLayouts(new Set([layoutName])) - if ((affectedPages?.size ?? 0) > 0 || pagesFileFilterPaths.length > 0) { - logRebuildTree(changedBasename, this.#logger, affectedPages) - const pageFilterPaths = Array.from(affectedPages ?? []).map(p => p.pageFile.filepath) - await this.#runPageBuild(siteData, pageFilterPaths, [], pagesFileFilterPaths) - return - } - } - // Couldn't determine layout — rebuild all pages to be safe - await this.#runPageBuild(siteData, null, [], null) - } else { - // Page-level asset (client.*, style.css, *.worker.*): rebuild only that page - const affectedPage = siteData.pages.find(p => p.path === changedDir) - if (affectedPage) { - logRebuildTree(changedBasename, this.#logger, new Set([affectedPage])) - await this.#runPageBuild(siteData, [affectedPage.pageFile.filepath], [], []) - } else { - // Page not found (maybe it was removed) — rebuild all pages - await this.#runPageBuild(siteData) - } - } + /** + * Keep resource ownership and successful-build state updates in the executor. + * @param {WatchPlan} plan + * @param {WatchEvent} event + * @returns {Promise} + */ + async #executeWatchPlan (plan, event) { + if (plan.message) this.#logger.info(plan.message) + if (plan.kind === 'skip') return + if (plan.kind === 'full') { + await this.#fullRebuild() + return + } + if (plan.kind === 'restart') { + await this.#restartEsbuildForEvent(event) + 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) + } - await this.#rebuildMaps(siteData) - } else { - // Non-esbuild file: structural change (page, layout, template, config, etc.) - this.#logger.info(`"${changedBasename}" ${event}, triggering full rebuild...`) - return this.#fullRebuild() + /** @param {WatchEvent} event */ + async #restartEsbuildForEvent (event) { + const siteData = await identifyPages(this.#src, this.opts) + if (siteData.errors.length > 0) { + this.#logger.error(`identifyPages errors:\n${siteData.errors.map(err => ` ${err.message}`).join('\n')}`) + return } + 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) + 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) } /** @@ -569,22 +555,6 @@ ${siteData.errors.map(err => ` ${err.message}`).join('\n')}`) } } - /** - * Find generated-page owners whose last successful outputs used any affected layout. - * - * @param {Set} layoutNames - * @returns {string[]} - */ - #getPagesFilePathsUsingLayouts (layoutNames) { - const pagesFilePaths = [] - for (const [pagesFilePath, usedLayouts] of this.#pagesFileLayoutMap) { - if (Array.from(usedLayouts).some(layoutName => layoutNames.has(layoutName))) { - pagesFilePaths.push(pagesFilePath) - } - } - return pagesFilePaths - } - /** * @param {() => Promise} fn */ @@ -739,103 +709,6 @@ ${siteData.errors.map(err => ` ${err.message}`).join('\n')}`) this.#esbuildEntryPoints = esbuildEntryPoints } - /** - * Chokidar change handler — implements the decision tree from the plan. - * - * @param {string} changedPath - Absolute path of the changed file. - */ - async #handleChange (changedPath) { - const siteData = this.#siteData - if (!siteData) return - - const changedBasename = basename(changedPath) - - // 2. global.vars.* → full rebuild (esbuild restart + all pages) - if (globalVarsNames.some(n => changedBasename === n)) { - this.#logger.info(`"${changedBasename}" changed, triggering full rebuild...`) - return this.#fullRebuild() - } - - // 3. global.data.* → recompute data and rebuild only declared subscribers - const globalDataChanged = this.#globalDataDepPaths.has(changedPath) - if (globalDataChanged) { - this.#logger.info(`"${changedBasename}" changed, rebuilding data subscribers...`) - } - - // 4. esbuild.settings.* → full rebuild - if (esbuildSettingsNames.some(n => changedBasename === n)) { - this.#logger.info(`"${changedBasename}" changed, triggering full rebuild...`) - return this.#fullRebuild() - } - - // 5. markdown-it.settings.* → rebuild Markdown pages and any data subscribers - // affected by global.data recomputation. - if (markdownItSettingsNames.some(n => changedBasename === n)) { - const mdPages = new Set(siteData.pages.filter(p => p.type === 'md')) - logRebuildTree(changedBasename, this.#logger, mdPages) - return this.#runPageBuild(siteData, Array.from(mdPages).map(p => p.pageFile.filepath), [], []) - } - - // domstack-manifest.settings.* only affects one-shot domstack manifest generation. - // Watch mode intentionally does not write or return a domstack manifest. - if (domstackManifestSettingsNames.some(n => changedBasename === n)) { - this.#logger.info(`"${changedBasename}" changed but domstack manifests are disabled in watch mode, skipping.`) - return - } - - if (this.#pageBuildFailed) { - this.#logger.info(`"${changedBasename}" changed, retrying all pages after the previous build failure...`) - return this.#runPageBuild(siteData) - } - - // A source can serve several roles at once: an imported parent layout may - // also be selected directly, and a helper may be shared by pages and templates. - // Union every matching consumer before scheduling one build. - const affectedLayouts = new Set(this.#layoutDepMap.get(changedPath)) - const directLayout = this.#layoutFileMap.get(changedPath) - if (directLayout) affectedLayouts.add(directLayout) - - const affectedPages = new Set(this.#pageDepMap.get(changedPath)) - const directPage = this.#pageFileMap.get(changedPath) - if (directPage) affectedPages.add(directPage) - for (const name of affectedLayouts) { - for (const page of this.#layoutPageMap.get(name) ?? []) affectedPages.add(page) - } - - const affectedTemplates = new Set(this.#templateDepMap.get(changedPath)) - for (const template of siteData.templates) { - if (template.templateFile.filepath === changedPath) affectedTemplates.add(template) - } - - const affectedOwners = new Set(this.#getPagesFilePathsUsingLayouts(affectedLayouts)) - for (const pagesFile of this.#pagesFileDepMap.get(changedPath) ?? []) { - affectedOwners.add(pagesFile.pagesFile.filepath) - } - for (const pagesFile of siteData.pagesFiles ?? []) { - if (pagesFile.pagesFile.filepath === changedPath) affectedOwners.add(changedPath) - } - - if (globalDataChanged || affectedPages.size || affectedTemplates.size || affectedOwners.size) { - logRebuildTree(changedBasename, this.#logger, affectedPages, affectedTemplates) - return this.#runPageBuild( - siteData, - Array.from(affectedPages, page => page.pageFile.filepath), - Array.from(affectedTemplates, template => template.templateFile.filepath), - [...affectedOwners] - ) - } - - // Browser entry points can also be imported by server-side consumers. - // Only skip the page phase once all those consumers have been considered. - if (this.#esbuildEntryPoints.has(changedPath)) { - this.#logger.info(`"${changedBasename}" changed, esbuild will handle rebundling.`) - return - } - - // No matching rule — skip. - this.#logger.info(`"${changedBasename}" changed but did not match any rebuild rule, skipping.`) - } - async stopWatching () { if (this.#stopping) return this.#stopping if ((!this.watching || !this.#cpxWatchers)) throw new Error('Not watching') diff --git a/lib/watch-plan.js b/lib/watch-plan.js new file mode 100644 index 0000000..93c4288 --- /dev/null +++ b/lib/watch-plan.js @@ -0,0 +1,165 @@ +/** + * @import { SiteData } from './builder.js' + * @import { PageInfo, TemplateInfo, PagesFileInfo } from './identify-pages.js' + * @typedef {object} WatchSnapshot + * @property {Pick} siteData + * @property {ReadonlyMap>} layoutDepMap + * @property {ReadonlyMap>} layoutPageMap + * @property {ReadonlyMap} pageFileMap + * @property {ReadonlyMap} layoutFileMap + * @property {ReadonlyMap>} pageDepMap + * @property {ReadonlyMap>} templateDepMap + * @property {ReadonlyMap>} pagesFileDepMap + * @property {ReadonlyMap>} pagesFileLayoutMap + * @property {ReadonlySet} globalDataDepPaths + * @property {boolean} pageBuildFailed + * @property {ReadonlySet} esbuildEntryPoints + * @typedef {ReturnType} WatchEvent + * @typedef {object} PagePlan + * @property {'pages'} kind + * @property {string[] | null} pageFilterPaths + * @property {string[] | null} templateFilterPaths + * @property {string[] | null} pagesFileFilterPaths + * @property {string} [message] + * @property {PageInfo[]} [pages] + * @property {TemplateInfo[]} [templates] + * @typedef {PagePlan | {kind: 'skip', message: string} | {kind: 'full', message: string} | {kind: 'restart', message: string}} WatchPlan + */ +import { basename, dirname, relative } from 'node:path' +import { classifyFile } from './file-conventions.js' + +/** + * @param {'change' | 'added' | 'removed'} type + * @param {string} filepath Absolute source path. + */ +export function classifyWatchEvent (type, filepath) { + const name = basename(filepath) + return { type, filepath, name, convention: classifyFile(name) } +} + +/** + * Plan from the last successful watch snapshot without doing I/O or mutating it. + * @param {WatchSnapshot} state + * @param {WatchEvent} event + * @returns {WatchPlan} + */ +export function planWatchEvent (state, event) { + const { type, filepath, name, convention } = event + const { siteData } = state + if (type !== 'change') { + return convention?.bundleScope + ? { kind: 'restart', message: `"${name}" ${type}, restarting esbuild...` } + : { kind: 'full', message: `"${name}" ${type}, triggering full rebuild...` } + } + if (convention?.change === 'full') return { kind: 'full', message: `"${name}" changed, triggering full rebuild...` } + if (convention?.change === 'markdown') { + const pages = siteData.pages.filter(page => page.type === 'md') + return selectedPages(pages) + } + if (convention?.change === 'manifest') { + return { kind: 'skip', message: `"${name}" changed but domstack manifests are disabled in watch mode, skipping.` } + } + if (state.pageBuildFailed) { + return { ...allPages(), message: `"${name}" changed, retrying all pages after the previous build failure...` } + } + + // A module can have several roles, including both browser and server uses. + // Union every direct and imported consumer before deciding to skip it. + const layouts = new Set(state.layoutDepMap.get(filepath)) + const directLayout = state.layoutFileMap.get(filepath) + if (directLayout) layouts.add(directLayout) + const affected = layoutConsumers(state, layouts) + const pages = new Set(state.pageDepMap.get(filepath)) + const directPage = state.pageFileMap.get(filepath) + if (directPage) pages.add(directPage) + for (const page of affected.pages) pages.add(page) + + const templates = new Set(state.templateDepMap.get(filepath)) + for (const template of siteData.templates) { + if (template.templateFile.filepath === filepath) templates.add(template) + } + const owners = affected.owners + for (const owner of state.pagesFileDepMap.get(filepath) ?? []) { + owners.add(owner.pagesFile.filepath) + } + for (const owner of siteData.pagesFiles ?? []) { + if (owner.pagesFile.filepath === filepath) owners.add(filepath) + } + + // Even without direct consumers, a producer input must recompute global data. + // The page worker selects subscribers after comparing the resulting values. + const globalDataChanged = state.globalDataDepPaths.has(filepath) + if (globalDataChanged || pages.size || templates.size || owners.size) { + return { + ...selectedPages([...pages]), + templateFilterPaths: [...templates].map(template => template.templateFile.filepath), + pagesFileFilterPaths: [...owners], + templates: [...templates], + ...(globalDataChanged ? { message: `"${name}" changed, rebuilding data subscribers...` } : {}), + } + } + if (state.esbuildEntryPoints.has(filepath)) { + return { kind: 'skip', message: `"${name}" changed, esbuild will handle rebundling.` } + } + return { kind: 'skip', message: `"${name}" changed but did not match any rebuild rule, skipping.` } +} + +/** + * Select HTML consumers after discovery and esbuild have refreshed bundle metadata. + * The reverse maps still describe the last successful map refresh, including + * source pages and layouts used by generated pages. + * @param {WatchSnapshot} state + * @param {WatchEvent} event + * @param {string} src + * @returns {WatchPlan} + */ +export function planBundleChange (state, event, src) { + const { convention, filepath, name, type } = event + if (convention?.bundleScope === 'service-worker') { + return { kind: 'skip', message: `"${name}" ${type}, no page rebuild needed.` } + } + if (convention?.bundleScope === 'global') return { ...allPages(), pages: [...state.siteData.pages] } + if (convention?.bundleScope === 'layout') { + const layout = Object.values(state.siteData.layouts).find(layout => + layout.layoutClient?.filepath === filepath || layout.layoutStyle?.filepath === filepath + ) + if (layout) { + const { pages, owners } = layoutConsumers(state, new Set([layout.layoutName])) + if (pages.size || owners.size) return { ...selectedPages([...pages]), pagesFileFilterPaths: [...owners] } + } + // A removed asset may no longer identify a layout; keep the conservative fallback. + return { ...allPages(), templateFilterPaths: [] } + } + const page = state.siteData.pages.find(page => page.path === relative(src, dirname(filepath))) + return page ? selectedPages([page]) : allPages() +} + +/** @returns {PagePlan} */ +function allPages () { + return { kind: 'pages', pageFilterPaths: null, templateFilterPaths: null, pagesFileFilterPaths: null } +} + +/** @param {PageInfo[]} pages */ +function pagePaths (pages) { + return pages.map(page => page.pageFile.filepath) +} + +/** @param {PageInfo[]} pages @returns {PagePlan} */ +function selectedPages (pages) { + return { ...allPages(), pageFilterPaths: pagePaths(pages), templateFilterPaths: [], pagesFileFilterPaths: [], pages } +} + +/** + * @param {WatchSnapshot} state + * @param {ReadonlySet} layoutNames + */ +function layoutConsumers (state, layoutNames) { + const pages = new Set(/** @type {PageInfo[]} */ ([])) + for (const layoutName of layoutNames) { + for (const page of state.layoutPageMap.get(layoutName) ?? []) pages.add(page) + } + const owners = new Set([...state.pagesFileLayoutMap] + .filter(([, usedLayouts]) => [...usedLayouts].some(name => layoutNames.has(name))) + .map(([owner]) => owner)) + return { pages, owners } +} diff --git a/lib/watch-plan.test.js b/lib/watch-plan.test.js new file mode 100644 index 0000000..5a71999 --- /dev/null +++ b/lib/watch-plan.test.js @@ -0,0 +1,166 @@ +/** + * @import { WatchSnapshot, WatchPlan } from './watch-plan.js' + * @import { WalkerFile, PageInfo, PageTypes } from './identify-pages.js' + */ +import { test } from 'node:test' +import assert from 'node:assert/strict' +import { basename, dirname, join } from 'node:path' +import { classifyWatchEvent, planWatchEvent, planBundleChange } from './watch-plan.js' + +const src = '/site' + +/** @param {string} relname @returns {WalkerFile} */ +function file (relname) { + return { root: src, filepath: join(src, relname), relname, basename: basename(relname), parentName: dirname(relname) } +} + +/** @param {string} path @param {PageTypes} [type] @returns {PageInfo} */ +function page (path, type = 'md') { + return { pageFile: file(join(path, `page.${type}`)), type, path, url: `/${path}/`, outputName: 'index.html', outputRelname: join(path, 'index.html'), draft: false } +} + +function fixture () { + const home = page('') + const other = page('other', 'js') + const template = { templateFile: file('feed.template.js'), path: '', outputName: 'feed.xml' } + const owner = { pagesFile: file('archive.pages.js'), path: '', name: 'archive' } + const root = { ...file('root.layout.js'), layoutName: 'root', layoutStyle: file('root.layout.css') } + const state = /** @satisfies {WatchSnapshot} */ ({ + siteData: { pages: [home, other], templates: [template], pagesFiles: [owner], layouts: { root } }, + layoutDepMap: new Map([['/site/layout-helper.js', new Set(['root'])]]), + layoutPageMap: new Map([['root', new Set([home])]]), + pageFileMap: new Map([[home.pageFile.filepath, home], ['/site/page.vars.js', home]]), + layoutFileMap: new Map([[root.filepath, 'root']]), + pageDepMap: new Map([['/site/page-helper.js', new Set([other])]]), + templateDepMap: new Map([['/site/template-helper.js', new Set([template])]]), + pagesFileDepMap: new Map([['/site/archive-helper.js', new Set([owner])]]), + pagesFileLayoutMap: new Map([[owner.pagesFile.filepath, new Set(['root'])]]), + globalDataDepPaths: new Set(['/site/global.data.js']), + pageBuildFailed: false, + esbuildEntryPoints: new Set(['/site/client.jsx', '/site/root.layout.css']), + }) + return { state, home, other, template, owner } +} + +/** @param {WatchPlan} plan */ +function scope (plan) { + assert.equal(plan.kind, 'pages') + if (plan.kind !== 'pages') throw new Error('Expected a page plan') + return [plan.pageFilterPaths, plan.templateFilterPaths, plan.pagesFileFilterPaths] +} + +test('settings and untracked changes produce inspectable full, page, and skip plans', () => { + const { state, home } = fixture() + for (const name of ['global.vars.js', 'esbuild.settings.js']) { + assert.equal(planWatchEvent(state, classifyWatchEvent('change', `/site/${name}`)).kind, 'full') + } + assert.deepEqual(scope(planWatchEvent(state, classifyWatchEvent('change', '/site/global.data.js'))), [[], [], []]) + assert.deepEqual(scope(planWatchEvent(state, classifyWatchEvent('change', '/site/markdown-it.settings.js'))), [[home.pageFile.filepath], [], []]) + for (const [name, reason] of [['domstack-manifest.settings.js', 'disabled'], ['client.jsx', 'esbuild'], ['unused.js', 'did not match']]) { + const plan = planWatchEvent(state, classifyWatchEvent('change', `/site/${name}`)) + assert.equal(plan.kind, 'skip') + assert.ok(plan.message?.includes(reason ?? '')) + } +}) + +test('layout maps target source pages and generated-page owners', () => { + const { state, home, owner } = fixture() + for (const name of ['root.layout.js', 'layout-helper.js']) { + const plan = planWatchEvent(state, classifyWatchEvent('change', `/site/${name}`)) + assert.deepEqual(scope(plan), [[home.pageFile.filepath], [], [owner.pagesFile.filepath]]) + } + state.layoutPageMap.clear() + assert.deepEqual(scope(planWatchEvent(state, classifyWatchEvent('change', '/site/root.layout.js'))), [[], [], [owner.pagesFile.filepath]]) + state.pagesFileLayoutMap.clear() + const unused = planWatchEvent(state, classifyWatchEvent('change', '/site/root.layout.js')) + assert.equal(unused.kind, 'skip') + assert.ok(unused.message?.includes('did not match any rebuild rule')) +}) + +test('page, template, and owner inputs retain their targeted scopes', () => { + const { state, home, other, owner, template } = fixture() + const cases = [ + ['page.md', [home.pageFile.filepath], [], []], + ['page.vars.js', [home.pageFile.filepath], [], []], + ['page-helper.js', [other.pageFile.filepath], [], []], + ['archive.pages.js', [], [], [owner.pagesFile.filepath]], + ['archive-helper.js', [], [], [owner.pagesFile.filepath]], + ['feed.template.js', [], [template.templateFile.filepath], []], + ['template-helper.js', [], [template.templateFile.filepath], []], + ] + for (const [name, ...expected] of cases) { + assert.deepEqual(scope(planWatchEvent(state, classifyWatchEvent('change', `/site/${name}`))), expected) + } +}) + +test('planning unions every shared role, deduplicates consumers, and leaves the snapshot untouched', () => { + const { state, home, other, template, owner } = fixture() + state.layoutDepMap.set('/site/shared.js', new Set(['root', 'another'])) + state.layoutPageMap.set('another', new Set([home])) + state.pageDepMap.set('/site/shared.js', new Set(state.siteData.pages)) + state.templateDepMap.set('/site/shared.js', new Set([template])) + state.pagesFileDepMap.set('/site/shared.js', new Set([owner])) + state.globalDataDepPaths.add('/site/shared.js') + state.esbuildEntryPoints.add('/site/shared.js') + const before = structuredClone(state) + const event = classifyWatchEvent('change', '/site/shared.js') + const first = planWatchEvent(state, event) + assert.deepEqual(scope(first), [[home.pageFile.filepath, other.pageFile.filepath], [template.templateFile.filepath], [owner.pagesFile.filepath]]) + assert.ok(first.message?.includes('rebuilding data subscribers')) + assert.deepEqual(planWatchEvent(state, event), first) + assert.deepEqual(state, before) +}) + +test('global-data-only imports trigger subscriber builds even when they are browser entries', () => { + for (const name of ['data-helper.js', 'client.jsx']) { + const { state } = fixture() + const filepath = `/site/${name}` + state.globalDataDepPaths.add(filepath) + const plan = planWatchEvent(state, classifyWatchEvent('change', filepath)) + assert.deepEqual(scope(plan), [[], [], []], 'recompute data without directly selecting unrelated consumers') + assert.ok(plan.message?.includes('rebuilding data subscribers')) + } +}) + +test('a directly selected layout also rebuilds layouts that import it', () => { + const { state, home, other, owner } = fixture() + state.layoutDepMap.set('/site/root.layout.js', new Set(['child'])) + state.layoutPageMap.set('child', new Set([other])) + const plan = planWatchEvent(state, classifyWatchEvent('change', '/site/root.layout.js')) + assert.deepEqual(scope(plan), [[other.pageFile.filepath, home.pageFile.filepath], [], [owner.pagesFile.filepath]]) +}) + +test('failed page builds retry fully before incomplete maps or browser entries can skip the work', () => { + const { state } = fixture() + const failed = { ...state, pageBuildFailed: true } + for (const name of ['unused.js', 'client.jsx', 'root.layout.js', 'global.data.js']) { + const plan = planWatchEvent(failed, classifyWatchEvent('change', `/site/${name}`)) + assert.deepEqual(scope(plan), [null, null, null]) + assert.ok(plan.message?.includes('retrying all pages after the previous build failure')) + } + assert.equal(planWatchEvent(failed, classifyWatchEvent('change', '/site/global.vars.js')).kind, 'full') + assert.equal(planWatchEvent(failed, classifyWatchEvent('change', '/site/domstack-manifest.settings.js')).kind, 'skip') +}) + +test('structural events distinguish esbuild entries from full rediscovery', () => { + const { state } = fixture() + for (const type of /** @type {const} */ (['added', 'removed'])) { + for (const name of ['client.jsx', 'client.tsx', 'style.css', 'task.worker.js', 'root.layout.client.tsx', 'root.layout.css', 'global.client.js', 'global.css', 'service-worker.js']) { + assert.equal(planWatchEvent(state, classifyWatchEvent(type, `/site/${name}`)).kind, 'restart', name) + } + for (const name of ['page.md', 'root.layout.js', 'archive.pages.js', 'global.data.js', 'dependency.js']) { + assert.equal(planWatchEvent(state, classifyWatchEvent(type, `/site/${name}`)).kind, 'full', name) + } + } +}) + +test('bundle plans cover page, global, layout, removed-layout fallback, and service-worker scopes', () => { + const { state, home, owner } = fixture() + const plan = (/** @type {string} */ name) => planBundleChange(state, classifyWatchEvent('added', `/site/${name}`), src) + assert.deepEqual(scope(plan('client.jsx')), [[home.pageFile.filepath], [], []]) + assert.deepEqual(scope(plan('global.css')), [null, null, null]) + assert.deepEqual(scope(plan('root.layout.css')), [[home.pageFile.filepath], [], [owner.pagesFile.filepath]]) + assert.deepEqual(scope(plan('removed.layout.css')), [null, [], null]) + assert.deepEqual(scope(plan('missing/client.js')), [null, null, null]) + assert.equal(plan('service-worker.js').kind, 'skip') +})