@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.
@@ -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'
@@ -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
  }