@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.
- package/package.json +4 -3
- package/src/prerender.js +356 -257
- package/src/runtime-schema.js +135 -0
- package/src/site/asset-processor.js +32 -0
- package/src/site/assets.js +82 -0
- 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
|
@@ -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
|
-
|
|
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:
|
|
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'
|