Skip to content

Commit 1834bca

Browse files
committed
fix: update types on config added or removed
1 parent 4361f68 commit 1834bca

File tree

1 file changed

+174
-163
lines changed

1 file changed

+174
-163
lines changed

packages/astro/src/content/vite-plugin-content.ts

Lines changed: 174 additions & 163 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@ import {
1515
ContentConfig,
1616
getEntryData,
1717
getEntrySlug,
18+
loadContentConfig,
19+
NotFoundError,
1820
parseFrontmatter,
1921
} from './utils.js';
2022
import * as devalue from 'devalue';
@@ -26,23 +28,29 @@ type Paths = {
2628
config: URL;
2729
};
2830

31+
type ChokidarEvent = 'add' | 'addDir' | 'change' | 'unlink' | 'unlinkDir';
32+
type ContentEvent = { name: ChokidarEvent; entry: string };
33+
type EntryInfo = {
34+
id: string;
35+
slug: string;
36+
collection: string;
37+
};
38+
39+
type GenerateContent = {
40+
init(): Promise<void>;
41+
queueEvent(event: ContentEvent): void;
42+
};
43+
44+
type ContentTypes = Record<string, Record<string, string>>;
45+
2946
const CONTENT_BASE = 'types.generated';
3047
const CONTENT_FILE = CONTENT_BASE + '.mjs';
3148
const CONTENT_TYPES_FILE = CONTENT_BASE + '.d.ts';
3249

33-
function isContentFlagImport({ searchParams, pathname }: Pick<URL, 'searchParams' | 'pathname'>) {
34-
return searchParams.has(CONTENT_FLAG) && contentFileExts.some((ext) => pathname.endsWith(ext));
35-
}
36-
37-
export function getPaths({ srcDir }: { srcDir: URL }): Paths {
38-
return {
39-
// Output generated types in content directory. May change in the future!
40-
cacheDir: new URL('./content/', srcDir),
41-
contentDir: new URL('./content/', srcDir),
42-
generatedInputDir: new URL('../../src/content/template/', import.meta.url),
43-
config: new URL('./content/config', srcDir),
44-
};
45-
}
50+
const msg = {
51+
collectionAdded: (collection: string) => `${cyan(collection)} collection added`,
52+
entryAdded: (entry: string, collection: string) => `${cyan(entry)} added to ${bold(collection)}.`,
53+
};
4654

4755
export function astroContentVirtualModPlugin({ settings }: { settings: AstroSettings }): Plugin {
4856
const paths = getPaths({ srcDir: settings.config.srcDir });
@@ -88,6 +96,126 @@ export function astroContentServerPlugin({
8896
const paths: Paths = getPaths({ srcDir: settings.config.srcDir });
8997
let contentDirExists = false;
9098
let contentGenerator: GenerateContent;
99+
100+
async function createContentGenerator(): Promise<GenerateContent> {
101+
const contentTypes: ContentTypes = {};
102+
103+
let events: Promise<void>[] = [];
104+
let debounceTimeout: NodeJS.Timeout | undefined;
105+
let eventsSettled: Promise<void> | undefined;
106+
107+
const contentTypesBase = await fs.readFile(
108+
new URL(CONTENT_TYPES_FILE, paths.generatedInputDir),
109+
'utf-8'
110+
);
111+
112+
async function init() {
113+
const pattern = new URL('./**/', paths.contentDir).pathname + '*.{md,mdx}';
114+
const entries = await glob(pattern);
115+
for (const entry of entries) {
116+
queueEvent({ name: 'add', entry }, { shouldLog: false });
117+
}
118+
await eventsSettled;
119+
}
120+
121+
async function onEvent(event: ContentEvent, opts?: { shouldLog: boolean }) {
122+
const shouldLog = opts?.shouldLog ?? true;
123+
124+
if (event.name === 'addDir' || event.name === 'unlinkDir') {
125+
const collection = path.relative(paths.contentDir.pathname, event.entry);
126+
// If directory is multiple levels deep, it is not a collection!
127+
const isCollectionEvent = collection.split(path.sep).length === 1;
128+
if (!isCollectionEvent) return;
129+
switch (event.name) {
130+
case 'addDir':
131+
addCollection(contentTypes, JSON.stringify(collection));
132+
if (shouldLog) {
133+
info(logging, 'content', msg.collectionAdded(collection));
134+
}
135+
break;
136+
case 'unlinkDir':
137+
removeCollection(contentTypes, JSON.stringify(collection));
138+
break;
139+
}
140+
} else {
141+
const fileType = getEntryType(event.entry, paths);
142+
if (fileType === 'config') {
143+
contentConfig = await loadContentConfig({ settings });
144+
return;
145+
}
146+
if (fileType === 'unknown') {
147+
warn(
148+
logging,
149+
'content',
150+
`${cyan(
151+
path.relative(paths.contentDir.pathname, event.entry)
152+
)} is not a supported file type. Skipping.`
153+
);
154+
return;
155+
}
156+
const entryInfo = getEntryInfo({ entryPath: event.entry, contentDir: paths.contentDir });
157+
// Not a valid `src/content/` entry. Silently return, but should be impossible?
158+
if (entryInfo instanceof Error) return;
159+
160+
const { id, slug, collection } = entryInfo;
161+
const collectionKey = JSON.stringify(collection);
162+
const entryKey = JSON.stringify(id);
163+
const collectionConfig =
164+
contentConfig instanceof Error ? undefined : contentConfig.collections[collection];
165+
switch (event.name) {
166+
case 'add':
167+
if (!(collectionKey in contentTypes)) {
168+
addCollection(contentTypes, collectionKey);
169+
}
170+
if (!(entryKey in contentTypes[collectionKey])) {
171+
addEntry(contentTypes, collectionKey, entryKey, slug, collectionConfig);
172+
}
173+
if (shouldLog) {
174+
info(logging, 'content', msg.entryAdded(entryInfo.slug, entryInfo.collection));
175+
}
176+
break;
177+
case 'unlink':
178+
if (collectionKey in contentTypes && entryKey in contentTypes[collectionKey]) {
179+
removeEntry(contentTypes, collectionKey, entryKey);
180+
}
181+
break;
182+
case 'change':
183+
// noop. Frontmatter types are inferred from collection schema import, so they won't change!
184+
break;
185+
}
186+
}
187+
}
188+
189+
function queueEvent(event: ContentEvent, eventOpts?: { shouldLog: boolean }) {
190+
if (!event.entry.startsWith(paths.contentDir.pathname)) return;
191+
if (event.entry.endsWith(CONTENT_TYPES_FILE)) return;
192+
193+
events.push(onEvent(event, eventOpts));
194+
runEventsDebounced();
195+
}
196+
197+
function runEventsDebounced() {
198+
eventsSettled = new Promise((resolve, reject) => {
199+
try {
200+
debounceTimeout && clearTimeout(debounceTimeout);
201+
debounceTimeout = setTimeout(async () => {
202+
await Promise.all(events);
203+
await writeContentFiles({
204+
contentTypes,
205+
paths,
206+
contentTypesBase,
207+
hasContentConfig: !(contentConfig instanceof NotFoundError),
208+
});
209+
resolve();
210+
}, 50 /* debounce 50 ms to batch chokidar events */);
211+
} catch (e) {
212+
reject(e);
213+
}
214+
});
215+
}
216+
return { init, queueEvent };
217+
}
218+
91219
return [
92220
{
93221
name: 'content-flag-plugin',
@@ -151,7 +279,7 @@ export const _internal = {
151279

152280
info(logging, 'content', 'Generating entries...');
153281

154-
contentGenerator = await toGenerateContent({ logging, paths, contentConfig });
282+
contentGenerator = await createContentGenerator();
155283
await contentGenerator.init();
156284
},
157285
async configureServer(viteServer) {
@@ -175,18 +303,33 @@ export const _internal = {
175303
}
176304

177305
function attachListeners() {
178-
viteServer.watcher.on('add', (entry) =>
179-
contentGenerator.queueEvent({ name: 'add', entry })
180-
);
306+
viteServer.watcher.on('all', async (event, entry) => {
307+
if (
308+
['add', 'unlink', 'change'].includes(event) &&
309+
getEntryType(entry, paths) === 'config'
310+
) {
311+
for (const modUrl of viteServer.moduleGraph.urlToModuleMap.keys()) {
312+
if (isContentFlagImport(new URL(modUrl, 'file://'))) {
313+
const mod = await viteServer.moduleGraph.getModuleByUrl(modUrl);
314+
if (mod) {
315+
viteServer.moduleGraph.invalidateModule(mod);
316+
}
317+
}
318+
}
319+
}
320+
});
321+
viteServer.watcher.on('add', (entry) => {
322+
contentGenerator.queueEvent({ name: 'add', entry });
323+
});
181324
viteServer.watcher.on('addDir', (entry) =>
182325
contentGenerator.queueEvent({ name: 'addDir', entry })
183326
);
184327
viteServer.watcher.on('change', (entry) =>
185328
contentGenerator.queueEvent({ name: 'change', entry })
186329
);
187-
viteServer.watcher.on('unlink', (entry) =>
188-
contentGenerator.queueEvent({ name: 'unlink', entry })
189-
);
330+
viteServer.watcher.on('unlink', (entry) => {
331+
contentGenerator.queueEvent({ name: 'unlink', entry });
332+
});
190333
viteServer.watcher.on('unlinkDir', (entry) =>
191334
contentGenerator.queueEvent({ name: 'unlinkDir', entry })
192335
);
@@ -196,150 +339,18 @@ export const _internal = {
196339
];
197340
}
198341

199-
type ChokidarEvent = 'add' | 'addDir' | 'change' | 'unlink' | 'unlinkDir';
200-
type ContentEvent = { name: ChokidarEvent; entry: string };
201-
type EntryInfo = {
202-
id: string;
203-
slug: string;
204-
collection: string;
205-
};
206-
207-
type GenerateContent = {
208-
init(): Promise<void>;
209-
queueEvent(event: ContentEvent): void;
210-
};
211-
212-
type ContentTypes = Record<string, Record<string, string>>;
213-
214-
const msg = {
215-
collectionAdded: (collection: string) => `${cyan(collection)} collection added`,
216-
entryAdded: (entry: string, collection: string) => `${cyan(entry)} added to ${bold(collection)}.`,
217-
};
218-
219-
async function toGenerateContent({
220-
logging,
221-
paths,
222-
contentConfig,
223-
}: {
224-
logging: LogOptions;
225-
paths: Paths;
226-
contentConfig: ContentConfig | Error;
227-
}): Promise<GenerateContent> {
228-
const contentTypes: ContentTypes = {};
229-
230-
let events: Promise<void>[] = [];
231-
let debounceTimeout: NodeJS.Timeout | undefined;
232-
let eventsSettled: Promise<void> | undefined;
233-
234-
const contentTypesBase = await fs.readFile(
235-
new URL(CONTENT_TYPES_FILE, paths.generatedInputDir),
236-
'utf-8'
237-
);
238-
239-
async function init() {
240-
const pattern = new URL('./**/', paths.contentDir).pathname + '*.{md,mdx}';
241-
const entries = await glob(pattern);
242-
for (const entry of entries) {
243-
queueEvent({ name: 'add', entry }, { shouldLog: false });
244-
}
245-
await eventsSettled;
246-
}
247-
248-
async function onEvent(event: ContentEvent, opts?: { shouldLog: boolean }) {
249-
const shouldLog = opts?.shouldLog ?? true;
250-
251-
if (event.name === 'addDir' || event.name === 'unlinkDir') {
252-
const collection = path.relative(paths.contentDir.pathname, event.entry);
253-
// If directory is multiple levels deep, it is not a collection!
254-
const isCollectionEvent = collection.split(path.sep).length === 1;
255-
if (!isCollectionEvent) return;
256-
switch (event.name) {
257-
case 'addDir':
258-
addCollection(contentTypes, JSON.stringify(collection));
259-
if (shouldLog) {
260-
info(logging, 'content', msg.collectionAdded(collection));
261-
}
262-
break;
263-
case 'unlinkDir':
264-
removeCollection(contentTypes, JSON.stringify(collection));
265-
break;
266-
}
267-
} else {
268-
const fileType = getEntryType(event.entry, paths);
269-
if (fileType === 'config') {
270-
return;
271-
}
272-
if (fileType === 'unknown') {
273-
warn(
274-
logging,
275-
'content',
276-
`${cyan(
277-
path.relative(paths.contentDir.pathname, event.entry)
278-
)} is not a supported file type. Skipping.`
279-
);
280-
return;
281-
}
282-
const entryInfo = getEntryInfo({ entryPath: event.entry, contentDir: paths.contentDir });
283-
// Not a valid `src/content/` entry. Silently return, but should be impossible?
284-
if (entryInfo instanceof Error) return;
285-
286-
const { id, slug, collection } = entryInfo;
287-
const collectionKey = JSON.stringify(collection);
288-
const entryKey = JSON.stringify(id);
289-
const collectionConfig =
290-
contentConfig instanceof Error ? undefined : contentConfig.collections[collection];
291-
switch (event.name) {
292-
case 'add':
293-
if (!(collectionKey in contentTypes)) {
294-
addCollection(contentTypes, collectionKey);
295-
}
296-
if (!(entryKey in contentTypes[collectionKey])) {
297-
addEntry(contentTypes, collectionKey, entryKey, slug, collectionConfig);
298-
}
299-
if (shouldLog) {
300-
info(logging, 'content', msg.entryAdded(entryInfo.slug, entryInfo.collection));
301-
}
302-
break;
303-
case 'unlink':
304-
if (collectionKey in contentTypes && entryKey in contentTypes[collectionKey]) {
305-
removeEntry(contentTypes, collectionKey, entryKey);
306-
}
307-
break;
308-
case 'change':
309-
// noop. Frontmatter types are inferred from collection schema import, so they won't change!
310-
break;
311-
}
312-
}
313-
}
314-
315-
function queueEvent(event: ContentEvent, eventOpts?: { shouldLog: boolean }) {
316-
if (!event.entry.startsWith(paths.contentDir.pathname)) return;
317-
if (event.entry.endsWith(CONTENT_TYPES_FILE)) return;
318-
319-
events.push(onEvent(event, eventOpts));
320-
runEventsDebounced();
321-
}
342+
export function getPaths({ srcDir }: { srcDir: URL }): Paths {
343+
return {
344+
// Output generated types in content directory. May change in the future!
345+
cacheDir: new URL('./content/', srcDir),
346+
contentDir: new URL('./content/', srcDir),
347+
generatedInputDir: new URL('../../src/content/template/', import.meta.url),
348+
config: new URL('./content/config', srcDir),
349+
};
350+
}
322351

323-
function runEventsDebounced() {
324-
eventsSettled = new Promise((resolve, reject) => {
325-
try {
326-
debounceTimeout && clearTimeout(debounceTimeout);
327-
debounceTimeout = setTimeout(async () => {
328-
await Promise.all(events);
329-
await writeContentFiles({
330-
contentTypes,
331-
paths,
332-
contentTypesBase,
333-
hasContentConfig: !(contentConfig instanceof Error),
334-
});
335-
resolve();
336-
}, 50 /* debounce 50 ms to batch chokidar events */);
337-
} catch (e) {
338-
reject(e);
339-
}
340-
});
341-
}
342-
return { init, queueEvent };
352+
function isContentFlagImport({ searchParams, pathname }: Pick<URL, 'searchParams' | 'pathname'>) {
353+
return searchParams.has(CONTENT_FLAG) && contentFileExts.some((ext) => pathname.endsWith(ext));
343354
}
344355

345356
function addCollection(contentMap: ContentTypes, collectionKey: string) {

0 commit comments

Comments
 (0)