Skip to content

Commit 65ffdc4

Browse files
committed
fix(content): regenerate intellisense manifest on content file changes
The collections.json manifest used for content intellisense was only generated during a full sync, so adding or removing content files during `astro dev` left autocomplete stale until a restart. Register debounced add/unlink watcher listeners (re-added on each full sync, alongside the loaders) to regenerate it, guarding against .astro self-writes. Also rebuild the entries map from the current store instead of merging, so deleted files are dropped instead of lingering. Fixes #16442
1 parent 7e8d371 commit 65ffdc4

3 files changed

Lines changed: 246 additions & 1 deletion

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'astro': patch
3+
---
4+
5+
Fixes the content intellisense manifest (`collections.json`) not updating when content files are added or removed during `astro dev`. The manifest is now regenerated on file add/unlink (debounced) and rebuilt from the current store so deleted entries are dropped instead of lingering.

packages/astro/src/content/content-layer.ts

Lines changed: 39 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { existsSync, promises as fs } from 'node:fs';
2+
import { fileURLToPath } from 'node:url';
23
import { parseFrontmatter } from '@astrojs/internal-helpers/frontmatter';
34
import type { MarkdownRenderer } from '@astrojs/internal-helpers/markdown';
45
import PQueue from 'p-queue';
@@ -358,7 +359,41 @@ export class ContentLayer {
358359
logger.info('Synced content');
359360
if (this.#settings.config.experimental.contentIntellisense) {
360361
await this.regenerateCollectionFileManifest();
362+
// On a full sync the watcher listeners were cleared above and re-added by the
363+
// loaders, so re-register the manifest watcher here too. On a selective sync the
364+
// listeners are left intact, so we skip to avoid stacking duplicate listeners.
365+
if (!options?.loaders?.length) {
366+
this.#watchCollectionFileManifest();
367+
}
368+
}
369+
}
370+
371+
/**
372+
* Watches for content files being added or removed and regenerates the collection
373+
* file manifest, so content intellisense stays in sync during `astro dev` without a
374+
* restart. Debounced to batch the burst of events chokidar emits for a single change.
375+
*/
376+
#watchCollectionFileManifest() {
377+
if (!this.#watcher) {
378+
return;
361379
}
380+
const dotAstroPath = fileURLToPath(this.#settings.dotAstroDir);
381+
let debounceTimeout: NodeJS.Timeout | undefined;
382+
const regenerate = (changedPath: string) => {
383+
// Ignore writes inside `.astro` (including the manifest itself) to avoid an
384+
// infinite regenerate -> write -> watch -> regenerate loop.
385+
if (changedPath.startsWith(dotAstroPath)) {
386+
return;
387+
}
388+
debounceTimeout && clearTimeout(debounceTimeout);
389+
debounceTimeout = setTimeout(() => {
390+
this.regenerateCollectionFileManifest().catch(() => {
391+
// Errors are already logged inside regenerateCollectionFileManifest.
392+
});
393+
}, 50 /* debounce to batch chokidar events */);
394+
};
395+
this.#watcher.on('add', regenerate);
396+
this.#watcher.on('unlink', regenerate);
362397
}
363398

364399
async regenerateCollectionFileManifest() {
@@ -368,7 +403,10 @@ export class ContentLayer {
368403
try {
369404
const collections = await fs.readFile(collectionsManifest, 'utf-8');
370405
const collectionsJson = JSON.parse(collections);
371-
collectionsJson.entries ??= {};
406+
// Rebuild the entries map from the current store rather than merging into the
407+
// previous one, so files that were deleted since the last run are dropped
408+
// instead of lingering in the manifest forever.
409+
collectionsJson.entries = {};
372410

373411
for (const { hasSchema, name } of collectionsJson.collections) {
374412
if (!hasSchema) {
Lines changed: 202 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,202 @@
1+
import { strict as assert } from 'node:assert';
2+
import { promises as fs } from 'node:fs';
3+
import { describe, it } from 'node:test';
4+
import { fileURLToPath } from 'node:url';
5+
import { ContentLayer } from '../../../dist/content/content-layer.js';
6+
import { MutableDataStore } from '../../../dist/content/mutable-data-store.js';
7+
import { AstroLogger } from '../../../dist/core/logger/core.js';
8+
import { createMinimalSettings, createTempDir, createTestConfigObserver } from './test-helpers.ts';
9+
10+
const COLLECTIONS_MANIFEST_FILE = 'collections/collections.json';
11+
12+
function silentLogger() {
13+
return new AstroLogger({
14+
destination: { write: () => true },
15+
level: 'silent',
16+
});
17+
}
18+
19+
/**
20+
* Writes an initial collections manifest (the shape the language server consumes)
21+
* into the `.astro` dir, declaring `blog` as a schema-bearing collection.
22+
*/
23+
async function seedManifest(dotAstroDir: URL) {
24+
const manifestUrl = new URL(COLLECTIONS_MANIFEST_FILE, dotAstroDir);
25+
await fs.mkdir(new URL('./', manifestUrl), { recursive: true });
26+
await fs.writeFile(
27+
manifestUrl,
28+
JSON.stringify({ collections: [{ name: 'blog', hasSchema: true }], entries: {} }, null, 2),
29+
);
30+
return manifestUrl;
31+
}
32+
33+
async function readManifest(manifestUrl: URL) {
34+
return JSON.parse(await fs.readFile(manifestUrl, 'utf-8'));
35+
}
36+
37+
function entryKey(root: URL, filePath: string) {
38+
return new URL(filePath, root).href.toLowerCase();
39+
}
40+
41+
describe('Content Layer - contentIntellisense manifest', () => {
42+
it('adds entries for content files present in the store', async () => {
43+
const root = createTempDir();
44+
const dotAstroDir = new URL('./.astro/', root);
45+
const manifestUrl = await seedManifest(dotAstroDir);
46+
47+
const store = new MutableDataStore();
48+
store.set('blog', 'first', { id: 'first', data: {}, filePath: 'src/content/blog/first.md' });
49+
50+
const contentLayer = new ContentLayer({
51+
settings: createMinimalSettings(root),
52+
logger: silentLogger(),
53+
store,
54+
contentConfigObserver: createTestConfigObserver({}),
55+
});
56+
57+
await contentLayer.regenerateCollectionFileManifest();
58+
59+
const manifest = await readManifest(manifestUrl);
60+
assert.ok(
61+
manifest.entries[entryKey(root, 'src/content/blog/first.md')],
62+
'manifest should include the present content file',
63+
);
64+
});
65+
66+
it('removes entries for content files deleted from the store on regeneration', async () => {
67+
const root = createTempDir();
68+
const dotAstroDir = new URL('./.astro/', root);
69+
const manifestUrl = await seedManifest(dotAstroDir);
70+
71+
const store = new MutableDataStore();
72+
store.set('blog', 'first', { id: 'first', data: {}, filePath: 'src/content/blog/first.md' });
73+
store.set('blog', 'second', { id: 'second', data: {}, filePath: 'src/content/blog/second.md' });
74+
75+
const contentLayer = new ContentLayer({
76+
settings: createMinimalSettings(root),
77+
logger: silentLogger(),
78+
store,
79+
contentConfigObserver: createTestConfigObserver({}),
80+
});
81+
82+
await contentLayer.regenerateCollectionFileManifest();
83+
84+
// The author deletes `second.md` — the store no longer holds it.
85+
store.delete('blog', 'second');
86+
await contentLayer.regenerateCollectionFileManifest();
87+
88+
const manifest = await readManifest(manifestUrl);
89+
assert.ok(
90+
manifest.entries[entryKey(root, 'src/content/blog/first.md')],
91+
'remaining file should stay in the manifest',
92+
);
93+
assert.equal(
94+
manifest.entries[entryKey(root, 'src/content/blog/second.md')],
95+
undefined,
96+
'deleted file should be removed from the manifest',
97+
);
98+
});
99+
100+
it('regenerates the manifest when a content file changes during dev', async () => {
101+
const root = createTempDir();
102+
const dotAstroDir = new URL('./.astro/', root);
103+
const manifestUrl = await seedManifest(dotAstroDir);
104+
105+
// Minimal mock of vite's FSWatcher capturing the registered listeners.
106+
const handlers: Record<string, Array<(path: string) => void>> = {};
107+
const watcher: any = {
108+
on(event: string, cb: (path: string) => void) {
109+
(handlers[event] ??= []).push(cb);
110+
return watcher;
111+
},
112+
off() {
113+
return watcher;
114+
},
115+
};
116+
117+
const store = new MutableDataStore();
118+
const settings = createMinimalSettings(root, {
119+
config: { experimental: { contentIntellisense: true } },
120+
});
121+
122+
const contentLayer = new ContentLayer({
123+
settings,
124+
logger: silentLogger(),
125+
store,
126+
watcher,
127+
contentConfigObserver: createTestConfigObserver({
128+
blog: { type: 'content_layer', loader: () => [] },
129+
}),
130+
});
131+
132+
await contentLayer.sync();
133+
134+
// The sync should have registered add/unlink listeners on the watcher.
135+
assert.ok(handlers.add?.length, 'an "add" listener should be registered');
136+
assert.ok(handlers.unlink?.length, 'an "unlink" listener should be registered');
137+
138+
// A new content file appears; the store now holds it.
139+
store.set('blog', 'fresh', { id: 'fresh', data: {}, filePath: 'src/content/blog/fresh.md' });
140+
for (const cb of handlers.add) {
141+
cb(fileURLToPath(new URL('src/content/blog/fresh.md', root)));
142+
}
143+
144+
// Wait out the debounce window, then confirm the manifest was regenerated.
145+
await new Promise((resolve) => setTimeout(resolve, 80));
146+
147+
const manifest = await readManifest(manifestUrl);
148+
assert.ok(
149+
manifest.entries[entryKey(root, 'src/content/blog/fresh.md')],
150+
'manifest should pick up the new file without a restart',
151+
);
152+
});
153+
154+
it('ignores writes inside the .astro dir to avoid an infinite loop', async () => {
155+
const root = createTempDir();
156+
const dotAstroDir = new URL('./.astro/', root);
157+
await seedManifest(dotAstroDir);
158+
159+
const handlers: Record<string, Array<(path: string) => void>> = {};
160+
const watcher: any = {
161+
on(event: string, cb: (path: string) => void) {
162+
(handlers[event] ??= []).push(cb);
163+
return watcher;
164+
},
165+
off() {
166+
return watcher;
167+
},
168+
};
169+
170+
const store = new MutableDataStore();
171+
const settings = createMinimalSettings(root, {
172+
config: { experimental: { contentIntellisense: true } },
173+
});
174+
175+
const contentLayer = new ContentLayer({
176+
settings,
177+
logger: silentLogger(),
178+
store,
179+
watcher,
180+
contentConfigObserver: createTestConfigObserver({
181+
blog: { type: 'content_layer', loader: () => [] },
182+
}),
183+
});
184+
185+
await contentLayer.sync();
186+
187+
let regenerated = false;
188+
const original = contentLayer.regenerateCollectionFileManifest.bind(contentLayer);
189+
contentLayer.regenerateCollectionFileManifest = async () => {
190+
regenerated = true;
191+
return original();
192+
};
193+
194+
// A write inside `.astro` (e.g. the manifest itself) must NOT trigger regeneration.
195+
for (const cb of handlers.add) {
196+
cb(fileURLToPath(new URL('collections/collections.json', dotAstroDir)));
197+
}
198+
await new Promise((resolve) => setTimeout(resolve, 80));
199+
200+
assert.equal(regenerated, false, '.astro writes should not trigger regeneration');
201+
});
202+
});

0 commit comments

Comments
 (0)