@@ -15,6 +15,8 @@ import {
15
15
ContentConfig ,
16
16
getEntryData ,
17
17
getEntrySlug ,
18
+ loadContentConfig ,
19
+ NotFoundError ,
18
20
parseFrontmatter ,
19
21
} from './utils.js' ;
20
22
import * as devalue from 'devalue' ;
@@ -26,23 +28,29 @@ type Paths = {
26
28
config : URL ;
27
29
} ;
28
30
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
+
29
46
const CONTENT_BASE = 'types.generated' ;
30
47
const CONTENT_FILE = CONTENT_BASE + '.mjs' ;
31
48
const CONTENT_TYPES_FILE = CONTENT_BASE + '.d.ts' ;
32
49
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
+ } ;
46
54
47
55
export function astroContentVirtualModPlugin ( { settings } : { settings : AstroSettings } ) : Plugin {
48
56
const paths = getPaths ( { srcDir : settings . config . srcDir } ) ;
@@ -88,6 +96,126 @@ export function astroContentServerPlugin({
88
96
const paths : Paths = getPaths ( { srcDir : settings . config . srcDir } ) ;
89
97
let contentDirExists = false ;
90
98
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
+
91
219
return [
92
220
{
93
221
name : 'content-flag-plugin' ,
@@ -151,7 +279,7 @@ export const _internal = {
151
279
152
280
info ( logging , 'content' , 'Generating entries...' ) ;
153
281
154
- contentGenerator = await toGenerateContent ( { logging , paths , contentConfig } ) ;
282
+ contentGenerator = await createContentGenerator ( ) ;
155
283
await contentGenerator . init ( ) ;
156
284
} ,
157
285
async configureServer ( viteServer ) {
@@ -175,18 +303,33 @@ export const _internal = {
175
303
}
176
304
177
305
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
+ } ) ;
181
324
viteServer . watcher . on ( 'addDir' , ( entry ) =>
182
325
contentGenerator . queueEvent ( { name : 'addDir' , entry } )
183
326
) ;
184
327
viteServer . watcher . on ( 'change' , ( entry ) =>
185
328
contentGenerator . queueEvent ( { name : 'change' , entry } )
186
329
) ;
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
+ } ) ;
190
333
viteServer . watcher . on ( 'unlinkDir' , ( entry ) =>
191
334
contentGenerator . queueEvent ( { name : 'unlinkDir' , entry } )
192
335
) ;
@@ -196,150 +339,18 @@ export const _internal = {
196
339
] ;
197
340
}
198
341
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
+ }
322
351
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 ) ) ;
343
354
}
344
355
345
356
function addCollection ( contentMap : ContentTypes , collectionKey : string ) {
0 commit comments