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