diff --git a/docs/implementation/README.md b/docs/implementation/README.md index 1b1c0ecc..5e34a6ca 100644 --- a/docs/implementation/README.md +++ b/docs/implementation/README.md @@ -280,7 +280,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. +DOMStack uses [`@11ty/dependency-tree`](https://github.com/11ty/eleventy-utils/tree/main/parse-deps) to statically analyze JavaScript and TypeScript imports and re-exports. The page watcher observes tracked imports within the source tree, including JSON files. DOMStack maintains dependency maps for: diff --git a/lib/watch/dependency-analysis-watch.test.js b/lib/watch/dependency-analysis-watch.test.js index 7ee43bd2..7ea2067d 100644 --- a/lib/watch/dependency-analysis-watch.test.js +++ b/lib/watch/dependency-analysis-watch.test.js @@ -188,14 +188,14 @@ function pauseObservation (t, filepath) { describe('watch coordinator dependency analysis', { concurrency: false, timeout: 60_000 }, () => { before(async () => { - // The installed CJS analyzer destructures readFileSync at module load. Install - // before importing DomStack, and count only its actual reads, not worker I/O, + // Install before importing DomStack, and count only the analyzer's actual + // reads, not worker I/O, // module loading, fixture assertions, or supplied rebuild options. const readFileSync = fs.readFileSync /** @param {Parameters} args */ const probe = (...args) => { if (typeof args[0] === 'string' && args[0].includes('.tmp-dependency-') && - new Error().stack?.includes('@11ty/dependency-tree-esm/main.js')) { + new Error().stack?.includes('@11ty/dependency-tree/main.js')) { analyzerReads.push(resolve(args[0])) } return readFileSync(...args) @@ -490,7 +490,8 @@ describe('watch coordinator dependency analysis', { concurrency: false, timeout: for (const kind of ['ignored', 'external', 'symlink']) { await t.test(kind, async t => { const helper = kind === 'external' ? '../outside.js' : kind === 'ignored' ? './ignored.js' : './alias.js' - const target = kind === 'external' ? '../outside.js' : kind === 'ignored' ? 'ignored.js' : 'target.js' + // The analyzer resolves symlinks; keep the target outside source observation. + const target = kind === 'external' ? '../outside.js' : kind === 'ignored' ? 'ignored.js' : '../target.js' const site = await fixture(t, { 'root.layout.js': layout('root', helper), [target]: "export const label = 'unobserved-initial'", @@ -505,7 +506,7 @@ describe('watch coordinator dependency analysis', { concurrency: false, timeout: await site.write('a/page.md', markdown('Markup ' + version)) await site.deliver('a/page.md') assert.ok(site.reads().includes('root.layout.js'), kind + ' closure is analyzed on every successful pass') - assert.ok(site.reads().includes(helper.replace(/^\.\//, ''))) + assert.ok(site.reads().includes(target.replace(/^\.\//, ''))) assert.ok(!site.reads().includes('other.layout.js'), 'fully observed unrelated closure still reuses analysis') assert.match(await site.output(), new RegExp('unobserved-' + version + '.*Markup ' + version, 's')) } diff --git a/lib/watch/dependency-index.js b/lib/watch/dependency-index.js index 563c30ce..ec3b4cb1 100644 --- a/lib/watch/dependency-index.js +++ b/lib/watch/dependency-index.js @@ -7,7 +7,7 @@ * @typedef {(filepath: string) => boolean | Promise} DependencyObservation */ import { resolve } from 'node:path' -import { find } from '@11ty/dependency-tree-typescript' +import { clearCache, find } from '@11ty/dependency-tree' import { isProcessedFile, globalBundleAssets, pageBundleAssets, layoutBundleAssets } from '../file-conventions.js' /** @type {DependencyObservation} */ @@ -158,6 +158,8 @@ export class WatchDependencyIndex { * @param {{ reuseAnalysis?: boolean, isObserved?: DependencyObservation }} [options] */ async rebuild (siteData, { reuseAnalysis = false, isObserved = unobserved } = {}) { + // DOMStack's observation-aware cache owns reuse across rebuilds, not file stats. + clearCache() const generation = ++this.#rebuildGeneration const cwd = process.cwd() if (!reuseAnalysis || this.#dependencyAnalysisFailed || cwd !== this.#analysisCwd || isObserved !== this.#observation) this.clearAnalysis() diff --git a/lib/watch/incremental-global-data-tests/index.test.js b/lib/watch/incremental-global-data-tests/index.test.js index 4c0cc4b8..ebdeb4a7 100644 --- a/lib/watch/incremental-global-data-tests/index.test.js +++ b/lib/watch/incremental-global-data-tests/index.test.js @@ -43,8 +43,69 @@ test('cached index adds, updates and removes distinct source IDs without rerende await assert.rejects(stat(join(site.dest, 'a/index.html')), { code: 'ENOENT' }) }) -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)') +for (const extension of ['js', 'ts']) { + for (const kind of ['named', 'star', 'namespace']) { + /** @param {string} name @param {string} leaf */ + const reexport = (name, leaf) => kind === 'namespace' + ? `export * as helpers from './${leaf}.${extension}';` + : kind === 'star' + ? `export * from './${leaf}.${extension}';` + : `export { ${name} } from './${leaf}.${extension}';` + /** @param {string} name @param {string} barrel */ + const importValue = (name, barrel) => kind === 'namespace' + ? `import { helpers } from '${barrel}.${extension}'; const { ${name} } = helpers;` + : `import { ${name} } from '${barrel}.${extension}';` + + test(`re-exported page helpers shared with templates upsert affected source pages (${extension}, ${kind})`, options, async t => { + const site = await fixture(t, { + files: { + 'root.layout.js': 'export default ({ children }) => children\n', + 'code/page.js': `${importValue('content', '../page-middle')} export const vars = { article: true, title: 'Code' }; export default () => content\n`, + [`page-middle.${extension}`]: reexport('content', 'page-leaf'), + [`page-leaf.${extension}`]: "export const content = 'Code one'\n", + 'shared.txt.template.js': `import { content } from './page-leaf.${extension}'; export default () => content\n`, + }, + }) + await site.start() + await site.write(`page-leaf.${extension}`, "export const content = 'Code two'\n") + const call = await site.rebuild([site.event(`page-leaf.${extension}`)]) + 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/) + const watched = await site.data() + assert.ok(watched.some(row => row.html.includes('Code two'))) + await site.dom.stopWatching() + await site.dom.build() + assert.deepEqual(await site.data(), watched, 'watch output matches a fresh build') + }) + + test(`re-exported global-data helpers shared with templates reset the index (${extension}, ${kind})`, options, async t => { + const site = await fixture(t, { + files: { + 'root.layout.js': 'export default ({ children }) => children\n', + 'producer-middle.js': `${importValue('prefix', './producer-barrel')} export { prefix };\n`, + [`producer-barrel.${extension}`]: reexport('prefix', 'shared-producer-leaf'), + [`shared-producer-leaf.${extension}`]: "export const prefix = 'Original prefix: '\n", + 'shared.txt.template.js': `import { prefix } from './shared-producer-leaf.${extension}'; export default () => prefix\n`, + }, + }) + await site.start() + assert.ok((await site.data()).every(row => row.html.startsWith('Original prefix: '))) + await site.write(`shared-producer-leaf.${extension}`, "export const prefix = 'Updated prefix: '\n") + const call = await site.rebuild([site.event(`shared-producer-leaf.${extension}`)]) + assertReset(call) + assert.equal(call.reason, 'global-data-changed') + assert.deepEqual(call.rendered, ['a/page.md', 'b/page.md']) + assert.equal(await readFile(join(site.dest, 'shared.txt'), 'utf8'), 'Updated prefix: ') + const watched = await site.data() + assert.ok(watched.every(row => row.html.startsWith('Updated prefix: '))) + await site.dom.stopWatching() + await site.dom.build() + assert.deepEqual(await site.data(), watched, 'watch output matches a fresh build') + }) + } +} test('a shared dependency upserts a transitive source page even when a template imports it directly', options, async t => { const site = await fixture(t, { diff --git a/package.json b/package.json index 4c3f8ca8..be649649 100644 --- a/package.json +++ b/package.json @@ -52,7 +52,7 @@ "node": "^22.18.0 || >=24.0.0" }, "dependencies": { - "@11ty/dependency-tree-typescript": "^1.0.0", + "@11ty/dependency-tree": "5.0.0-alpha.3", "@domstack/sync": "^0.0.8", "argsclopts": "^1.0.4", "async-folder-walker": "^3.0.5", diff --git a/types/@11ty__dependency-tree-typescript.d.ts b/types/@11ty__dependency-tree.d.ts similarity index 66% rename from types/@11ty__dependency-tree-typescript.d.ts rename to types/@11ty__dependency-tree.d.ts index 575fc73d..941fd624 100644 --- a/types/@11ty__dependency-tree-typescript.d.ts +++ b/types/@11ty__dependency-tree.d.ts @@ -1,4 +1,5 @@ -declare module '@11ty/dependency-tree-typescript' { +declare module '@11ty/dependency-tree' { + export function clearCache (filePath?: string): void export function find (filePath: string): Promise export function findGraph (filePath: string): Promise export function mergeGraphs (...graphs: object[]): object