@uniweb/build 0.1.27 → 0.1.28
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +3 -3
- package/src/prerender.js +347 -7
- package/src/runtime-schema.js +34 -1
- package/src/site/collection-processor.js +382 -0
- package/src/site/content-collector.js +60 -6
- package/src/site/data-fetcher.js +496 -0
- package/src/site/index.js +14 -0
- package/src/site/plugin.js +50 -0
|
@@ -0,0 +1,496 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Data Fetcher Utilities
|
|
3
|
+
*
|
|
4
|
+
* Handles parsing fetch configurations and executing data fetches
|
|
5
|
+
* from local files (public/) or remote URLs.
|
|
6
|
+
*
|
|
7
|
+
* Supports:
|
|
8
|
+
* - Simple string paths: "/data/team.json"
|
|
9
|
+
* - Full config objects with schema, prerender, merge, transform options
|
|
10
|
+
* - Collection references: { collection: 'articles', limit: 3 }
|
|
11
|
+
* - Local JSON/YAML files
|
|
12
|
+
* - Remote URLs
|
|
13
|
+
* - Transform paths to extract nested data
|
|
14
|
+
* - Post-processing: limit, sort, filter
|
|
15
|
+
*
|
|
16
|
+
* @module @uniweb/build/site/data-fetcher
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { readFile } from 'node:fs/promises'
|
|
20
|
+
import { join } from 'node:path'
|
|
21
|
+
import { existsSync } from 'node:fs'
|
|
22
|
+
import yaml from 'js-yaml'
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Infer schema name from path or URL
|
|
26
|
+
* Extracts filename without extension as the schema key
|
|
27
|
+
*
|
|
28
|
+
* @param {string} pathOrUrl - File path or URL
|
|
29
|
+
* @returns {string} Schema name
|
|
30
|
+
*
|
|
31
|
+
* @example
|
|
32
|
+
* inferSchemaFromPath('/data/team-members.json') // 'team-members'
|
|
33
|
+
* inferSchemaFromPath('https://api.com/users') // 'users'
|
|
34
|
+
*/
|
|
35
|
+
function inferSchemaFromPath(pathOrUrl) {
|
|
36
|
+
if (!pathOrUrl) return 'data'
|
|
37
|
+
|
|
38
|
+
// Get the last path segment
|
|
39
|
+
const segment = pathOrUrl.split('/').pop()
|
|
40
|
+
// Remove query string
|
|
41
|
+
const filename = segment.split('?')[0]
|
|
42
|
+
// Remove extension
|
|
43
|
+
return filename.replace(/\.(json|yaml|yml)$/i, '')
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Get a nested value from an object using dot notation
|
|
48
|
+
*
|
|
49
|
+
* @param {object} obj - Source object
|
|
50
|
+
* @param {string} path - Dot-separated path (e.g., 'data.items')
|
|
51
|
+
* @returns {any} The nested value or undefined
|
|
52
|
+
*/
|
|
53
|
+
function getNestedValue(obj, path) {
|
|
54
|
+
if (!obj || !path) return obj
|
|
55
|
+
|
|
56
|
+
const parts = path.split('.')
|
|
57
|
+
let current = obj
|
|
58
|
+
|
|
59
|
+
for (const part of parts) {
|
|
60
|
+
if (current === null || current === undefined) return undefined
|
|
61
|
+
current = current[part]
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
return current
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Parse a filter value from string
|
|
69
|
+
*
|
|
70
|
+
* @param {string} raw - Raw value string
|
|
71
|
+
* @returns {any} Parsed value
|
|
72
|
+
*/
|
|
73
|
+
function parseFilterValue(raw) {
|
|
74
|
+
if (raw === 'true') return true
|
|
75
|
+
if (raw === 'false') return false
|
|
76
|
+
if (raw === 'null') return null
|
|
77
|
+
if (/^\d+$/.test(raw)) return parseInt(raw, 10)
|
|
78
|
+
if (/^\d+\.\d+$/.test(raw)) return parseFloat(raw)
|
|
79
|
+
|
|
80
|
+
// Remove quotes if present
|
|
81
|
+
if ((raw.startsWith('"') && raw.endsWith('"')) ||
|
|
82
|
+
(raw.startsWith("'") && raw.endsWith("'"))) {
|
|
83
|
+
return raw.slice(1, -1)
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
return raw
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Apply filter expression to array of items
|
|
91
|
+
*
|
|
92
|
+
* Supported operators: ==, !=, >, <, >=, <=, contains
|
|
93
|
+
*
|
|
94
|
+
* @param {Array} items - Items to filter
|
|
95
|
+
* @param {string} filterExpr - Filter expression (e.g., "published != false")
|
|
96
|
+
* @returns {Array} Filtered items
|
|
97
|
+
*
|
|
98
|
+
* @example
|
|
99
|
+
* applyFilter(items, 'published != false')
|
|
100
|
+
* applyFilter(items, 'tags contains featured')
|
|
101
|
+
*/
|
|
102
|
+
export function applyFilter(items, filterExpr) {
|
|
103
|
+
if (!filterExpr || !Array.isArray(items)) return items
|
|
104
|
+
|
|
105
|
+
const match = filterExpr.match(/^(\S+)\s*(==|!=|>=?|<=?|contains)\s*(.+)$/)
|
|
106
|
+
if (!match) return items
|
|
107
|
+
|
|
108
|
+
const [, field, op, rawValue] = match
|
|
109
|
+
const value = parseFilterValue(rawValue.trim())
|
|
110
|
+
|
|
111
|
+
return items.filter(item => {
|
|
112
|
+
const itemValue = getNestedValue(item, field)
|
|
113
|
+
switch (op) {
|
|
114
|
+
case '==': return itemValue === value
|
|
115
|
+
case '!=': return itemValue !== value
|
|
116
|
+
case '>': return itemValue > value
|
|
117
|
+
case '<': return itemValue < value
|
|
118
|
+
case '>=': return itemValue >= value
|
|
119
|
+
case '<=': return itemValue <= value
|
|
120
|
+
case 'contains':
|
|
121
|
+
return Array.isArray(itemValue)
|
|
122
|
+
? itemValue.includes(value)
|
|
123
|
+
: String(itemValue).includes(value)
|
|
124
|
+
default: return true
|
|
125
|
+
}
|
|
126
|
+
})
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Apply sort expression to array of items
|
|
131
|
+
*
|
|
132
|
+
* @param {Array} items - Items to sort
|
|
133
|
+
* @param {string} sortExpr - Sort expression (e.g., "date desc" or "order asc, title asc")
|
|
134
|
+
* @returns {Array} Sorted items (new array)
|
|
135
|
+
*
|
|
136
|
+
* @example
|
|
137
|
+
* applySort(items, 'date desc')
|
|
138
|
+
* applySort(items, 'order asc, title asc')
|
|
139
|
+
*/
|
|
140
|
+
export function applySort(items, sortExpr) {
|
|
141
|
+
if (!sortExpr || !Array.isArray(items)) return items
|
|
142
|
+
|
|
143
|
+
const sorts = sortExpr.split(',').map(s => {
|
|
144
|
+
const [field, dir = 'asc'] = s.trim().split(/\s+/)
|
|
145
|
+
return { field, desc: dir.toLowerCase() === 'desc' }
|
|
146
|
+
})
|
|
147
|
+
|
|
148
|
+
return [...items].sort((a, b) => {
|
|
149
|
+
for (const { field, desc } of sorts) {
|
|
150
|
+
const aVal = getNestedValue(a, field) ?? ''
|
|
151
|
+
const bVal = getNestedValue(b, field) ?? ''
|
|
152
|
+
if (aVal < bVal) return desc ? 1 : -1
|
|
153
|
+
if (aVal > bVal) return desc ? -1 : 1
|
|
154
|
+
}
|
|
155
|
+
return 0
|
|
156
|
+
})
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Apply post-processing to fetched data (filter, sort, limit)
|
|
161
|
+
*
|
|
162
|
+
* @param {any} data - Fetched data
|
|
163
|
+
* @param {object} config - Fetch config with optional filter, sort, limit
|
|
164
|
+
* @returns {any} Processed data
|
|
165
|
+
*/
|
|
166
|
+
export function applyPostProcessing(data, config) {
|
|
167
|
+
if (!data || !Array.isArray(data)) return data
|
|
168
|
+
if (!config.filter && !config.sort && !config.limit) return data
|
|
169
|
+
|
|
170
|
+
let result = data
|
|
171
|
+
|
|
172
|
+
// Apply filter first
|
|
173
|
+
if (config.filter) {
|
|
174
|
+
result = applyFilter(result, config.filter)
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// Apply sort
|
|
178
|
+
if (config.sort) {
|
|
179
|
+
result = applySort(result, config.sort)
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// Apply limit last
|
|
183
|
+
if (config.limit && config.limit > 0) {
|
|
184
|
+
result = result.slice(0, config.limit)
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
return result
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Normalize a fetch configuration to standard form
|
|
192
|
+
*
|
|
193
|
+
* @param {string|object} fetch - Simple path string or full config object
|
|
194
|
+
* @returns {object|null} Normalized config or null if invalid
|
|
195
|
+
*
|
|
196
|
+
* @example
|
|
197
|
+
* // Simple string
|
|
198
|
+
* parseFetchConfig('/data/team.json')
|
|
199
|
+
* // Returns: { path: '/data/team.json', schema: 'team', prerender: true, merge: false }
|
|
200
|
+
*
|
|
201
|
+
* // Full config
|
|
202
|
+
* parseFetchConfig({ path: '/team', schema: 'person', prerender: false })
|
|
203
|
+
* // Returns: { path: '/team', schema: 'person', prerender: false, merge: false }
|
|
204
|
+
*
|
|
205
|
+
* // Collection reference
|
|
206
|
+
* parseFetchConfig({ collection: 'articles', limit: 3, sort: 'date desc' })
|
|
207
|
+
* // Returns: { path: '/data/articles.json', schema: 'articles', limit: 3, sort: 'date desc', ... }
|
|
208
|
+
*/
|
|
209
|
+
export function parseFetchConfig(fetch) {
|
|
210
|
+
if (!fetch) return null
|
|
211
|
+
|
|
212
|
+
// Simple string: "/data/team.json"
|
|
213
|
+
if (typeof fetch === 'string') {
|
|
214
|
+
const schema = inferSchemaFromPath(fetch)
|
|
215
|
+
return {
|
|
216
|
+
path: fetch,
|
|
217
|
+
url: undefined,
|
|
218
|
+
schema,
|
|
219
|
+
prerender: true,
|
|
220
|
+
merge: false,
|
|
221
|
+
transform: undefined,
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// Full config object
|
|
226
|
+
if (typeof fetch !== 'object') return null
|
|
227
|
+
|
|
228
|
+
// Collection reference: { collection: 'articles', limit: 3 }
|
|
229
|
+
if (fetch.collection) {
|
|
230
|
+
return {
|
|
231
|
+
path: `/data/${fetch.collection}.json`,
|
|
232
|
+
url: undefined,
|
|
233
|
+
schema: fetch.schema || fetch.collection,
|
|
234
|
+
prerender: fetch.prerender ?? true,
|
|
235
|
+
merge: fetch.merge ?? false,
|
|
236
|
+
transform: fetch.transform,
|
|
237
|
+
// Post-processing options
|
|
238
|
+
limit: fetch.limit,
|
|
239
|
+
sort: fetch.sort,
|
|
240
|
+
filter: fetch.filter,
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
const {
|
|
245
|
+
path,
|
|
246
|
+
url,
|
|
247
|
+
schema,
|
|
248
|
+
prerender = true,
|
|
249
|
+
merge = false,
|
|
250
|
+
transform,
|
|
251
|
+
// Post-processing options (also supported for path/url fetches)
|
|
252
|
+
limit,
|
|
253
|
+
sort,
|
|
254
|
+
filter,
|
|
255
|
+
} = fetch
|
|
256
|
+
|
|
257
|
+
// Must have either path or url
|
|
258
|
+
if (!path && !url) return null
|
|
259
|
+
|
|
260
|
+
return {
|
|
261
|
+
path,
|
|
262
|
+
url,
|
|
263
|
+
schema: schema || inferSchemaFromPath(path || url),
|
|
264
|
+
prerender,
|
|
265
|
+
merge,
|
|
266
|
+
transform,
|
|
267
|
+
// Post-processing options
|
|
268
|
+
limit,
|
|
269
|
+
sort,
|
|
270
|
+
filter,
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/**
|
|
275
|
+
* Execute a fetch operation
|
|
276
|
+
*
|
|
277
|
+
* @param {object} config - Normalized fetch config from parseFetchConfig
|
|
278
|
+
* @param {object} options - Execution options
|
|
279
|
+
* @param {string} options.siteRoot - Site root directory
|
|
280
|
+
* @param {string} [options.publicDir='public'] - Public directory name
|
|
281
|
+
* @returns {Promise<{ data: any, error?: string }>} Fetched data or error
|
|
282
|
+
*
|
|
283
|
+
* @example
|
|
284
|
+
* const result = await executeFetch(
|
|
285
|
+
* { path: '/data/team.json', schema: 'team' },
|
|
286
|
+
* { siteRoot: '/path/to/site' }
|
|
287
|
+
* )
|
|
288
|
+
* // result.data contains the parsed JSON
|
|
289
|
+
*
|
|
290
|
+
* @example
|
|
291
|
+
* // With post-processing
|
|
292
|
+
* const result = await executeFetch(
|
|
293
|
+
* { path: '/data/articles.json', limit: 3, sort: 'date desc' },
|
|
294
|
+
* { siteRoot: '/path/to/site' }
|
|
295
|
+
* )
|
|
296
|
+
* // result.data contains the 3 most recent articles
|
|
297
|
+
*/
|
|
298
|
+
export async function executeFetch(config, options = {}) {
|
|
299
|
+
if (!config) return { data: null }
|
|
300
|
+
|
|
301
|
+
const { path, url, transform } = config
|
|
302
|
+
const { siteRoot, publicDir = 'public' } = options
|
|
303
|
+
|
|
304
|
+
try {
|
|
305
|
+
let data
|
|
306
|
+
|
|
307
|
+
if (path) {
|
|
308
|
+
// Local file from public/
|
|
309
|
+
const filePath = join(siteRoot, publicDir, path)
|
|
310
|
+
|
|
311
|
+
if (!existsSync(filePath)) {
|
|
312
|
+
console.warn(`[data-fetcher] File not found: ${filePath}`)
|
|
313
|
+
return { data: [], error: `File not found: ${path}` }
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
const content = await readFile(filePath, 'utf8')
|
|
317
|
+
|
|
318
|
+
// Parse based on extension
|
|
319
|
+
if (path.endsWith('.json')) {
|
|
320
|
+
data = JSON.parse(content)
|
|
321
|
+
} else if (path.endsWith('.yaml') || path.endsWith('.yml')) {
|
|
322
|
+
data = yaml.load(content)
|
|
323
|
+
} else {
|
|
324
|
+
// Try JSON first, then YAML
|
|
325
|
+
try {
|
|
326
|
+
data = JSON.parse(content)
|
|
327
|
+
} catch {
|
|
328
|
+
data = yaml.load(content)
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
} else if (url) {
|
|
332
|
+
// Remote URL
|
|
333
|
+
const response = await globalThis.fetch(url)
|
|
334
|
+
if (!response.ok) {
|
|
335
|
+
throw new Error(`HTTP ${response.status}: ${response.statusText}`)
|
|
336
|
+
}
|
|
337
|
+
data = await response.json()
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
// Apply transform if specified (extract nested data)
|
|
341
|
+
if (transform && data) {
|
|
342
|
+
data = getNestedValue(data, transform)
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
// Apply post-processing (filter, sort, limit)
|
|
346
|
+
data = applyPostProcessing(data, config)
|
|
347
|
+
|
|
348
|
+
// Ensure we return an array or object, defaulting to empty array
|
|
349
|
+
return { data: data ?? [] }
|
|
350
|
+
} catch (error) {
|
|
351
|
+
console.warn(`[data-fetcher] Fetch failed: ${error.message}`)
|
|
352
|
+
return { data: [], error: error.message }
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
/**
|
|
357
|
+
* Merge fetched data into existing content
|
|
358
|
+
*
|
|
359
|
+
* @param {object} content - Existing content object with data property
|
|
360
|
+
* @param {any} fetchedData - Data from fetch
|
|
361
|
+
* @param {string} schema - Schema key to store under
|
|
362
|
+
* @param {boolean} [merge=false] - If true, merge with existing data; if false, replace
|
|
363
|
+
* @returns {object} Updated content object
|
|
364
|
+
*
|
|
365
|
+
* @example
|
|
366
|
+
* const content = { data: { team: [{ name: 'Local' }] } }
|
|
367
|
+
* const fetched = [{ name: 'Remote' }]
|
|
368
|
+
*
|
|
369
|
+
* // Replace (default)
|
|
370
|
+
* mergeDataIntoContent(content, fetched, 'team', false)
|
|
371
|
+
* // content.data.team = [{ name: 'Remote' }]
|
|
372
|
+
*
|
|
373
|
+
* // Merge
|
|
374
|
+
* mergeDataIntoContent(content, fetched, 'team', true)
|
|
375
|
+
* // content.data.team = [{ name: 'Local' }, { name: 'Remote' }]
|
|
376
|
+
*/
|
|
377
|
+
export function mergeDataIntoContent(content, fetchedData, schema, merge = false) {
|
|
378
|
+
if (fetchedData === null || fetchedData === undefined || !schema) {
|
|
379
|
+
return content
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
// Create a new content object with updated data
|
|
383
|
+
const result = {
|
|
384
|
+
...content,
|
|
385
|
+
data: { ...(content.data || {}) },
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
if (merge && result.data[schema] !== undefined) {
|
|
389
|
+
// Merge mode: combine with existing data
|
|
390
|
+
const existing = result.data[schema]
|
|
391
|
+
|
|
392
|
+
if (Array.isArray(existing) && Array.isArray(fetchedData)) {
|
|
393
|
+
// Arrays: concatenate
|
|
394
|
+
result.data[schema] = [...existing, ...fetchedData]
|
|
395
|
+
} else if (
|
|
396
|
+
typeof existing === 'object' &&
|
|
397
|
+
existing !== null &&
|
|
398
|
+
typeof fetchedData === 'object' &&
|
|
399
|
+
fetchedData !== null &&
|
|
400
|
+
!Array.isArray(existing) &&
|
|
401
|
+
!Array.isArray(fetchedData)
|
|
402
|
+
) {
|
|
403
|
+
// Objects: shallow merge
|
|
404
|
+
result.data[schema] = { ...existing, ...fetchedData }
|
|
405
|
+
} else {
|
|
406
|
+
// Different types: fetched data wins
|
|
407
|
+
result.data[schema] = fetchedData
|
|
408
|
+
}
|
|
409
|
+
} else {
|
|
410
|
+
// Replace mode (default): fetched data overwrites
|
|
411
|
+
result.data[schema] = fetchedData
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
return result
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
/**
|
|
418
|
+
* Convert a plural schema name to singular
|
|
419
|
+
* Used for dynamic routes where the parent has "articles" and
|
|
420
|
+
* each child page gets the singular "article" for the current item
|
|
421
|
+
*
|
|
422
|
+
* @param {string} name - Plural name (e.g., 'articles', 'posts', 'people')
|
|
423
|
+
* @returns {string} Singular name (e.g., 'article', 'post', 'person')
|
|
424
|
+
*
|
|
425
|
+
* @example
|
|
426
|
+
* singularize('articles') // 'article'
|
|
427
|
+
* singularize('posts') // 'post'
|
|
428
|
+
* singularize('people') // 'person'
|
|
429
|
+
* singularize('categories') // 'category'
|
|
430
|
+
*/
|
|
431
|
+
export function singularize(name) {
|
|
432
|
+
if (!name) return name
|
|
433
|
+
|
|
434
|
+
// Handle common irregular plurals
|
|
435
|
+
const irregulars = {
|
|
436
|
+
people: 'person',
|
|
437
|
+
children: 'child',
|
|
438
|
+
men: 'man',
|
|
439
|
+
women: 'woman',
|
|
440
|
+
feet: 'foot',
|
|
441
|
+
teeth: 'tooth',
|
|
442
|
+
mice: 'mouse',
|
|
443
|
+
geese: 'goose',
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
if (irregulars[name]) return irregulars[name]
|
|
447
|
+
|
|
448
|
+
// Standard rules (in order of specificity)
|
|
449
|
+
if (name.endsWith('ies')) {
|
|
450
|
+
// categories -> category
|
|
451
|
+
return name.slice(0, -3) + 'y'
|
|
452
|
+
}
|
|
453
|
+
if (name.endsWith('ves')) {
|
|
454
|
+
// leaves -> leaf
|
|
455
|
+
return name.slice(0, -3) + 'f'
|
|
456
|
+
}
|
|
457
|
+
if (name.endsWith('es') && (name.endsWith('shes') || name.endsWith('ches') || name.endsWith('xes') || name.endsWith('sses') || name.endsWith('zes'))) {
|
|
458
|
+
// boxes -> box, watches -> watch
|
|
459
|
+
return name.slice(0, -2)
|
|
460
|
+
}
|
|
461
|
+
if (name.endsWith('s') && !name.endsWith('ss')) {
|
|
462
|
+
// articles -> article
|
|
463
|
+
return name.slice(0, -1)
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
return name
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
/**
|
|
470
|
+
* Execute multiple fetch operations in parallel
|
|
471
|
+
*
|
|
472
|
+
* @param {object[]} configs - Array of normalized fetch configs
|
|
473
|
+
* @param {object} options - Execution options (same as executeFetch)
|
|
474
|
+
* @returns {Promise<Map<string, any>>} Map of schema -> data
|
|
475
|
+
*/
|
|
476
|
+
export async function executeMultipleFetches(configs, options = {}) {
|
|
477
|
+
if (!configs || configs.length === 0) {
|
|
478
|
+
return new Map()
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
const results = await Promise.all(
|
|
482
|
+
configs.map(async (config) => {
|
|
483
|
+
const result = await executeFetch(config, options)
|
|
484
|
+
return { schema: config.schema, data: result.data }
|
|
485
|
+
})
|
|
486
|
+
)
|
|
487
|
+
|
|
488
|
+
const dataMap = new Map()
|
|
489
|
+
for (const { schema, data } of results) {
|
|
490
|
+
if (data !== null) {
|
|
491
|
+
dataMap.set(schema, data)
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
return dataMap
|
|
496
|
+
}
|
package/src/site/index.js
CHANGED
|
@@ -31,3 +31,17 @@ export {
|
|
|
31
31
|
isVideoFile,
|
|
32
32
|
isPdfFile
|
|
33
33
|
} from './advanced-processors.js'
|
|
34
|
+
export {
|
|
35
|
+
processCollections,
|
|
36
|
+
writeCollectionFiles,
|
|
37
|
+
getCollectionLastModified
|
|
38
|
+
} from './collection-processor.js'
|
|
39
|
+
export {
|
|
40
|
+
parseFetchConfig,
|
|
41
|
+
executeFetch,
|
|
42
|
+
applyFilter,
|
|
43
|
+
applySort,
|
|
44
|
+
applyPostProcessing,
|
|
45
|
+
mergeDataIntoContent,
|
|
46
|
+
singularize
|
|
47
|
+
} from './data-fetcher.js'
|
package/src/site/plugin.js
CHANGED
|
@@ -36,6 +36,7 @@ import { readFile, readdir } from 'node:fs/promises'
|
|
|
36
36
|
import { collectSiteContent } from './content-collector.js'
|
|
37
37
|
import { processAssets, rewriteSiteContentPaths } from './asset-processor.js'
|
|
38
38
|
import { processAdvancedAssets } from './advanced-processors.js'
|
|
39
|
+
import { processCollections, writeCollectionFiles } from './collection-processor.js'
|
|
39
40
|
import { generateSearchIndex, isSearchEnabled, getSearchIndexFilename } from '../search/index.js'
|
|
40
41
|
import { mergeTranslations } from '../i18n/merge.js'
|
|
41
42
|
|
|
@@ -343,6 +344,14 @@ export function siteContentPlugin(options = {}) {
|
|
|
343
344
|
siteContent = await collectSiteContent(resolvedSitePath)
|
|
344
345
|
console.log(`[site-content] Collected ${siteContent.pages?.length || 0} pages`)
|
|
345
346
|
|
|
347
|
+
// Process content collections if defined in site.yml
|
|
348
|
+
// This generates JSON files in public/data/ BEFORE the Vite build
|
|
349
|
+
if (siteContent.config?.collections) {
|
|
350
|
+
console.log('[site-content] Processing content collections...')
|
|
351
|
+
const collections = await processCollections(resolvedSitePath, siteContent.config.collections)
|
|
352
|
+
await writeCollectionFiles(resolvedSitePath, collections)
|
|
353
|
+
}
|
|
354
|
+
|
|
346
355
|
// Update localesDir from site config
|
|
347
356
|
if (siteContent.config?.i18n?.localesDir) {
|
|
348
357
|
localesDir = siteContent.config.i18n.localesDir
|
|
@@ -383,6 +392,25 @@ export function siteContentPlugin(options = {}) {
|
|
|
383
392
|
}, 100)
|
|
384
393
|
}
|
|
385
394
|
|
|
395
|
+
// Debounce collection rebuilds separately (writes to file system)
|
|
396
|
+
let collectionRebuildTimeout = null
|
|
397
|
+
const scheduleCollectionRebuild = () => {
|
|
398
|
+
if (collectionRebuildTimeout) clearTimeout(collectionRebuildTimeout)
|
|
399
|
+
collectionRebuildTimeout = setTimeout(async () => {
|
|
400
|
+
console.log('[site-content] Collection content changed, regenerating JSON...')
|
|
401
|
+
try {
|
|
402
|
+
if (siteContent?.config?.collections) {
|
|
403
|
+
const collections = await processCollections(resolvedSitePath, siteContent.config.collections)
|
|
404
|
+
await writeCollectionFiles(resolvedSitePath, collections)
|
|
405
|
+
}
|
|
406
|
+
// Send full reload to client
|
|
407
|
+
server.ws.send({ type: 'full-reload' })
|
|
408
|
+
} catch (err) {
|
|
409
|
+
console.error('[site-content] Collection rebuild failed:', err.message)
|
|
410
|
+
}
|
|
411
|
+
}, 100)
|
|
412
|
+
}
|
|
413
|
+
|
|
386
414
|
// Track all watchers for cleanup
|
|
387
415
|
const watchers = []
|
|
388
416
|
|
|
@@ -408,6 +436,28 @@ export function siteContentPlugin(options = {}) {
|
|
|
408
436
|
// theme.yml may not exist, that's ok
|
|
409
437
|
}
|
|
410
438
|
|
|
439
|
+
// Watch content/ folder for collection changes
|
|
440
|
+
if (siteContent?.config?.collections) {
|
|
441
|
+
const contentPaths = new Set()
|
|
442
|
+
for (const config of Object.values(siteContent.config.collections)) {
|
|
443
|
+
const collectionPath = typeof config === 'string' ? config : config.path
|
|
444
|
+
if (collectionPath) {
|
|
445
|
+
contentPaths.add(resolve(resolvedSitePath, collectionPath))
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
for (const contentPath of contentPaths) {
|
|
450
|
+
if (existsSync(contentPath)) {
|
|
451
|
+
try {
|
|
452
|
+
watchers.push(watch(contentPath, { recursive: true }, scheduleCollectionRebuild))
|
|
453
|
+
console.log(`[site-content] Watching ${contentPath} for collection changes`)
|
|
454
|
+
} catch (err) {
|
|
455
|
+
console.warn('[site-content] Could not watch content directory:', err.message)
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
|
|
411
461
|
// Store watchers for cleanup
|
|
412
462
|
watcher = { close: () => watchers.forEach(w => w.close()) }
|
|
413
463
|
}
|