@uniweb/build 0.1.26 → 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.
@@ -12,6 +12,7 @@
12
12
  * - preset: Preset configuration name
13
13
  * - input: Input field mapping
14
14
  * - props: Additional component props (merged with other params)
15
+ * - fetch: Data fetching configuration (path, url, schema, prerender, merge, transform)
15
16
  *
16
17
  * Note: `component` is supported as an alias for `type` (deprecated)
17
18
  *
@@ -26,6 +27,7 @@ import { join, parse } from 'node:path'
26
27
  import { existsSync } from 'node:fs'
27
28
  import yaml from 'js-yaml'
28
29
  import { collectSectionAssets, mergeAssetCollections } from './assets.js'
30
+ import { parseFetchConfig, singularize } from './data-fetcher.js'
29
31
 
30
32
  // Try to import content-reader, fall back to simplified parser
31
33
  let markdownToProseMirror
@@ -45,6 +47,25 @@ try {
45
47
  })
46
48
  }
47
49
 
50
+ /**
51
+ * Check if a folder name represents a dynamic route (e.g., [slug], [id])
52
+ * @param {string} folderName - The folder name to check
53
+ * @returns {boolean}
54
+ */
55
+ function isDynamicRoute(folderName) {
56
+ return /^\[(\w+)\]$/.test(folderName)
57
+ }
58
+
59
+ /**
60
+ * Extract the parameter name from a dynamic route folder (e.g., [slug] → slug)
61
+ * @param {string} folderName - The folder name (e.g., "[slug]")
62
+ * @returns {string|null} The parameter name or null if not a dynamic route
63
+ */
64
+ function extractRouteParam(folderName) {
65
+ const match = folderName.match(/^\[(\w+)\]$/)
66
+ return match ? match[1] : null
67
+ }
68
+
48
69
  /**
49
70
  * Parse YAML string using js-yaml
50
71
  */
@@ -140,7 +161,7 @@ async function processMarkdownFile(filePath, id, siteRoot) {
140
161
  }
141
162
  }
142
163
 
143
- const { type, component, preset, input, props, ...params } = frontMatter
164
+ const { type, component, preset, input, props, fetch, ...params } = frontMatter
144
165
 
145
166
  // Convert markdown to ProseMirror
146
167
  const proseMirrorContent = markdownToProseMirror(markdown)
@@ -152,6 +173,7 @@ async function processMarkdownFile(filePath, id, siteRoot) {
152
173
  input,
153
174
  params: { ...params, ...props },
154
175
  content: proseMirrorContent,
176
+ fetch: parseFetchConfig(fetch),
155
177
  subsections: []
156
178
  }
157
179
 
@@ -293,9 +315,10 @@ async function processExplicitSections(sectionsConfig, pagePath, siteRoot, paren
293
315
  * @param {Object} options - Route options
294
316
  * @param {boolean} options.isIndex - Whether this page is the index for its parent route
295
317
  * @param {string} options.parentRoute - The parent route (e.g., '/' or '/docs')
318
+ * @param {Object} options.parentFetch - Parent page's fetch config (for dynamic routes)
296
319
  * @returns {Object} Page data with assets manifest
297
320
  */
298
- async function processPage(pagePath, pageName, siteRoot, { isIndex = false, parentRoute = '/' } = {}) {
321
+ async function processPage(pagePath, pageName, siteRoot, { isIndex = false, parentRoute = '/', parentFetch = null } = {}) {
299
322
  const pageConfig = await readYamlFile(join(pagePath, 'page.yml'))
300
323
 
301
324
  // Note: We no longer skip hidden pages here - they still exist as valid pages,
@@ -354,9 +377,16 @@ async function processPage(pagePath, pageName, siteRoot, { isIndex = false, pare
354
377
  // All pages get their actual folder-based route (no special treatment for index)
355
378
  // The isIndex flag marks which page should also be accessible at the parent route
356
379
  let route
380
+ const isDynamic = isDynamicRoute(pageName)
381
+ const paramName = isDynamic ? extractRouteParam(pageName) : null
382
+
357
383
  if (pageName.startsWith('@')) {
358
384
  // Special pages (layout areas) keep their @ prefix
359
385
  route = parentRoute === '/' ? `/@${pageName.slice(1)}` : `${parentRoute}/@${pageName.slice(1)}`
386
+ } else if (isDynamic) {
387
+ // Dynamic routes: /blog/[slug] → /blog/:slug (for route matching)
388
+ // The actual routes like /blog/my-post are generated at prerender time
389
+ route = parentRoute === '/' ? `/:${paramName}` : `${parentRoute}/:${paramName}`
360
390
  } else {
361
391
  // Normal pages get parent + their name
362
392
  route = parentRoute === '/' ? `/${pageName}` : `${parentRoute}/${pageName}`
@@ -365,6 +395,13 @@ async function processPage(pagePath, pageName, siteRoot, { isIndex = false, pare
365
395
  // Extract configuration
366
396
  const { seo = {}, layout = {}, ...restConfig } = pageConfig
367
397
 
398
+ // For dynamic routes, determine the parent's data schema
399
+ // This tells prerender which data array to iterate over
400
+ let parentSchema = null
401
+ if (isDynamic && parentFetch) {
402
+ parentSchema = parentFetch.schema
403
+ }
404
+
368
405
  return {
369
406
  page: {
370
407
  route,
@@ -375,6 +412,11 @@ async function processPage(pagePath, pageName, siteRoot, { isIndex = false, pare
375
412
  order: pageConfig.order,
376
413
  lastModified: lastModified?.toISOString(),
377
414
 
415
+ // Dynamic route metadata
416
+ isDynamic,
417
+ paramName, // e.g., "slug" from [slug]
418
+ parentSchema, // e.g., "articles" - the data array to iterate over
419
+
378
420
  // Navigation options
379
421
  hidden: pageConfig.hidden || false, // Hide from all navigation
380
422
  hideInHeader: pageConfig.hideInHeader || false, // Hide from header nav
@@ -394,6 +436,10 @@ async function processPage(pagePath, pageName, siteRoot, { isIndex = false, pare
394
436
  changefreq: seo.changefreq || null,
395
437
  priority: seo.priority || null
396
438
  },
439
+
440
+ // Data fetching
441
+ fetch: parseFetchConfig(pageConfig.fetch),
442
+
397
443
  sections: hierarchicalSections
398
444
  },
399
445
  assetCollection: pageAssetCollection
@@ -441,9 +487,10 @@ function determineIndexPage(orderConfig, availableFolders) {
441
487
  * @param {string} parentRoute - Parent route (e.g., '/' or '/docs')
442
488
  * @param {string} siteRoot - Site root directory for asset resolution
443
489
  * @param {Object} orderConfig - { pages: [...], index: 'name' } from parent's config
490
+ * @param {Object} parentFetch - Parent page's fetch config (for dynamic child routes)
444
491
  * @returns {Promise<Object>} { pages, assetCollection, header, footer, left, right }
445
492
  */
446
- async function collectPagesRecursive(dirPath, parentRoute, siteRoot, orderConfig = {}) {
493
+ async function collectPagesRecursive(dirPath, parentRoute, siteRoot, orderConfig = {}, parentFetch = null) {
447
494
  const entries = await readdir(dirPath)
448
495
  const pages = []
449
496
  let assetCollection = {
@@ -487,9 +534,11 @@ async function collectPagesRecursive(dirPath, parentRoute, siteRoot, orderConfig
487
534
  const isSpecial = entry.startsWith('@')
488
535
 
489
536
  // Process this directory as a page
537
+ // Pass parentFetch so dynamic routes can inherit parent's data schema
490
538
  const result = await processPage(entryPath, entry, siteRoot, {
491
539
  isIndex: isIndex && !isSpecial,
492
- parentRoute
540
+ parentRoute,
541
+ parentFetch
493
542
  })
494
543
 
495
544
  if (result) {
@@ -517,7 +566,9 @@ async function collectPagesRecursive(dirPath, parentRoute, siteRoot, orderConfig
517
566
  if (!isSpecial) {
518
567
  // The child route depends on whether this page is the index
519
568
  const childParentRoute = isIndex ? parentRoute : page.route
520
- const subResult = await collectPagesRecursive(entryPath, childParentRoute, siteRoot, childOrderConfig)
569
+ // Pass this page's fetch config to children (for dynamic routes that inherit parent data)
570
+ const childFetch = page.fetch || parentFetch
571
+ const subResult = await collectPagesRecursive(entryPath, childParentRoute, siteRoot, childOrderConfig, childFetch)
521
572
  pages.push(...subResult.pages)
522
573
  assetCollection = mergeAssetCollections(assetCollection, subResult.assetCollection)
523
574
  }
@@ -571,7 +622,10 @@ export async function collectSiteContent(sitePath) {
571
622
  }
572
623
 
573
624
  return {
574
- config: siteConfig,
625
+ config: {
626
+ ...siteConfig,
627
+ fetch: parseFetchConfig(siteConfig.fetch),
628
+ },
575
629
  theme: themeConfig,
576
630
  pages,
577
631
  header,
@@ -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'