diff --git a/.gitignore b/.gitignore index 35cf472..c017896 100644 --- a/.gitignore +++ b/.gitignore @@ -23,3 +23,4 @@ test-results *.d.ts.map !types/**/*.d.ts !types/**/*.d.ts.map +test-cases/generated-pages/.streaming diff --git a/docs/data/README.md b/docs/data/README.md index 0bc902e..404e166 100644 --- a/docs/data/README.md +++ b/docs/data/README.md @@ -433,11 +433,12 @@ Helpers that process `changes` can use the exported `GlobalDataChanges` type. - State lasts for one watch session, not across process restarts. Always handle `changes.kind === 'reset'`, including after failed builds. -- Markdown and HTML source edits can update individual entries. - Changes to watched modules, including JavaScript/TypeScript pages, helpers, browser entries, and settings, reset the saved index and rebuild the site. +- Source-page edits and tracked helper changes can update individual entries. + Changes to `global.data.*` or settings inputs reset the saved index. - Store structured-cloneable values such as plain records, arrays, or maps—not `PageData` instances, functions, or native objects with shared mutable storage. - A page can appear in `changes.upserted` even when its content is unchanged. Replace its cached entry, or delete it if the page no longer belongs in your index. -- Restart watch mode after changing imported JSON, files read with `fs.readFile()`, environment variables, or other inputs outside page dependency tracking. +- Relative static imports within the watched source tree are tracked, including imported JSON. + Restart watch mode after changing files read with `fs.readFile()`, environment variables, or other inputs outside page dependency tracking. - Static re-exports (`export … from`) are not followed by dependency tracking ([#328](https://github.com/bcomnes/domstack/issues/328)). Use an explicit import followed by a local export, or restart watch mode after those dependencies change. diff --git a/docs/implementation/README.md b/docs/implementation/README.md index a25a520..0a789f5 100644 --- a/docs/implementation/README.md +++ b/docs/implementation/README.md @@ -229,7 +229,7 @@ Require a full page retry`"] Bundle replanning returns only a page plan or a skip; it does not restart esbuild again. After a page-build failure, the next page-producing plan retries the complete page phase rather than trusting incremental filters. -Manifest-settings changes and service-worker entry additions or removals skip page rendering unless an incremental global-data index needs to be reset. +Manifest-settings changes and service-worker entry additions or removals skip page rendering unless the module also has server-side consumers. The one-shot manifest pipeline is not part of watch execution. > [!NOTE] @@ -245,14 +245,11 @@ DOMStack uses these rebuild scopes: - **Full page/template rebuild**: DOMStack renders every source-backed and generated page and every template without restarting esbuild. - **Full rebuild**: DOMStack rediscovers the source tree, restarts esbuild, renders all pages and templates, and refreshes its dependency maps. -Like templates, generated-pages modules rebuild when their own source or watched imported dependencies change. +Like templates, generated-pages modules rebuild when their own source or imported dependencies change. When a targeted build recomputes global data, DOMStack compares top-level values with the previous successful build and adds only subscribers of changed keys to the rebuild set. ### What triggers what -The tables below apply when `global.data.ts` does not save state with `setState`. -With an [incremental index](../data/#incremental-global-data), Markdown and HTML edits can update individual entries, while changes to watched modules reset the index and trigger a **full rebuild**. - | Change | Rebuild scope | |---|---| | Existing `page.ts`, `page.html`, `page.md`, or adjacent `page.vars.ts` | That page, plus subscribers of any changed global-data keys | @@ -261,9 +258,8 @@ With an [incremental index](../data/#incremental-global-data), Markdown and HTML | Existing `*.template.ts` or a module it imports | Affected templates | | Existing `*.pages.ts` | Generated outputs owned by that file, then refresh dependency maps | | A module imported by `*.pages.ts` | Generated outputs owned by the importing files, then refresh dependency maps | -| `markdown-it.settings.ts` | All source-backed Markdown pages, plus subscribers of any changed global-data keys | | `global.data.ts` | Consumers subscribed to top-level keys whose values changed | -| `global.vars.ts` or `esbuild.settings.ts` | Full rebuild | +| `global.vars.ts`, `markdown-it.settings.ts`, `esbuild.settings.ts`, or their tracked imports | Full rebuild and reset of retained global-data state | | `domstack-manifest.settings.ts` | No rebuild. The manifest pipeline is disabled in watch mode | | Existing client, style, Web Worker, or service-worker entry | esbuild only, unless the same module also has server-side consumers | | Static asset under `src` or a file under a `--copy` directory | cpx2 copies or removes the output directly | @@ -283,7 +279,7 @@ When a full page/template rebuild or targeted generated-pages rebuild no longer ### Dependency tracking DOMStack uses [`@11ty/dependency-tree-typescript`](https://github.com/11ty/dependency-tree-typescript) to statically analyze ESM imports. -Imported JSON changes do not trigger page rebuilds; restart watch mode after editing those files. +The page watcher observes tracked imports within the source tree, including JSON files. DOMStack maintains dependency maps for: - Layout dependencies, source-backed pages using each layout, and generated-page owner layout membership @@ -298,8 +294,8 @@ Dependency analysis is best-effort. When DOMStack cannot safely determine a targeted scope, it falls back to a broader rebuild or skips an unrelated changed module. esbuild tracks browser-entry dependencies independently. -A module imported only by `client.ts` normally triggers rebundling without rendering page HTML. -If `global.data.ts` has saved an incremental index, the module edit also resets that index and triggers a full rebuild. +A module imported only by `client.ts` triggers rebundling without rendering page HTML. +If the module also has server-side consumers, DOMStack rebuilds those consumers too. ### Stable entry filenames @@ -326,7 +322,7 @@ Page HTML points to stable entry files during watch mode. esbuild can update an ### Manifest behavior Watch mode builds and rebundles the site service worker, but it does not finalize, return, or write the [DOMStack manifest](../../docs/workers/#domstack-manifest). -Editing `domstack-manifest.settings.ts` only triggers a rebuild when an incremental global-data index needs to be reset; it does not generate a manifest. +Editing `domstack-manifest.settings.ts` does not trigger a watch rebuild unless it is also imported by server-side code. Use `domstack --serve` when testing manifest-driven cache behavior. It runs a one-shot build and serves the result without watch-mode filenames or live-reload HTML injection. diff --git a/index.js b/index.js index b7e58ae..31b284b 100644 --- a/index.js +++ b/index.js @@ -105,10 +105,13 @@ export class DomStack { #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. */ @@ -335,7 +338,9 @@ export class DomStack { const anymatch = (/** @type {string} */name) => ig.ignores(relname(this.#src, name)) const watcher = chokidar.watch(this.#src, { - ignored: (filePath, stats) => anymatch(filePath) || Boolean(stats?.isFile() && !isProcessedFile(filePath)), + // 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 @@ -453,21 +458,43 @@ export class DomStack { pagesFileDepMap: this.#pagesFileDepMap, pagesFileLayoutMap: this.#pagesFileLayoutMap, globalDataDepPaths: this.#globalDataDepPaths, - hasGlobalDataState: this.#watchSession?.globalDataBaseline?.state !== undefined, + 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) - // Preserve single-event bundle routing unless retained state needs a reset. - const singlePlan = !(snapshot.hasGlobalDataState && inputChanges.resetReason) && + // 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 @@ -703,15 +730,27 @@ export class DomStack { const pageDepMap = /** @type {Map>} */ (new Map()) const templateDepMap = /** @type {Map>} */ (new Map()) const pagesFileDepMap = /** @type {Map>} */ (new Map()) - const globalDataDepPaths = new Set() - if (siteData.globalData) { - globalDataDepPaths.add(siteData.globalData.filepath) - try { - for (const dep of await find(siteData.globalData.filepath)) globalDataDepPaths.add(resolve(dep)) - } catch { - // Static import analysis is best-effort, as for page and layout helpers. + 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)) { @@ -728,7 +767,7 @@ export class DomStack { layoutDepMap.get(absPath)?.add(layout.layoutName) } } catch { - // dep analysis is best-effort + dependencyAnalysisFailed = true } } @@ -748,7 +787,7 @@ export class DomStack { // pageDepMap: dep filepath → Set for (const pageInfo of siteData.pages) { - const filesToTrack = [pageInfo.pageFile.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 { @@ -759,7 +798,7 @@ export class DomStack { pageDepMap.get(absPath)?.add(pageInfo) } } catch { - // best-effort + dependencyAnalysisFailed = true } } } @@ -774,7 +813,7 @@ export class DomStack { templateDepMap.get(absPath)?.add(templateInfo) } } catch { - // best-effort + dependencyAnalysisFailed = true } } @@ -788,6 +827,7 @@ export class DomStack { 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}`) } @@ -804,6 +844,16 @@ export class DomStack { 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 @@ -812,7 +862,10 @@ export class DomStack { this.#templateDepMap = templateDepMap this.#pagesFileDepMap = pagesFileDepMap this.#globalDataDepPaths = globalDataDepPaths + this.#settingsDepPaths = settingsDepPaths + this.#dependencyAnalysisFailed = dependencyAnalysisFailed this.#esbuildEntryPoints = esbuildEntryPoints + this.#esbuildDepPaths = esbuildDepPaths } /** diff --git a/lib/watch-plan.js b/lib/watch-plan.js index d41efed..48a7dcd 100644 --- a/lib/watch-plan.js +++ b/lib/watch-plan.js @@ -12,9 +12,11 @@ * @property {ReadonlyMap>} pagesFileDepMap * @property {ReadonlyMap>} pagesFileLayoutMap * @property {ReadonlySet} globalDataDepPaths - * @property {boolean} [hasGlobalDataState] The watch session retains producer state. + * @property {ReadonlySet} [settingsDepPaths] Settings roots and imports require a full rebuild. + * @property {boolean} [dependencyAnalysisFailed] * @property {boolean} pageBuildFailed * @property {ReadonlySet} esbuildEntryPoints + * @property {ReadonlySet} [esbuildDepPaths] Known imported browser inputs; server roles still take precedence. * @typedef {ReturnType} WatchEvent * @typedef {object} PagePlan * @property {'pages'} kind @@ -53,12 +55,16 @@ export function classifyWatchEvent (type, filepath) { export function planWatchEvent (state, event) { const { type, filepath, name, convention } = event const { siteData } = state + if (convention?.change === 'full' || convention?.change === 'markdown' || state.settingsDepPaths?.has(filepath)) { + const action = type === 'change' ? 'changed' : type + return { kind: 'full', message: `"${name}" ${action}, triggering full rebuild...` } + } 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 === 'manifest' && !hasWatchRole(state, filepath)) { return { kind: 'skip', message: `"${name}" changed but domstack manifests are disabled in watch mode, skipping.` } } @@ -71,12 +77,6 @@ export function planWatchEvent (state, event) { if (directLayout) layouts.add(directLayout) const affected = layoutConsumers(state, layouts) const pages = new Set(state.pageDepMap.get(filepath)) - const markdownChanged = convention?.change === 'markdown' - if (markdownChanged) { - for (const page of siteData.pages) { - if (page.type === 'md') pages.add(page) - } - } const directPage = state.pageFileMap.get(filepath) if (directPage) pages.add(directPage) for (const page of affected.pages) pages.add(page) @@ -96,7 +96,7 @@ export function planWatchEvent (state, event) { // Even without direct consumers, a producer input must recompute global data. // The page worker selects subscribers after comparing the resulting values. const globalDataChanged = convention?.change === 'pages' || state.globalDataDepPaths.has(filepath) - if (globalDataChanged || markdownChanged || pages.size || templates.size || owners.size) { + if (globalDataChanged || pages.size || templates.size || owners.size) { const plan = selectedPages([...pages]) plan.templates = [...templates] plan.templateFilterPaths = plan.templates.map(template => template.templateFile.filepath) @@ -104,7 +104,7 @@ export function planWatchEvent (state, event) { if (globalDataChanged) plan.message = `"${name}" changed, rebuilding data subscribers...` return plan } - if (state.esbuildEntryPoints.has(filepath)) { + if (state.esbuildEntryPoints.has(filepath) || state.esbuildDepPaths?.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.` } @@ -119,21 +119,18 @@ export function planWatchEvent (state, event) { * lines in event order so logging retains every selected consumer and reason. * * A full output plan requires rediscovery, but does not itself reset inputs. - * Structural source events upsert mapped source consumers plus the event path - * (including removed paths). Other structural events upsert all + * Structural source events upsert the change-plan's source consumers plus the + * event path (including removed paths). Other structural events upsert all * current source pages. The worker must reconcile membership after rediscovery; * these candidates are not instructions to read deleted files. Templates and * generated-page owners are output selections, not source-input rows. * - * A reset supersedes the delta. Reasons are page-build-failed, then the first - * applicable event reason, using this per-event priority: + * A reset supersedes the delta. Reasons are page-build-failed, then + * dependency-analysis-failed, then the first applicable event reason, using + * this per-event priority: * unreliable-event, global-data-changed, global-vars-changed, * markdown-settings-changed, global-config-changed, or unknown-event. - * Only non-module source texts (.md/.html) can use deltas. - * Other inputs may also configure global vars or Markdown rendering. - * Resets force full output rebuilds only when producer state is retained; - * without retained state, legacy event routing still determines output scope. - * Failed builds retain their recovery plan; the executor handles rediscovery. + * Unknown changes reset; unknown adds/removes can use conservative deltas. * Empty batches skip without requesting recovery. Inputs are never mutated. * * @param {WatchSnapshot} state @@ -147,7 +144,9 @@ export function planWatchBatch (state, events) { const inputChanges = { upsertedPaths: [], events: [...events] } if (!events.length) return { plan, inputChanges } - let resetReason = state.pageBuildFailed ? 'page-build-failed' : undefined + let resetReason = state.pageBuildFailed + ? 'page-build-failed' + : state.dependencyAnalysisFailed ? 'dependency-analysis-failed' : undefined const sourcePaths = new Set(/** @type {string[]} */ ([])) for (const page of state.siteData.pages) { if (!page.generated) sourcePaths.add(page.pageFile.filepath) @@ -163,10 +162,10 @@ export function planWatchBatch (state, events) { // Only source membership changes need mapped consumers; other structural // events already invalidate every current source input. const changePlan = sourceEvent ? planWatchEvent(state, { ...event, type: 'change' }) : eventPlan - const reason = inputResetReason(state, event) + const reason = inputResetReason(state, event, changePlan) resetReason ??= reason plan = unionWatchPlans(plan, eventPlan) - if (reason === 'unreliable-event') { + if (reason === 'unknown-event' || reason === 'unreliable-event') { plan = { kind: 'full', message: `"${event.name}" cannot be routed reliably, triggering full rebuild...` } } @@ -185,10 +184,8 @@ export function planWatchBatch (state, events) { } if (structural && sourceEvent) upsertedPaths.add(event.filepath) } - if (state.pageBuildFailed) { + if (state.pageBuildFailed || state.dependencyAnalysisFailed) { plan = unionWatchPlans(plan, allPages()) - } else if (state.hasGlobalDataState && resetReason) { - plan = unionWatchPlans(plan, { kind: 'full', message: 'Producer inputs require a reset, triggering full rebuild...' }) } if (resetReason) inputChanges.resetReason = resetReason inputChanges.upsertedPaths = [...upsertedPaths] @@ -198,20 +195,19 @@ export function planWatchBatch (state, events) { /** * @param {WatchSnapshot} state * @param {WatchEvent} event + * @param {WatchPlan} changePlan */ -function inputResetReason (state, { type, filepath, name, convention }) { +function inputResetReason (state, { type, filepath, name, convention }, changePlan) { if (type !== 'change' && type !== 'added' && type !== 'removed') return 'unreliable-event' if (convention?.change === 'pages' || state.globalDataDepPaths.has(filepath)) return 'global-data-changed' if (fileConventions.globalVars.names.includes(name)) return 'global-vars-changed' if (convention?.change === 'markdown') return 'markdown-settings-changed' - if (convention?.change === 'full') return 'global-config-changed' - const sourcePage = state.pageFileMap.get(filepath) - const sourceText = name.endsWith('.md') || name.endsWith('.html') - if (sourceText && ( - (sourcePage && !sourcePage.generated && sourcePage.pageFile.filepath === filepath) || - (type !== 'change' && (name.endsWith('.md') || convention?.change === 'page')) - )) return - return 'unknown-event' + if (convention?.change === 'full' || state.settingsDepPaths?.has(filepath)) return 'global-config-changed' + if (type === 'change' && changePlan.kind === 'skip' && + convention?.change !== 'manifest' && !state.esbuildEntryPoints.has(filepath) && + !state.esbuildDepPaths?.has(filepath) && !hasWatchRole(state, filepath)) { + return 'unknown-event' + } } /** @param {WatchSnapshot} state @param {string} filepath */ diff --git a/lib/watch-plan.test.js b/lib/watch-plan.test.js index b9c285c..787fa73 100644 --- a/lib/watch-plan.test.js +++ b/lib/watch-plan.test.js @@ -19,7 +19,7 @@ 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 (hasGlobalDataState = true) { +function fixture () { const home = page('') const other = page('other', 'js') const template = { templateFile: file('feed.template.js'), path: '', outputName: 'feed.xml' } @@ -29,16 +29,18 @@ function fixture (hasGlobalDataState = true) { 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], [other.pageFile.filepath, other], ['/site/page.vars.js', 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']), - hasGlobalDataState, + settingsDepPaths: new Set(), + dependencyAnalysisFailed: false, pageBuildFailed: false, esbuildEntryPoints: new Set(['/site/client.jsx', '/site/root.layout.css']), + esbuildDepPaths: new Set(), }) return { state, home, other, template, owner } } @@ -50,176 +52,98 @@ function scope (plan) { return [plan.pageFilterPaths, plan.templateFilterPaths, plan.pagesFileFilterPaths] } -test('source text retains targeted scopes while modules and configuration roots reset retained state', () => { - const { state, home } = fixture() - const source = planWatchBatch(state, [classifyWatchEvent('change', home.pageFile.filepath)]) - assert.deepEqual(scope(source.plan), [[home.pageFile.filepath], [], []]) - assert.equal(source.inputChanges.resetReason, undefined) - for (const name of ['page.vars.js', 'other/page.js', 'new/page.mjs']) { - for (const type of /** @type {const} */ (['change', 'added', 'removed'])) { - const batch = planWatchBatch(state, [classifyWatchEvent(type, `/site/${name}`)]) - assert.equal(batch.plan.kind, 'full') - assert.equal(batch.inputChanges.resetReason, 'unknown-event') - } +test('settings and untracked changes produce inspectable full, page, and skip plans', () => { + const { state } = fixture() + for (const name of ['global.vars.js', 'esbuild.settings.js', 'markdown-it.settings.js']) { + assert.equal(planWatchEvent(state, classifyWatchEvent('change', `/site/${name}`)).kind, 'full') } - state.globalDataDepPaths.clear() assert.deepEqual(scope(planWatchEvent(state, classifyWatchEvent('change', '/site/global.data.js'))), [[], [], []]) - for (const [name, reason] of [ - ['global.data.js', 'global-data-changed'], - ['global.vars.js', 'global-vars-changed'], - ['markdown-it.settings.js', 'markdown-settings-changed'], - ['esbuild.settings.js', 'global-config-changed'], - ]) { - for (const type of /** @type {const} */ (['change', 'added', 'removed'])) { - const batch = planWatchBatch(state, [classifyWatchEvent(type, `/site/${name}`)]) - assert.equal(batch.inputChanges.resetReason, reason) - assert.equal(batch.plan.kind, 'full') - } + 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 ?? '')) } - const skipped = planWatchEvent(state, classifyWatchEvent('change', '/site/domstack-manifest.settings.js')) - assert.equal(skipped.kind, 'skip') - assert.ok(skipped.message?.includes('disabled')) }) -test('helpers, browser entries, layouts, templates and factories reset and rebuild fully even with known consumers', () => { - const { state } = fixture() - for (const name of ['page-helper.js', 'layout-helper.js', 'template-helper.js', 'archive-helper.js', 'unknown.js', 'client.jsx', 'root.layout.css', 'root.layout.js', 'feed.template.js', 'archive.pages.js', 'domstack-manifest.settings.js']) { - const event = classifyWatchEvent('change', `/site/${name}`) - const batch = planWatchBatch(state, [event]) - assert.equal(batch.plan.kind, 'full', name) - assert.equal(batch.inputChanges.resetReason, 'unknown-event', name) +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() - assert.equal(planWatchBatch(state, [classifyWatchEvent('change', '/site/root.layout.js')]).plan.kind, 'full', 'an unused layout can still have an untracked configuration role') + 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('without retained state legacy helper scopes and browser skips survive input reset requests', () => { - for (const omitFlag of [false, true]) { - const { state: snapshot, home, other, template, owner } = fixture(false) - const state = /** @type {WatchSnapshot} */ ({ ...snapshot }) - if (omitFlag) delete state.hasGlobalDataState - for (const [name, expected, reason] of /** @type {const} */ ([ - ['page-helper.js', [[other.pageFile.filepath], [], []], 'unknown-event'], - ['page.vars.js', [[home.pageFile.filepath], [], []], 'unknown-event'], - ['other/page.js', [[other.pageFile.filepath], [], []], 'unknown-event'], - ['layout-helper.js', [[home.pageFile.filepath], [], [owner.pagesFile.filepath]], 'unknown-event'], - ['root.layout.js', [[home.pageFile.filepath], [], [owner.pagesFile.filepath]], 'unknown-event'], - ['template-helper.js', [[], [template.templateFile.filepath], []], 'unknown-event'], - ['archive-helper.js', [[], [], [owner.pagesFile.filepath]], 'unknown-event'], - ['feed.template.js', [[], [template.templateFile.filepath], []], 'unknown-event'], - ['archive.pages.js', [[], [], [owner.pagesFile.filepath]], 'unknown-event'], - ['global.data.js', [[], [], []], 'global-data-changed'], - ['markdown-it.settings.js', [[home.pageFile.filepath], [], []], 'markdown-settings-changed'], - ])) { - const event = classifyWatchEvent('change', `/site/${name}`) - const batch = planWatchBatch(state, [event]) - assert.deepEqual(scope(batch.plan), expected, name) - assert.deepEqual(batch.plan, planWatchEvent(state, event), name) - assert.equal(batch.inputChanges.resetReason, reason, name) - } - for (const name of ['client.jsx', 'root.layout.css', 'unknown.js', 'domstack-manifest.settings.js']) { - const event = classifyWatchEvent('change', `/site/${name}`) - const batch = planWatchBatch(state, [event]) - assert.equal(batch.plan.kind, 'skip', name) - assert.deepEqual(batch.plan, planWatchEvent(state, event), name) - assert.deepEqual(batch.inputChanges, { resetReason: 'unknown-event', upsertedPaths: [], events: [event] }) - } - snapshot.globalDataDepPaths.add('/site/client.jsx') - const imported = planWatchBatch(state, [classifyWatchEvent('change', '/site/client.jsx')]) - assert.deepEqual(scope(imported.plan), [[], [], []], 'producer imports take precedence over browser skips') - assert.equal(imported.inputChanges.resetReason, 'global-data-changed') +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('direct Markdown settings still union every known consumer before browser skips', () => { - const { state, home, other, template, owner } = fixture(false) - const filepath = '/site/markdown-it.settings.js' - state.pageDepMap.set(filepath, new Set([other])) - state.layoutDepMap.set(filepath, new Set(['root'])) - state.templateDepMap.set(filepath, new Set([template])) - state.pagesFileDepMap.set(filepath, new Set([owner])) - state.esbuildEntryPoints.add(filepath) - const event = classifyWatchEvent('change', filepath) - assert.deepEqual(scope(planWatchEvent(state, event)), [[other.pageFile.filepath, home.pageFile.filepath], [template.templateFile.filepath], [owner.pagesFile.filepath]]) - const batch = planWatchBatch(state, [event]) - assert.deepEqual(batch.plan, planWatchEvent(state, event)) - assert.equal(batch.inputChanges.resetReason, 'markdown-settings-changed') -}) - -test('shared helpers cannot hide configuration roles behind precise server or browser consumers', () => { - const { state, template, owner } = fixture() - const filepath = '/site/shared.js' - state.layoutDepMap.set(filepath, new Set(['root'])) - state.pageDepMap.set(filepath, new Set(state.siteData.pages)) - state.templateDepMap.set(filepath, new Set([template])) - state.pagesFileDepMap.set(filepath, new Set([owner])) - state.esbuildEntryPoints.add(filepath) +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', filepath) - const batch = planWatchBatch(state, [event]) - assert.equal(batch.plan.kind, 'full') - assert.equal(batch.inputChanges.resetReason, 'unknown-event') - assert.deepEqual(planWatchBatch(state, [event]), batch) + 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 imports reset and rebuild fully on every event even with shared roles', () => { - for (const type of /** @type {const} */ (['change', 'added', 'removed'])) { - for (const name of ['data-helper.js', 'client.jsx', 'page.md', 'domstack-manifest.settings.js']) { - const { state, other } = fixture() - const filepath = `/site/${name}` - state.globalDataDepPaths.add(filepath) - state.pageDepMap.set(filepath, new Set([other])) - const batch = planWatchBatch(state, [classifyWatchEvent(type, filepath)]) - assert.equal(batch.plan.kind, 'full', `${type} ${name}`) - assert.equal(batch.inputChanges.resetReason, 'global-data-changed') - } +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('manifest settings imported by server consumers do not hide those roles', () => { - const { state, other } = fixture() - const filepath = '/site/domstack-manifest.settings.js' - state.pageDepMap.set(filepath, new Set([other])) - const batch = planWatchBatch(state, [classifyWatchEvent('change', filepath)]) - assert.equal(batch.plan.kind, 'full') - assert.equal(batch.inputChanges.resetReason, 'unknown-event') -}) - -test('direct source text selections union mapped consumers without mutating the snapshot', () => { - const { state, home, other, template, owner } = fixture() - const filepath = home.pageFile.filepath - state.layoutDepMap.set(filepath, new Set(['root', 'another'])) - state.layoutPageMap.set('another', new Set([home])) - state.pageDepMap.set(filepath, new Set([other])) - state.templateDepMap.set(filepath, new Set([template])) - state.pagesFileDepMap.set(filepath, new Set([owner])) - const before = structuredClone(state) - const batch = planWatchBatch(state, [classifyWatchEvent('change', filepath)]) - assert.deepEqual(scope(batch.plan), [[other.pageFile.filepath, home.pageFile.filepath], [template.templateFile.filepath], [owner.pagesFile.filepath]]) - assert.equal(batch.inputChanges.resetReason, undefined) - assert.deepEqual(state, before) +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 builds preserve recovery plans and override event reset reasons', () => { +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 ['page.md', 'page.vars.js', 'global.data.js', 'unused.js', 'client.jsx', 'root.layout.js', 'markdown-it.settings.js']) { - const event = classifyWatchEvent('change', `/site/${name}`) - const batch = planWatchBatch(failed, [event]) - assert.deepEqual(scope(batch.plan), [null, null, null]) - assert.ok(batch.plan.message?.includes('retrying all pages after the previous build failure')) - assert.equal(batch.inputChanges.resetReason, 'page-build-failed') + 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')) } - for (const name of ['global.vars.js', 'esbuild.settings.js']) { - const batch = planWatchBatch(failed, [classifyWatchEvent('change', `/site/${name}`)]) - assert.equal(batch.plan.kind, 'full') - assert.equal(batch.inputChanges.resetReason, 'page-build-failed') + for (const name of ['global.vars.js', 'markdown-it.settings.js']) { + assert.equal(planWatchEvent(failed, classifyWatchEvent('change', `/site/${name}`)).kind, 'full') } - const skipped = planWatchBatch(failed, [classifyWatchEvent('change', '/site/domstack-manifest.settings.js')]) - assert.deepEqual(scope(skipped.plan), [null, null, null], 'a nonempty recovery batch cannot skip') - assert.equal(skipped.inputChanges.resetReason, 'page-build-failed') + assert.equal(planWatchEvent(failed, classifyWatchEvent('change', '/site/domstack-manifest.settings.js')).kind, 'skip') }) test('structural events distinguish esbuild entries from full rediscovery', () => { @@ -279,31 +203,95 @@ test('classification resolves paths without changing the public event shape', () assert.equal(classifyWatchEvent('change', '/site/nested/../page.md').filepath, '/site/page.md') }) +test('snapshots without optional dependency analysis fields remain compatible', () => { + const { state, home } = fixture() + const { settingsDepPaths, dependencyAnalysisFailed, esbuildDepPaths, ...legacy } = state + assert.deepEqual(scope(planWatchEvent(legacy, classifyWatchEvent('change', '/site/page.md'))), [[home.pageFile.filepath], [], []]) + assert.equal(planWatchBatch(legacy, [classifyWatchEvent('change', '/site/page.md')]).inputChanges.resetReason, undefined) + assert.equal(planWatchBatch(legacy, [classifyWatchEvent('change', '/site/unknown.js')]).inputChanges.resetReason, 'unknown-event') +}) + +test('settings roots and dependencies always rebuild fully and reset inputs regardless of shared roles', () => { + const cases = /** @type {const} */ ([ + ['global.vars.js', false, 'global-vars-changed'], + ['markdown-it.settings.js', false, 'markdown-settings-changed'], + ['esbuild.settings.js', false, 'global-config-changed'], + ['vars-helper.js', true, 'global-config-changed'], + ['markdown-helper.js', true, 'global-config-changed'], + ['client.jsx', true, 'global-config-changed'], + ['page.md', true, 'global-config-changed'], + ['domstack-manifest.settings.js', true, 'global-config-changed'], + ]) + for (const type of /** @type {const} */ (['change', 'added', 'removed'])) { + for (const [name, dependency, reason] of cases) { + const { state, home, other, template, owner } = fixture() + const filepath = `/site/${name}` + if (dependency) state.settingsDepPaths.add(filepath) + const event = classifyWatchEvent(type, filepath) + assert.equal(planWatchEvent(state, event).kind, 'full', `${type} ${name}`) + assert.equal(planWatchBatch(state, [event]).inputChanges.resetReason, reason, `${type} ${name}`) + + state.settingsDepPaths.add(filepath) + state.pageDepMap.set(filepath, new Set([other])) + state.layoutDepMap.set(filepath, new Set(['root'])) + state.templateDepMap.set(filepath, new Set([template])) + state.pagesFileDepMap.set(filepath, new Set([owner])) + state.esbuildEntryPoints.add(filepath) + state.esbuildDepPaths.add(filepath) + const before = structuredClone(state) + assert.equal(planWatchEvent(state, event).kind, 'full', `shared ${type} ${name}`) + const batch = planWatchBatch(state, [event]) + assert.equal(batch.plan.kind, 'full', `shared ${type} ${name}`) + assert.deepEqual(batch.inputChanges, { + upsertedPaths: [home.pageFile.filepath, other.pageFile.filepath], + resetReason: reason, + events: [event], + }, `shared ${type} ${name}`) + assert.deepEqual(state, before) + + state.globalDataDepPaths.add(filepath) + assert.equal(planWatchBatch(state, [event]).inputChanges.resetReason, 'global-data-changed', `global data ${type} ${name}`) + } + } +}) + +test('manifest settings imported by server consumers do not hide those roles', () => { + const { state, other } = fixture() + const filepath = '/site/domstack-manifest.settings.js' + state.pageDepMap.set(filepath, new Set([other])) + state.globalDataDepPaths.add(filepath) + const event = classifyWatchEvent('change', filepath) + assert.deepEqual(scope(planWatchEvent(state, event)), [[other.pageFile.filepath], [], []]) + assert.equal(planWatchBatch(state, [event]).inputChanges.resetReason, 'global-data-changed') +}) + test('batch unions filtered output scopes and source inputs, retaining event order and duplicates', () => { - const { state, home, other } = fixture() - state.pageDepMap.set(home.pageFile.filepath, new Set([other])) - const events = ['page.md', 'page.md', 'page.md'] + const { state, home, other, template, owner } = fixture() + const events = ['page-helper.js', 'layout-helper.js', 'template-helper.js', 'archive-helper.js', 'page.md', 'client.jsx', 'page-helper.js'] .map(name => classifyWatchEvent('change', `/site/${name}`)) const before = structuredClone({ state, events }) events.forEach(Object.freeze) Object.freeze(events) const batch = planWatchBatch(state, events) - assert.deepEqual(scope(batch.plan), [[other.pageFile.filepath, home.pageFile.filepath], [], []]) + assert.deepEqual(scope(batch.plan), [[other.pageFile.filepath, home.pageFile.filepath], [template.templateFile.filepath], [owner.pagesFile.filepath]]) assert.equal(batch.plan.kind, 'pages') if (batch.plan.kind !== 'pages') throw new Error('Expected a page plan') assert.deepEqual(batch.plan.pages, [other, home]) - assert.deepEqual(batch.plan.templates, []) - assert.equal(batch.plan.message, undefined, 'source-only batches need no reset message') - assert.deepEqual(batch.inputChanges, { upsertedPaths: [other.pageFile.filepath, home.pageFile.filepath], events }) + assert.deepEqual(batch.plan.templates, [template]) + assert.equal(batch.plan.message, undefined, 'browser skip messages do not replace rebuild logging') + assert.deepEqual(batch.inputChanges, { + upsertedPaths: [other.pageFile.filepath, home.pageFile.filepath], + events, + }) assert.notEqual(batch.inputChanges.events, events) assert.deepEqual(planWatchBatch(state, events), batch) assert.deepEqual({ state, events }, before) }) -test('without retained state single page plans preserve metadata even when surrounded by skips', () => { - const { state } = fixture(false) - const skipped = classifyWatchEvent('change', '/site/domstack-manifest.settings.js') - for (const name of ['page.md', 'page.vars.js', 'global.data.js']) { +test('single page plans preserve exact logging metadata and messages even when surrounded by skips', () => { + const { state } = fixture() + const skipped = classifyWatchEvent('change', '/site/client.jsx') + for (const name of ['page.md', 'layout-helper.js', 'feed.template.js', 'global.data.js']) { const event = classifyWatchEvent('change', `/site/${name}`) const expected = planWatchEvent(state, event) assert.equal(expected.kind, 'pages') @@ -318,32 +306,78 @@ test('without retained state single page plans preserve metadata even when surro }) test('page-plan unions retain distinct messages in event order without duplicate lines', () => { - const { state, home } = fixture(false) + const { state, home, template } = fixture() + state.globalDataDepPaths.add('/site/data-helper.js') const data = classifyWatchEvent('change', '/site/global.data.js') - const otherData = classifyWatchEvent('change', '/site/nested/global.data.mjs') + const helper = classifyWatchEvent('change', '/site/data-helper.js') const pageEvent = classifyWatchEvent('change', home.pageFile.filepath) - for (const [first, second] of [[data, otherData], [otherData, data]]) { + const templateEvent = classifyWatchEvent('change', template.templateFile.filepath) + for (const [first, second] of [[data, helper], [helper, data]]) { assert.ok(first && second) - const batch = planWatchBatch(state, [first, pageEvent, second, first, second]) + const batch = planWatchBatch(state, [first, pageEvent, second, templateEvent, first, second]) assert.equal(batch.plan.kind, 'pages') if (batch.plan.kind !== 'pages') throw new Error('Expected a page plan') assert.deepEqual(batch.plan.pages, [home]) + assert.deepEqual(batch.plan.templates, [template]) assert.equal(batch.plan.message, [planWatchEvent(state, first).message, planWatchEvent(state, second).message].join('\n')) } }) -test('empty batches never reset or request recovery', () => { - for (const hasGlobalDataState of [true, false]) { - const { state } = fixture(hasGlobalDataState) - for (const pageBuildFailed of [true, false]) { - const batch = planWatchBatch({ ...state, pageBuildFailed }, []) - assert.equal(batch.plan.kind, 'skip') - assert.deepEqual(batch.inputChanges, { upsertedPaths: [], events: [] }) - } +test('known browser-only dependencies skip page rebuilding in both planners without resetting inputs', () => { + const { state } = fixture() + for (const filepath of ['/site/browser-helper.js', '/site/nested/client.js']) { + state.esbuildDepPaths.add(filepath) + assert.equal(state.esbuildEntryPoints.has(filepath), false) + const event = classifyWatchEvent('change', filepath) + const single = planWatchEvent(state, event) + assert.equal(single.kind, 'skip') + assert.ok(single.message?.includes('esbuild will handle rebundling')) + const batch = planWatchBatch(state, [event]) + assert.deepEqual(batch.plan, single) + assert.deepEqual(batch.inputChanges, { upsertedPaths: [], events: [event] }) + } + const unknown = planWatchBatch(state, [classifyWatchEvent('change', '/site/unknown.js')]) + assert.equal(unknown.plan.kind, 'full') + assert.equal(unknown.inputChanges.resetReason, 'unknown-event') +}) + +test('template and generated-page owner changes do not invent source inputs', () => { + const { state, template, owner } = fixture() + const batch = planWatchBatch(state, ['feed.template.js', 'archive.pages.js', 'archive-helper.js'] + .map(name => classifyWatchEvent('change', `/site/${name}`))) + assert.deepEqual(scope(batch.plan), [[], [template.templateFile.filepath], [owner.pagesFile.filepath]]) + assert.deepEqual(batch.inputChanges.upsertedPaths, []) + assert.equal(batch.inputChanges.resetReason, undefined) +}) + +test('null filters dominate filtered scopes and failure resets override event reasons', () => { + for (const flag of /** @type {const} */ (['pageBuildFailed', 'dependencyAnalysisFailed'])) { + const { state: snapshot } = fixture() + const state = { ...snapshot, pageBuildFailed: flag === 'pageBuildFailed', dependencyAnalysisFailed: flag === 'dependencyAnalysisFailed' } + const events = ['page.md', 'feed.template.js', 'global.data.js', 'client.jsx'].map(name => classifyWatchEvent('change', `/site/${name}`)) + const batch = planWatchBatch(state, events) + assert.deepEqual(scope(batch.plan), [null, null, null]) + assert.equal(batch.inputChanges.resetReason, flag === 'pageBuildFailed' ? 'page-build-failed' : 'dependency-analysis-failed') + const skipped = planWatchBatch(state, [classifyWatchEvent('change', '/site/domstack-manifest.settings.js')]) + assert.deepEqual(scope(skipped.plan), [null, null, null], 'a nonempty recovery batch cannot skip') + state.pageBuildFailed = state.dependencyAnalysisFailed = true + assert.equal(planWatchBatch(state, events).inputChanges.resetReason, 'page-build-failed') + } +}) + +test('empty and intentionally skipped batches have no input changes', () => { + const { state } = fixture() + for (const events of [[], ['client.jsx', 'domstack-manifest.settings.js'].map(name => classifyWatchEvent('change', `/site/${name}`))]) { + const batch = planWatchBatch(state, events) + assert.equal(batch.plan.kind, 'skip') + assert.deepEqual(batch.inputChanges, { upsertedPaths: [], events }) } + const failed = { ...state, pageBuildFailed: true, dependencyAnalysisFailed: true } + assert.deepEqual(planWatchBatch(failed, []).inputChanges, { upsertedPaths: [], events: [] }) + assert.equal(planWatchBatch(failed, []).plan.kind, 'skip') }) -test('structural source pages carry membership candidates and mapped consumers without resetting', () => { +test('structural source pages carry membership candidates and change-plan consumers without resetting', () => { for (const type of /** @type {const} */ (['added', 'removed'])) { for (const name of ['page.md', 'new/page.html', 'new/post.md']) { const { state, home, other } = fixture() @@ -360,29 +394,6 @@ test('structural source pages carry membership candidates and mapped consumers w } }) -test('Markdown and HTML source edits use deltas only when mapped and not global-data dependencies', () => { - for (const type of /** @type {const} */ (['md', 'html'])) { - const { state } = fixture() - const post = { ...page('posts', type), pageFile: file(`posts/article.${type}`) } - state.siteData.pages.push(post) - state.pageFileMap.set(post.pageFile.filepath, post) - const event = classifyWatchEvent('change', post.pageFile.filepath) - const batch = planWatchBatch(state, [event]) - assert.deepEqual(scope(batch.plan), [[post.pageFile.filepath], [], []]) - assert.deepEqual(batch.inputChanges.upsertedPaths, [post.pageFile.filepath]) - assert.equal(batch.inputChanges.resetReason, undefined) - state.globalDataDepPaths.add(event.filepath) - const imported = planWatchBatch(state, [event]) - assert.equal(imported.plan.kind, 'full') - assert.equal(imported.inputChanges.resetReason, 'global-data-changed') - state.globalDataDepPaths.clear() - state.pageFileMap.delete(event.filepath) - const unmapped = planWatchBatch(state, [event]) - assert.equal(unmapped.plan.kind, 'full') - assert.equal(unmapped.inputChanges.resetReason, 'unknown-event') - } -}) - test('structural events preserve replacement and removal order for membership reconciliation', () => { const { state } = fixture() const events = [ @@ -396,7 +407,7 @@ test('structural events preserve replacement and removal order for membership re assert.deepEqual(batch.inputChanges, { upsertedPaths: ['/site/page.md', '/site/page.html', '/site/new/post.md'], events }) }) -test('structural page vars and other non-source-text inputs reset with source-only candidates', () => { +test('structural layout, vars, unknown, template, and owner events conservatively upsert only source pages', () => { for (const type of /** @type {const} */ (['added', 'removed'])) { for (const name of ['page.vars.js', 'new/page.vars.mjs', 'root.layout.js', 'new.layout.js', 'helper.js', 'feed.template.js', 'archive.pages.js']) { const { state, home, other, owner } = fixture() @@ -404,9 +415,7 @@ test('structural page vars and other non-source-text inputs reset with source-on const event = classifyWatchEvent(type, `/site/${name}`) const batch = planWatchBatch(state, [event]) assert.equal(batch.plan.kind, 'full') - assert.deepEqual(batch.inputChanges.upsertedPaths, [home.pageFile.filepath, other.pageFile.filepath]) - assert.deepEqual(batch.inputChanges.events, [event]) - assert.equal(batch.inputChanges.resetReason, 'unknown-event') + assert.deepEqual(batch.inputChanges, { upsertedPaths: [home.pageFile.filepath, other.pageFile.filepath], events: [event] }, `${type} ${name}`) } } }) @@ -414,7 +423,7 @@ test('structural page vars and other non-source-text inputs reset with source-on test('repeated all-source events preserve candidate order and later membership changes', () => { const { state, home, other } = fixture() const events = [ - classifyWatchEvent('change', '/site/other/page.js'), + classifyWatchEvent('change', '/site/page-helper.js'), classifyWatchEvent('added', '/site/first/post.md'), classifyWatchEvent('added', '/site/feed.template.js'), classifyWatchEvent('removed', '/site/root.layout.js'), @@ -427,13 +436,13 @@ test('repeated all-source events preserve candidate order and later membership c assert.equal(batch.plan.kind, 'full') assert.deepEqual(batch.inputChanges, { upsertedPaths: [other.pageFile.filepath, '/site/first/post.md', home.pageFile.filepath, '/site/second/post.md'], - resetReason: 'unknown-event', + resetReason: 'global-vars-changed', events, }) assert.deepEqual(state, before) }) -test('batch bundle restarts become full resets even when mixed with source deltas', () => { +test('batch bundle restarts become full without resetting, including when mixed with page or full plans', () => { for (const name of ['client.jsx', 'root.layout.css', 'global.css', 'service-worker.js']) { const { state, home, other } = fixture() const restart = classifyWatchEvent('removed', `/site/${name}`) @@ -441,31 +450,57 @@ test('batch bundle restarts become full resets even when mixed with source delta for (const events of [[restart], [restart, classifyWatchEvent('change', '/site/page.md')], [classifyWatchEvent('added', '/site/new/page.md'), restart], [restart, classifyWatchEvent('added', '/site/new/page.md')]]) { const batch = planWatchBatch(state, events) assert.equal(batch.plan.kind, 'full') - assert.equal(batch.inputChanges.resetReason, 'unknown-event') + assert.equal(batch.inputChanges.resetReason, undefined) assert.ok(batch.inputChanges.upsertedPaths.includes(home.pageFile.filepath)) assert.ok(batch.inputChanges.upsertedPaths.includes(other.pageFile.filepath)) } } }) -test('unsupported event types force full resets even without retained state', () => { - for (const hasGlobalDataState of [true, false]) { - const { state } = fixture(hasGlobalDataState) - const unreliable = { ...classifyWatchEvent('change', '/site/page.md'), type: 'rename' } - // @ts-expect-error Runtime callers with unsupported event types must reset, without widening WatchEvent. - const batch = planWatchBatch(state, [unreliable]) - assert.equal(batch.plan.kind, 'full') - assert.equal(batch.inputChanges.resetReason, 'unreliable-event') +test('global data roots need no map entry and shared imports reset on changes, additions, and removals', () => { + for (const type of /** @type {const} */ (['change', 'added', 'removed'])) { + const { state, other } = fixture() + state.globalDataDepPaths.clear() + const root = classifyWatchEvent(type, '/site/global.data.js') + assert.equal(planWatchBatch(state, [root]).inputChanges.resetReason, 'global-data-changed') + if (type === 'change') assert.deepEqual(scope(planWatchEvent(state, root)), [[], [], []]) + for (const name of ['helper.js', 'client.jsx', 'page.md']) { + const filepath = `/site/${name}` + state.globalDataDepPaths.add(filepath) + state.esbuildDepPaths.add(filepath) + state.pageDepMap.set(filepath, new Set([other])) + const batch = planWatchBatch(state, [classifyWatchEvent(type, filepath)]) + assert.equal(batch.inputChanges.resetReason, 'global-data-changed', `${type} ${name}`) + } } }) +test('unknown changes reset and rebuild but known unused layouts and browser entries can skip', () => { + const { state } = fixture() + const unknown = classifyWatchEvent('change', '/site/unknown.js') + assert.equal(planWatchEvent(state, unknown).kind, 'skip', 'single-event behavior is unchanged') + const batch = planWatchBatch(state, [unknown]) + assert.equal(batch.plan.kind, 'full') + assert.equal(batch.inputChanges.resetReason, 'unknown-event') + state.layoutPageMap.clear() + state.pagesFileLayoutMap.clear() + const unusedLayout = planWatchBatch(state, [classifyWatchEvent('change', '/site/root.layout.js')]) + assert.equal(unusedLayout.plan.kind, 'skip') + assert.equal(unusedLayout.inputChanges.resetReason, undefined) + + const unreliable = { ...unknown, type: 'rename' } + // @ts-expect-error Runtime callers with unsupported event types must reset, without widening WatchEvent. + const invalidBatch = planWatchBatch(state, [unreliable]) + assert.equal(invalidBatch.plan.kind, 'full') + assert.equal(invalidBatch.inputChanges.resetReason, 'unreliable-event') +}) + test('page-plan unions deduplicate paths and logging metadata for distinct objects representing one consumer', () => { const { state, home, template, owner } = fixture() - const filepath = home.pageFile.filepath - state.pageDepMap.set(filepath, new Set([home, { ...home }])) - state.templateDepMap.set(filepath, new Set([template, { ...template }])) - state.pagesFileDepMap.set(filepath, new Set([owner, { ...owner }])) - const event = classifyWatchEvent('change', filepath) + state.pageDepMap.set('/site/shared.js', new Set([home, { ...home }])) + state.templateDepMap.set('/site/shared.js', new Set([template, { ...template }])) + state.pagesFileDepMap.set('/site/shared.js', new Set([owner, { ...owner }])) + const event = classifyWatchEvent('change', '/site/shared.js') assert.deepEqual(planWatchBatch(state, [event]).plan, planWatchEvent(state, event), 'a single plan is preserved exactly') const batch = planWatchBatch(state, [event, event]) assert.deepEqual(scope(batch.plan), [[home.pageFile.filepath], [template.templateFile.filepath], [owner.pagesFile.filepath]]) @@ -478,10 +513,10 @@ test('page-plan unions deduplicate paths and logging metadata for distinct objec test('a full plan never short-circuits later input deltas or reset reasons', () => { const { state } = fixture() - const structural = classifyWatchEvent('removed', '/site/page.md') + const restart = classifyWatchEvent('added', '/site/client.jsx') const config = classifyWatchEvent('change', '/site/global.vars.js') const added = classifyWatchEvent('added', '/site/new/page.md') - for (const events of [[structural, config, added], [config, added, structural], [added, structural, config]]) { + for (const events of [[restart, config, added], [config, added, restart], [added, restart, config]]) { const batch = planWatchBatch(state, events) assert.equal(batch.plan.kind, 'full') assert.equal(batch.inputChanges.resetReason, 'global-vars-changed') @@ -496,7 +531,5 @@ test('reset reason uses the first applicable event after snapshot failure preced const markdown = classifyWatchEvent('change', '/site/markdown-it.settings.js') assert.equal(planWatchBatch(state, [data, markdown]).inputChanges.resetReason, 'global-data-changed') assert.equal(planWatchBatch(state, [markdown, data]).inputChanges.resetReason, 'markdown-settings-changed') - assert.equal(planWatchBatch(state, [data, markdown]).plan.kind, 'full') - const failed = { ...state, pageBuildFailed: true } - assert.equal(planWatchBatch(failed, [data, markdown]).inputChanges.resetReason, 'page-build-failed') + assert.equal(planWatchBatch(state, [data, markdown]).plan.kind, 'full', 'settings force full output selection even after a global-data reset') }) diff --git a/test-cases/incremental-global-data/helpers.js b/test-cases/incremental-global-data/helpers.js index fbd28bf..1fdcfdb 100644 --- a/test-cases/incremental-global-data/helpers.js +++ b/test-cases/incremental-global-data/helpers.js @@ -20,6 +20,7 @@ import { setImmediate as nextTurn, setTimeout as delay } from 'node:timers/promi import chokidar from 'chokidar' import pino from 'pino' import { DomStack } from '../../index.js' +import { startWatch } from '../watch/helpers.js' /** @param {string} heading @param {string} [body] @param {string} [frontmatter] */ export function article (heading, body = 'Original body.', frontmatter = '') { @@ -36,11 +37,12 @@ export async function waitFor (predicate, message) { } /** - * Mock only source event delivery; builds, workers, rendering and cleanup are real. + * Deterministic tests mock only source event delivery; native tests use real watchers. + * Builds, workers, rendering and cleanup are real in both modes. * @param {TestContext} t - * @param {{ files?: Record }} [options] + * @param {{ native?: boolean, files?: Record }} [options] */ -export async function fixture (t, { files = {} } = {}) { +export async function fixture (t, { native = false, files = {} } = {}) { const root = await mkdtemp(join(import.meta.dirname, '.tmp-')) const src = join(root, 'src') const dest = join(root, 'public') @@ -51,19 +53,21 @@ export async function fixture (t, { files = {} } = {}) { const dom = new DomStack(src, dest, { metafile: false, domstackManifest: false, logger: pino({ level: 'silent' }) }) /** @type {FSWatcher | undefined} */ let watcher - const watch = chokidar.watch - /** @param {Parameters} args */ - const sourceWatch = (...args) => { - if (args[0] !== src) return watch(...args) - const fake = Object.assign(new EventEmitter(), { - closed: false, - async close () { fake.closed = true }, - }) - watcher = /** @type {FSWatcher} */ (/** @type {unknown} */ (fake)) - setImmediate(() => { if (!fake.closed) fake.emit('ready') }) - return watcher + if (!native) { + const watch = chokidar.watch + /** @param {Parameters} args */ + const sourceWatch = (...args) => { + if (args[0] !== src) return watch(...args) + const fake = Object.assign(new EventEmitter(), { + closed: false, + async close () { fake.closed = true }, + }) + watcher = /** @type {FSWatcher} */ (/** @type {unknown} */ (fake)) + setImmediate(() => { if (!fake.closed) fake.emit('ready') }) + return watcher + } + t.mock.method(chokidar, 'watch', sourceWatch) } - t.mock.method(chokidar, 'watch', sourceWatch) t.after(async () => { await rm(gate, { force: true }) try { @@ -129,7 +133,7 @@ export async function fixture (t, { files = {} } = {}) { return call }, async start () { - const report = await dom.watch({ serve: false }) + const report = native ? await startWatch(t, dom, src) : await dom.watch({ serve: false }) assert.deepEqual(report.pageBuildResults?.errors, [], 'initial page build succeeded') return report }, diff --git a/test-cases/incremental-global-data/index.test.js b/test-cases/incremental-global-data/index.test.js index 578de35..4c0cc4b 100644 --- a/test-cases/incremental-global-data/index.test.js +++ b/test-cases/incremental-global-data/index.test.js @@ -46,7 +46,7 @@ test('cached index adds, updates and removes distinct source IDs without rerende test.todo('re-exported page helpers shared with templates upsert affected source pages (https://github.com/bcomnes/domstack/issues/328)') test.todo('named, star, and namespace re-exports of global-data helpers trigger an index reset (https://github.com/bcomnes/domstack/issues/328)') -test('a shared page and template helper conservatively resets the index and rebuilds both outputs', options, async t => { +test('a shared dependency upserts a transitive source page even when a template imports it directly', options, async t => { const site = await fixture(t, { files: { 'code/page.js': "import { content } from '../page-middle.js'; export const vars = { article: true, title: 'Code' }; export default () => content\n", @@ -58,9 +58,8 @@ test('a shared page and template helper conservatively resets the index and rebu await site.start() await site.write('page-leaf.js', "export const content = 'Code two'\n") const call = await site.rebuild([site.event('page-leaf.js')]) - assertReset(call) - assert.equal(call.reason, 'unknown-event') - assert.deepEqual(call.rendered, ['a/page.md', 'b/page.md', 'code/page.js']) + assertDelta(call, ['code/page.js']) + assert.deepEqual(call.rendered, ['code/page.js']) assert.equal(await readFile(join(site.dest, 'shared.txt'), 'utf8'), 'Code two') assert.match(await readFile(join(site.dest, 'code/index.html'), 'utf8'), /Code two/) assert.ok((await site.data()).some(row => row.html.includes('Code two'))) @@ -82,6 +81,30 @@ test('a global producer dependency resets the whole index, including in a mixed assert.ok(data.every(row => row.html.startsWith('New search: '))) }) +test('service-worker replacement events preserve a required global-data reset', options, async t => { + const site = await fixture(t, { + files: { + 'producer-middle.js': "import { prefix } from './service-worker.js'; export { prefix }\n", + 'service-worker.js': "export const prefix = 'Original prefix: '\n", + }, + }) + await site.start() + assert.ok((await site.data()).every(row => row.html.startsWith('Original prefix: '))) + await rm(join(site.src, 'service-worker.js')) + await site.write('service-worker.js', "export const prefix = 'Replaced prefix: '\n") + + // Both notifications can arrive after the replacement exists, in separate batches. + for (const type of /** @type {const} */ (['removed', 'added'])) { + const event = site.event('service-worker.js', type) + const call = await site.rebuild([event]) + assertReset(call) + assert.equal(call.reason, 'global-data-changed') + assert.deepEqual(call.events, [event]) + assert.deepEqual(call.rendered, ['a/page.md', 'b/page.md']) + assert.ok((await site.data()).every(row => row.html.startsWith('Replaced prefix: '))) + } +}) + test('helpers shared by pages and global configuration reset the index and rebuild unrelated outputs', options, async t => { const site = await fixture(t, { files: { @@ -91,6 +114,7 @@ test('helpers shared by pages and global configuration reset the index and rebui 'markdown-it.settings.js': "import { html } from './markdown-helper.js'; export default md => md.set({ html })\n", 'markdown-helper.js': 'export const html = true\n', 'code/page.js': "import { site } from '../vars-helper.js'; import { html } from '../markdown-helper.js'; export default () => site + ':' + html\n", + 'unrelated.txt.template.js': "import { randomUUID } from 'node:crypto'; export const dataDeps = []; export default () => randomUUID()\n", 'a/page.md': article('Alpha', 'Raw HTML'), }, }) @@ -100,18 +124,20 @@ test('helpers shared by pages and global configuration reset the index and rebui await site.write('vars-helper.js', "export const site = 'Site two'\n") const vars = await site.rebuild([site.event('vars-helper.js')]) assertReset(vars) - assert.equal(vars.reason, 'unknown-event') + assert.equal(vars.reason, 'global-config-changed') assert.ok((await site.data()).every(row => row.html.includes('
Site two
'))) assert.match(await readFile(join(site.dest, 'b/index.html'), 'utf8'), /
Site two<\/header>/) assert.match(await readFile(join(site.dest, 'code/index.html'), 'utf8'), /Site two:true/) + const unrelated = await readFile(join(site.dest, 'unrelated.txt'), 'utf8') await site.write('markdown-helper.js', 'export const html = false\n') const markdown = await site.rebuild([site.event('markdown-helper.js')]) assertReset(markdown) - assert.equal(markdown.reason, 'unknown-event') + assert.equal(markdown.reason, 'global-config-changed') assert.match((await site.data())[0]?.html ?? '', /<strong>Raw HTML<\/strong>/) assert.match(await readFile(join(site.dest, 'a/index.html'), 'utf8'), /<strong>Raw HTML<\/strong>/) assert.match(await readFile(join(site.dest, 'code/index.html'), 'utf8'), /Site two:false/) + assert.notEqual(await readFile(join(site.dest, 'unrelated.txt'), 'utf8'), unrelated, 'Markdown settings rerender templates without settings imports or data subscriptions') }) test('in-flight events become one ordered batch with deduplicated inputs', options, async t => { @@ -279,3 +305,28 @@ test('independent sessions and a stopped/restarted instance do not inherit cache assertDelta(await first.rebuild([first.event('a/page.md')]), ['a/page.md']) assert.deepEqual((await first.data()).map(row => row.title), ['Restart Alpha', 'Edited while stopped']) }) + +test('a native imported JSON edit resets the producer and publishes every updated row', options, async t => { + const site = await fixture(t, { + native: true, + files: { + 'producer-leaf.js': "import settings from './prefix.json' with { type: 'json' }; export const prefix = settings.prefix\n", + 'prefix.json': JSON.stringify({ prefix: 'Original prefix: ' }), + }, + }) + await site.start() + assert.ok((await site.data()).every(row => row.html.startsWith('Original prefix: '))) + const callsBeforeEdit = (await site.calls()).length + await site.write('prefix.json', JSON.stringify({ prefix: 'Updated prefix: ' })) + await waitFor(async () => (await site.calls()).length > callsBeforeEdit, 'native JSON edit reaches the producer') + // The producer log precedes output writes; read the JSON only after they finish. + await site.dom.settled() + + const call = (await site.calls()).at(-1) + assertReset(call) + assert.equal(call.reason, 'global-data-changed') + assert.deepEqual(call.rendered, ['a/page.md', 'b/page.md']) + const data = await site.data() + assert.deepEqual(data.map(row => row.title), ['Alpha', 'Beta']) + assert.ok(data.every(row => row.html.startsWith('Updated prefix: '))) +})