@uniweb/build 0.1.27 → 0.1.29
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 +4 -4
- package/src/prerender.js +347 -7
- package/src/runtime-schema.js +34 -1
- package/src/site/collection-processor.js +382 -0
- package/src/site/config.js +46 -1
- 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 +74 -1
|
@@ -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
|
|
|
@@ -273,6 +274,7 @@ export function siteContentPlugin(options = {}) {
|
|
|
273
274
|
let server = null
|
|
274
275
|
let localeTranslations = {} // Cache: { locale: translations }
|
|
275
276
|
let localesDir = 'locales' // Default, updated from site config
|
|
277
|
+
let collectionsConfig = null // Cached for watcher setup
|
|
276
278
|
|
|
277
279
|
/**
|
|
278
280
|
* Load translations for a specific locale
|
|
@@ -331,10 +333,28 @@ export function siteContentPlugin(options = {}) {
|
|
|
331
333
|
return {
|
|
332
334
|
name: 'uniweb:site-content',
|
|
333
335
|
|
|
334
|
-
configResolved(config) {
|
|
336
|
+
async configResolved(config) {
|
|
335
337
|
resolvedSitePath = resolve(config.root, sitePath)
|
|
336
338
|
resolvedOutDir = resolve(config.root, config.build.outDir)
|
|
337
339
|
isProduction = config.command === 'build'
|
|
340
|
+
|
|
341
|
+
// In dev mode, process collections early so JSON files exist before server starts
|
|
342
|
+
// This runs before configureServer, ensuring data is available immediately
|
|
343
|
+
if (!isProduction) {
|
|
344
|
+
try {
|
|
345
|
+
// Do an early content collection to get the collections config
|
|
346
|
+
const earlyContent = await collectSiteContent(resolvedSitePath)
|
|
347
|
+
collectionsConfig = earlyContent.config?.collections
|
|
348
|
+
|
|
349
|
+
if (collectionsConfig) {
|
|
350
|
+
console.log('[site-content] Processing content collections...')
|
|
351
|
+
const collections = await processCollections(resolvedSitePath, collectionsConfig)
|
|
352
|
+
await writeCollectionFiles(resolvedSitePath, collections)
|
|
353
|
+
}
|
|
354
|
+
} catch (err) {
|
|
355
|
+
console.warn('[site-content] Early collection processing failed:', err.message)
|
|
356
|
+
}
|
|
357
|
+
}
|
|
338
358
|
},
|
|
339
359
|
|
|
340
360
|
async buildStart() {
|
|
@@ -343,6 +363,15 @@ export function siteContentPlugin(options = {}) {
|
|
|
343
363
|
siteContent = await collectSiteContent(resolvedSitePath)
|
|
344
364
|
console.log(`[site-content] Collected ${siteContent.pages?.length || 0} pages`)
|
|
345
365
|
|
|
366
|
+
// Process content collections if defined in site.yml
|
|
367
|
+
// In dev mode, this was already done in configResolved (before server starts)
|
|
368
|
+
// In production, do it here
|
|
369
|
+
if (isProduction && siteContent.config?.collections) {
|
|
370
|
+
console.log('[site-content] Processing content collections...')
|
|
371
|
+
const collections = await processCollections(resolvedSitePath, siteContent.config.collections)
|
|
372
|
+
await writeCollectionFiles(resolvedSitePath, collections)
|
|
373
|
+
}
|
|
374
|
+
|
|
346
375
|
// Update localesDir from site config
|
|
347
376
|
if (siteContent.config?.i18n?.localesDir) {
|
|
348
377
|
localesDir = siteContent.config.i18n.localesDir
|
|
@@ -383,6 +412,27 @@ export function siteContentPlugin(options = {}) {
|
|
|
383
412
|
}, 100)
|
|
384
413
|
}
|
|
385
414
|
|
|
415
|
+
// Debounce collection rebuilds separately (writes to file system)
|
|
416
|
+
let collectionRebuildTimeout = null
|
|
417
|
+
const scheduleCollectionRebuild = () => {
|
|
418
|
+
if (collectionRebuildTimeout) clearTimeout(collectionRebuildTimeout)
|
|
419
|
+
collectionRebuildTimeout = setTimeout(async () => {
|
|
420
|
+
console.log('[site-content] Collection content changed, regenerating JSON...')
|
|
421
|
+
try {
|
|
422
|
+
// Use collectionsConfig (cached from configResolved) or siteContent
|
|
423
|
+
const collections = collectionsConfig || siteContent?.config?.collections
|
|
424
|
+
if (collections) {
|
|
425
|
+
const processed = await processCollections(resolvedSitePath, collections)
|
|
426
|
+
await writeCollectionFiles(resolvedSitePath, processed)
|
|
427
|
+
}
|
|
428
|
+
// Send full reload to client
|
|
429
|
+
server.ws.send({ type: 'full-reload' })
|
|
430
|
+
} catch (err) {
|
|
431
|
+
console.error('[site-content] Collection rebuild failed:', err.message)
|
|
432
|
+
}
|
|
433
|
+
}, 100)
|
|
434
|
+
}
|
|
435
|
+
|
|
386
436
|
// Track all watchers for cleanup
|
|
387
437
|
const watchers = []
|
|
388
438
|
|
|
@@ -408,6 +458,29 @@ export function siteContentPlugin(options = {}) {
|
|
|
408
458
|
// theme.yml may not exist, that's ok
|
|
409
459
|
}
|
|
410
460
|
|
|
461
|
+
// Watch content/ folder for collection changes
|
|
462
|
+
// Use collectionsConfig cached from configResolved (siteContent may be null here)
|
|
463
|
+
if (collectionsConfig) {
|
|
464
|
+
const contentPaths = new Set()
|
|
465
|
+
for (const config of Object.values(collectionsConfig)) {
|
|
466
|
+
const collectionPath = typeof config === 'string' ? config : config.path
|
|
467
|
+
if (collectionPath) {
|
|
468
|
+
contentPaths.add(resolve(resolvedSitePath, collectionPath))
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
for (const contentPath of contentPaths) {
|
|
473
|
+
if (existsSync(contentPath)) {
|
|
474
|
+
try {
|
|
475
|
+
watchers.push(watch(contentPath, { recursive: true }, scheduleCollectionRebuild))
|
|
476
|
+
console.log(`[site-content] Watching ${contentPath} for collection changes`)
|
|
477
|
+
} catch (err) {
|
|
478
|
+
console.warn('[site-content] Could not watch content directory:', err.message)
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
|
|
411
484
|
// Store watchers for cleanup
|
|
412
485
|
watcher = { close: () => watchers.forEach(w => w.close()) }
|
|
413
486
|
}
|