@uniweb/build 0.9.4 → 0.10.0
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 +3 -3
- package/src/dev-backend.js +247 -0
- package/src/prerender.js +20 -16
- package/src/runtime-schema.js +73 -38
- package/src/site/collection-processor.js +59 -6
- package/src/site/data-fetcher.js +82 -11
- package/src/site/plugin.js +3 -3
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@uniweb/build",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.10.0",
|
|
4
4
|
"description": "Build tooling for the Uniweb Component Web Platform",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
@@ -54,8 +54,8 @@
|
|
|
54
54
|
"@uniweb/theming": "0.1.3"
|
|
55
55
|
},
|
|
56
56
|
"optionalDependencies": {
|
|
57
|
-
"@uniweb/runtime": "0.7.4",
|
|
58
57
|
"@uniweb/content-reader": "1.1.4",
|
|
58
|
+
"@uniweb/runtime": "0.8.0",
|
|
59
59
|
"@uniweb/schemas": "0.2.1"
|
|
60
60
|
},
|
|
61
61
|
"peerDependencies": {
|
|
@@ -65,7 +65,7 @@
|
|
|
65
65
|
"@tailwindcss/vite": "^4.0.0",
|
|
66
66
|
"@vitejs/plugin-react": "^4.0.0 || ^5.0.0",
|
|
67
67
|
"vite-plugin-svgr": "^4.0.0",
|
|
68
|
-
"@uniweb/core": "0.
|
|
68
|
+
"@uniweb/core": "0.7.0"
|
|
69
69
|
},
|
|
70
70
|
"peerDependenciesMeta": {
|
|
71
71
|
"vite": {
|
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Dev backend for testing Uniweb sites with `supports: [where, limit, sort]`.
|
|
4
|
+
*
|
|
5
|
+
* Reads a directory of YAML collections (each subfolder is a collection,
|
|
6
|
+
* each .yml file inside is a record) and exposes them via HTTP. Evaluates
|
|
7
|
+
* where-objects on the server side using @uniweb/core's matchWhere — the
|
|
8
|
+
* exact same evaluator the runtime uses as a fallback. This lets you
|
|
9
|
+
* develop a site against a "real" backend without standing up a database.
|
|
10
|
+
*
|
|
11
|
+
* Wire format matches the framework default fetcher's pushdown conventions
|
|
12
|
+
* (see framework/runtime/src/default-fetcher.js):
|
|
13
|
+
*
|
|
14
|
+
* GET /api/{collection} — full collection
|
|
15
|
+
* GET /api/{collection}?_where=<JSON> — filtered by where-object
|
|
16
|
+
* GET /api/{collection}?_limit=N — first N records
|
|
17
|
+
* GET /api/{collection}?_sort=field:dir — sorted
|
|
18
|
+
* POST /api/{collection} body: { where, ... } — operators in body
|
|
19
|
+
* GET /api/{collection}/{slug} — single record
|
|
20
|
+
*
|
|
21
|
+
* Usage:
|
|
22
|
+
* node scripts/framework/dev-backend.js --collections <path> [--port N]
|
|
23
|
+
*
|
|
24
|
+
* Example (academic-metrics):
|
|
25
|
+
* node scripts/framework/dev-backend.js \
|
|
26
|
+
* --collections framework/templates/academic-metrics/site/collections \
|
|
27
|
+
* --port 8080
|
|
28
|
+
*
|
|
29
|
+
* Then in the site's site.yml:
|
|
30
|
+
* fetcher:
|
|
31
|
+
* baseUrl: http://localhost:8080
|
|
32
|
+
* supports: [where, limit, sort]
|
|
33
|
+
*
|
|
34
|
+
* And rewrite collection refs to URLs, e.g.:
|
|
35
|
+
* fetch: { url: /api/members, schema: members }
|
|
36
|
+
*/
|
|
37
|
+
|
|
38
|
+
import { createServer } from 'node:http'
|
|
39
|
+
import { readFile, readdir, stat } from 'node:fs/promises'
|
|
40
|
+
import { existsSync } from 'node:fs'
|
|
41
|
+
import { resolve, join, basename, extname } from 'node:path'
|
|
42
|
+
import { parseArgs } from 'node:util'
|
|
43
|
+
import yaml from 'js-yaml'
|
|
44
|
+
import { matchWhere } from '@uniweb/core'
|
|
45
|
+
|
|
46
|
+
const { values } = parseArgs({
|
|
47
|
+
options: {
|
|
48
|
+
collections: { type: 'string', short: 'c' },
|
|
49
|
+
port: { type: 'string', short: 'p', default: '8080' },
|
|
50
|
+
},
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
if (!values.collections) {
|
|
54
|
+
console.error('Usage: dev-backend.js --collections <path> [--port N]')
|
|
55
|
+
process.exit(1)
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const COLLECTIONS_DIR = resolve(values.collections)
|
|
59
|
+
const PORT = Number(values.port)
|
|
60
|
+
|
|
61
|
+
if (!existsSync(COLLECTIONS_DIR)) {
|
|
62
|
+
console.error(`Collections directory not found: ${COLLECTIONS_DIR}`)
|
|
63
|
+
process.exit(1)
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// ─── Load collections from disk ─────────────────────────────────────────────
|
|
67
|
+
|
|
68
|
+
async function loadCollection(dir) {
|
|
69
|
+
const files = await readdir(dir)
|
|
70
|
+
const items = []
|
|
71
|
+
for (const file of files) {
|
|
72
|
+
const ext = extname(file).toLowerCase()
|
|
73
|
+
if (!['.yml', '.yaml', '.json'].includes(ext)) continue
|
|
74
|
+
const filepath = join(dir, file)
|
|
75
|
+
const content = await readFile(filepath, 'utf8')
|
|
76
|
+
let data
|
|
77
|
+
try {
|
|
78
|
+
data = ext === '.json' ? JSON.parse(content) : yaml.load(content)
|
|
79
|
+
} catch (err) {
|
|
80
|
+
console.warn(`[dev-backend] Failed to parse ${filepath}: ${err.message}`)
|
|
81
|
+
continue
|
|
82
|
+
}
|
|
83
|
+
if (data == null) continue
|
|
84
|
+
const slug = basename(file, ext)
|
|
85
|
+
if (Array.isArray(data)) {
|
|
86
|
+
// Array-form file: each element is a record.
|
|
87
|
+
for (const record of data) {
|
|
88
|
+
if (record && typeof record === 'object') items.push(record)
|
|
89
|
+
}
|
|
90
|
+
} else if (typeof data === 'object') {
|
|
91
|
+
items.push({ slug, ...data })
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
return items
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
async function loadAllCollections() {
|
|
98
|
+
const entries = await readdir(COLLECTIONS_DIR)
|
|
99
|
+
const collections = {}
|
|
100
|
+
for (const name of entries) {
|
|
101
|
+
const fullPath = join(COLLECTIONS_DIR, name)
|
|
102
|
+
const s = await stat(fullPath)
|
|
103
|
+
if (!s.isDirectory()) continue
|
|
104
|
+
collections[name] = await loadCollection(fullPath)
|
|
105
|
+
console.log(`[dev-backend] Loaded ${collections[name].length} items from "${name}"`)
|
|
106
|
+
}
|
|
107
|
+
return collections
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// ─── Operator handling (mirrors default-fetcher pushdown wire format) ───────
|
|
111
|
+
|
|
112
|
+
function applyOperators(items, operators) {
|
|
113
|
+
let result = items
|
|
114
|
+
if (operators.where) {
|
|
115
|
+
result = matchWhere(operators.where, result)
|
|
116
|
+
}
|
|
117
|
+
if (operators.sort) {
|
|
118
|
+
result = applySort(result, operators.sort)
|
|
119
|
+
}
|
|
120
|
+
if (typeof operators.limit === 'number' && operators.limit > 0) {
|
|
121
|
+
result = result.slice(0, operators.limit)
|
|
122
|
+
}
|
|
123
|
+
return result
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function applySort(items, sortExpr) {
|
|
127
|
+
const sorts = String(sortExpr).split(',').map((s) => {
|
|
128
|
+
const [field, dir = 'asc'] = s.trim().split(/\s+/)
|
|
129
|
+
return { field, desc: dir.toLowerCase() === 'desc' }
|
|
130
|
+
})
|
|
131
|
+
return [...items].sort((a, b) => {
|
|
132
|
+
for (const { field, desc } of sorts) {
|
|
133
|
+
const av = a?.[field] ?? ''
|
|
134
|
+
const bv = b?.[field] ?? ''
|
|
135
|
+
if (av < bv) return desc ? 1 : -1
|
|
136
|
+
if (av > bv) return desc ? -1 : 1
|
|
137
|
+
}
|
|
138
|
+
return 0
|
|
139
|
+
})
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function parseOperatorsFromQuery(searchParams) {
|
|
143
|
+
const out = {}
|
|
144
|
+
if (searchParams.has('_where')) {
|
|
145
|
+
try {
|
|
146
|
+
out.where = JSON.parse(searchParams.get('_where'))
|
|
147
|
+
} catch (err) {
|
|
148
|
+
throw new Error(`Invalid _where JSON: ${err.message}`)
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
if (searchParams.has('_limit')) {
|
|
152
|
+
out.limit = Number(searchParams.get('_limit'))
|
|
153
|
+
}
|
|
154
|
+
if (searchParams.has('_sort')) {
|
|
155
|
+
out.sort = searchParams.get('_sort')
|
|
156
|
+
}
|
|
157
|
+
return out
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
async function readJsonBody(req) {
|
|
161
|
+
return new Promise((resolve, reject) => {
|
|
162
|
+
let body = ''
|
|
163
|
+
req.on('data', (chunk) => { body += chunk })
|
|
164
|
+
req.on('end', () => {
|
|
165
|
+
if (!body) return resolve({})
|
|
166
|
+
try { resolve(JSON.parse(body)) }
|
|
167
|
+
catch (err) { reject(new Error(`Invalid JSON body: ${err.message}`)) }
|
|
168
|
+
})
|
|
169
|
+
req.on('error', reject)
|
|
170
|
+
})
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// ─── HTTP server ────────────────────────────────────────────────────────────
|
|
174
|
+
|
|
175
|
+
function send(res, status, body) {
|
|
176
|
+
res.writeHead(status, {
|
|
177
|
+
'Content-Type': 'application/json',
|
|
178
|
+
'Access-Control-Allow-Origin': '*',
|
|
179
|
+
'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
|
|
180
|
+
'Access-Control-Allow-Headers': 'Content-Type',
|
|
181
|
+
})
|
|
182
|
+
res.end(typeof body === 'string' ? body : JSON.stringify(body))
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
async function handleRequest(req, res, collections) {
|
|
186
|
+
if (req.method === 'OPTIONS') return send(res, 204, '')
|
|
187
|
+
|
|
188
|
+
const url = new URL(req.url, `http://${req.headers.host}`)
|
|
189
|
+
const match = url.pathname.match(/^\/api\/([^/]+)(?:\/([^/]+))?$/)
|
|
190
|
+
if (!match) return send(res, 404, { error: 'Not found' })
|
|
191
|
+
|
|
192
|
+
const [, collectionName, slug] = match
|
|
193
|
+
const items = collections[collectionName]
|
|
194
|
+
if (!items) return send(res, 404, { error: `Unknown collection: ${collectionName}` })
|
|
195
|
+
|
|
196
|
+
// Single record by slug.
|
|
197
|
+
if (slug) {
|
|
198
|
+
const item = items.find((r) => r?.slug === slug)
|
|
199
|
+
if (!item) return send(res, 404, { error: `No record with slug "${slug}"` })
|
|
200
|
+
return send(res, 200, item)
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// Collection — apply operators from query string (GET) or body (POST).
|
|
204
|
+
let operators
|
|
205
|
+
try {
|
|
206
|
+
operators = req.method === 'POST'
|
|
207
|
+
? await readJsonBody(req)
|
|
208
|
+
: parseOperatorsFromQuery(url.searchParams)
|
|
209
|
+
} catch (err) {
|
|
210
|
+
return send(res, 400, { error: err.message })
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
let result
|
|
214
|
+
try {
|
|
215
|
+
result = applyOperators(items, operators)
|
|
216
|
+
} catch (err) {
|
|
217
|
+
return send(res, 400, { error: `Operator evaluation failed: ${err.message}` })
|
|
218
|
+
}
|
|
219
|
+
return send(res, 200, result)
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// ─── Boot ───────────────────────────────────────────────────────────────────
|
|
223
|
+
|
|
224
|
+
const collections = await loadAllCollections()
|
|
225
|
+
const knownCollections = Object.keys(collections)
|
|
226
|
+
if (knownCollections.length === 0) {
|
|
227
|
+
console.warn('[dev-backend] No collections found.')
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
const server = createServer((req, res) => {
|
|
231
|
+
handleRequest(req, res, collections).catch((err) => {
|
|
232
|
+
console.error('[dev-backend] Request handler threw:', err)
|
|
233
|
+
send(res, 500, { error: 'Internal server error' })
|
|
234
|
+
})
|
|
235
|
+
})
|
|
236
|
+
|
|
237
|
+
server.listen(PORT, () => {
|
|
238
|
+
console.log(`[dev-backend] Listening on http://localhost:${PORT}`)
|
|
239
|
+
console.log(`[dev-backend] Collections: ${knownCollections.join(', ') || '(none)'}`)
|
|
240
|
+
console.log('[dev-backend] Endpoints:')
|
|
241
|
+
for (const name of knownCollections) {
|
|
242
|
+
console.log(` GET /api/${name} — full collection`)
|
|
243
|
+
console.log(` GET /api/${name}?_where=<JSON> — filtered`)
|
|
244
|
+
console.log(` GET /api/${name}/{slug} — single record`)
|
|
245
|
+
console.log(` POST /api/${name} body: { where } — operators in body`)
|
|
246
|
+
}
|
|
247
|
+
})
|
package/src/prerender.js
CHANGED
|
@@ -11,6 +11,7 @@ import { readFile, writeFile, mkdir } from 'node:fs/promises'
|
|
|
11
11
|
import { existsSync, readdirSync, statSync } from 'node:fs'
|
|
12
12
|
import { join, dirname, resolve } from 'node:path'
|
|
13
13
|
import { pathToFileURL } from 'node:url'
|
|
14
|
+
import { deriveCacheKey } from '@uniweb/core'
|
|
14
15
|
import { executeFetch, mergeDataIntoContent, singularize } from './site/data-fetcher.js'
|
|
15
16
|
import { shouldSplitContent } from './site/split-content.js'
|
|
16
17
|
|
|
@@ -509,27 +510,19 @@ export async function prerenderSite(siteDir, options = {}) {
|
|
|
509
510
|
const shellPath = existsSync(htmlPath) ? htmlPath : join(distDir, 'index.html')
|
|
510
511
|
const htmlShell = await readFile(shellPath, 'utf8')
|
|
511
512
|
|
|
512
|
-
//
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
if (
|
|
517
|
-
|
|
518
|
-
uniweb.activeWebsite.dataStore.set(entry.config, entry.data)
|
|
519
|
-
}
|
|
520
|
-
}
|
|
521
|
-
|
|
522
|
-
// Build-specific: load extensions (secondary foundations via URL)
|
|
523
|
-
const extensions = siteContent.config?.extensions
|
|
524
|
-
if (extensions?.length) {
|
|
525
|
-
onProgress(`Loading ${extensions.length} extension(s)...`)
|
|
513
|
+
// Build-specific: load extensions (secondary foundations via URL) BEFORE
|
|
514
|
+
// initPrerender so the Website's FetcherDispatcher sees their routes.
|
|
515
|
+
const extensionSources = siteContent.config?.extensions
|
|
516
|
+
const loadedExtensions = []
|
|
517
|
+
if (extensionSources?.length) {
|
|
518
|
+
onProgress(`Loading ${extensionSources.length} extension(s)...`)
|
|
526
519
|
const projectRoot = join(siteDir, '..')
|
|
527
|
-
for (const ext of
|
|
520
|
+
for (const ext of extensionSources) {
|
|
528
521
|
try {
|
|
529
522
|
const url = typeof ext === 'string' ? ext : ext.url
|
|
530
523
|
const extPath = resolveExtensionPath(url, distDir, projectRoot)
|
|
531
524
|
const extModule = await import(pathToFileURL(extPath).href)
|
|
532
|
-
|
|
525
|
+
loadedExtensions.push(extModule)
|
|
533
526
|
onProgress(` Extension loaded: ${url}`)
|
|
534
527
|
} catch (err) {
|
|
535
528
|
onProgress(` Warning: Extension failed to load: ${ext} (${err.message})`)
|
|
@@ -537,6 +530,17 @@ export async function prerenderSite(siteDir, options = {}) {
|
|
|
537
530
|
}
|
|
538
531
|
}
|
|
539
532
|
|
|
533
|
+
// Initialize the Uniweb runtime using the shared SSR module
|
|
534
|
+
const uniweb = initPrerender(siteContent, foundation, loadedExtensions, { onProgress })
|
|
535
|
+
|
|
536
|
+
// Build-specific: pre-populate DataStore so EntityStore can resolve data during prerender.
|
|
537
|
+
// Use the framework's default cache key so runtime probes hit the same entries.
|
|
538
|
+
if (fetchedData.length > 0 && uniweb.activeWebsite?.dataStore) {
|
|
539
|
+
for (const entry of fetchedData) {
|
|
540
|
+
uniweb.activeWebsite.dataStore.set(deriveCacheKey(entry.config), { data: entry.data })
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
|
|
540
544
|
// Pre-fetch icons for SSR embedding
|
|
541
545
|
await prefetchIcons(siteContent, uniweb, onProgress)
|
|
542
546
|
|
package/src/runtime-schema.js
CHANGED
|
@@ -10,7 +10,18 @@
|
|
|
10
10
|
* - defaults: param default values
|
|
11
11
|
* - context: static capabilities for cross-block coordination
|
|
12
12
|
* - initialState: initial values for mutable block state
|
|
13
|
-
* - inheritData:
|
|
13
|
+
* - inheritData: internal flag for cascaded data delivery
|
|
14
|
+
* true → deliver all data available at ancestor levels (default)
|
|
15
|
+
* false → deliver nothing (component opted out with `data: false`)
|
|
16
|
+
*
|
|
17
|
+
* Data delivery is default-on: a component without any `data:` field
|
|
18
|
+
* receives all data cascaded from its ancestor levels (block → page →
|
|
19
|
+
* parent page → site) via `content.data.{schema}`. A component that
|
|
20
|
+
* genuinely cannot tolerate ambient data declares `data: false`.
|
|
21
|
+
*
|
|
22
|
+
* `data: { entity: 'articles' }` is a **declaration**, not a gate. It
|
|
23
|
+
* tells the editor and prepare-props what shape the component expects,
|
|
24
|
+
* but does not restrict delivery.
|
|
14
25
|
*
|
|
15
26
|
* Full metadata (titles, descriptions, hints, etc.) stays in schema.json
|
|
16
27
|
* for the visual editor.
|
|
@@ -202,39 +213,64 @@ export function extractRuntimeSchema(fullMeta) {
|
|
|
202
213
|
}
|
|
203
214
|
|
|
204
215
|
// Data binding (CMS entities)
|
|
205
|
-
//
|
|
206
|
-
//
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
216
|
+
//
|
|
217
|
+
// Supported forms:
|
|
218
|
+
// data: false → explicit opt-out
|
|
219
|
+
// data: { entity: 'person:6' } → declaration + shape hints
|
|
220
|
+
// data: { schemas: {...} } → validation / default shapes
|
|
221
|
+
// data: 'person:6' → legacy string form (declaration only)
|
|
222
|
+
//
|
|
223
|
+
// Deprecated forms (accepted with dev-mode warning, removed in next release):
|
|
224
|
+
// data: { inherit: true | false | [...] } — component-side gating is gone;
|
|
225
|
+
// delivery is default-on
|
|
226
|
+
// data: { detail, limit } — moved to block-level fetch
|
|
227
|
+
if (fullMeta.data === false) {
|
|
228
|
+
// Explicit opt-out — deliver nothing to this component.
|
|
229
|
+
runtime.inheritData = false
|
|
230
|
+
} else if (typeof fullMeta.data === 'string') {
|
|
231
|
+
// Legacy string form: data: 'person:6'
|
|
232
|
+
const parsed = parseDataString(fullMeta.data)
|
|
233
|
+
if (parsed) {
|
|
234
|
+
runtime.data = parsed
|
|
235
|
+
}
|
|
236
|
+
} else if (fullMeta.data && typeof fullMeta.data === 'object') {
|
|
237
|
+
if (fullMeta.data.entity) {
|
|
238
|
+
const parsed = parseDataString(fullMeta.data.entity)
|
|
211
239
|
if (parsed) {
|
|
212
240
|
runtime.data = parsed
|
|
213
241
|
}
|
|
214
|
-
}
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
runtime.data = parsed
|
|
220
|
-
}
|
|
221
|
-
}
|
|
222
|
-
if (fullMeta.data.schemas) {
|
|
223
|
-
const schemas = extractSchemas(fullMeta.data.schemas)
|
|
224
|
-
if (schemas) {
|
|
225
|
-
runtime.schemas = schemas
|
|
226
|
-
}
|
|
242
|
+
}
|
|
243
|
+
if (fullMeta.data.schemas) {
|
|
244
|
+
const schemas = extractSchemas(fullMeta.data.schemas)
|
|
245
|
+
if (schemas) {
|
|
246
|
+
runtime.schemas = schemas
|
|
227
247
|
}
|
|
228
|
-
|
|
229
|
-
|
|
248
|
+
}
|
|
249
|
+
// Deprecated: data.inherit is a no-op under default-on delivery. The
|
|
250
|
+
// only behavior we still honor is `inherit: false` → treat as opt-out,
|
|
251
|
+
// so existing foundations that wrote "don't deliver" still don't.
|
|
252
|
+
// Array and `true` forms are ignored — delivery happens regardless.
|
|
253
|
+
if (fullMeta.data.inherit === false) {
|
|
254
|
+
if (typeof process !== 'undefined' && process.env?.NODE_ENV !== 'production') {
|
|
255
|
+
console.warn(
|
|
256
|
+
'[uniweb] `data: { inherit: false }` is deprecated; use `data: false` instead.'
|
|
257
|
+
)
|
|
230
258
|
}
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
if (
|
|
234
|
-
|
|
259
|
+
runtime.inheritData = false
|
|
260
|
+
} else if (fullMeta.data.inherit !== undefined) {
|
|
261
|
+
if (typeof process !== 'undefined' && process.env?.NODE_ENV !== 'production') {
|
|
262
|
+
console.warn(
|
|
263
|
+
'[uniweb] `data: { inherit: ... }` is deprecated; delivery is default-on. Remove the `inherit` field.'
|
|
264
|
+
)
|
|
235
265
|
}
|
|
236
|
-
|
|
237
|
-
|
|
266
|
+
}
|
|
267
|
+
// Deprecated: detail/limit on the component side. Block-level
|
|
268
|
+
// `fetch: { inherit: true, detail, limit }` is where these belong now.
|
|
269
|
+
if (fullMeta.data.detail !== undefined || fullMeta.data.limit !== undefined) {
|
|
270
|
+
if (typeof process !== 'undefined' && process.env?.NODE_ENV !== 'production') {
|
|
271
|
+
console.warn(
|
|
272
|
+
'[uniweb] `data: { detail, limit }` on the component side is deprecated; set these on a block-level `fetch: { inherit: true, ... }` instead.'
|
|
273
|
+
)
|
|
238
274
|
}
|
|
239
275
|
}
|
|
240
276
|
}
|
|
@@ -267,18 +303,17 @@ export function extractRuntimeSchema(fullMeta) {
|
|
|
267
303
|
}
|
|
268
304
|
}
|
|
269
305
|
|
|
270
|
-
//
|
|
271
|
-
//
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
runtime.inheritData = fullMeta.inheritData
|
|
306
|
+
// Top-level inheritData (legacy, pre-`data.*` format) — honored only as opt-out.
|
|
307
|
+
// Truthy values and arrays are ignored; delivery is default-on.
|
|
308
|
+
if (fullMeta.inheritData === false && runtime.inheritData === undefined) {
|
|
309
|
+
runtime.inheritData = false
|
|
275
310
|
}
|
|
276
311
|
|
|
277
|
-
//
|
|
278
|
-
//
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
312
|
+
// Data delivery is default-on. `runtime.inheritData` stays undefined unless
|
|
313
|
+
// the component explicitly opts out (runtime.inheritData === false), in
|
|
314
|
+
// which case EntityStore delivers nothing. The declaration `data.entity`
|
|
315
|
+
// no longer implies a gate — it's a hint consumed by prepare-props and
|
|
316
|
+
// the editor.
|
|
282
317
|
|
|
283
318
|
return Object.keys(runtime).length > 0 ? runtime : null
|
|
284
319
|
}
|
|
@@ -79,7 +79,8 @@ function parseCollectionConfig(name, config) {
|
|
|
79
79
|
sort: null,
|
|
80
80
|
filter: null,
|
|
81
81
|
limit: 0,
|
|
82
|
-
excerpt: { maxLength: 160 }
|
|
82
|
+
excerpt: { maxLength: 160 },
|
|
83
|
+
deferred: null,
|
|
83
84
|
}
|
|
84
85
|
}
|
|
85
86
|
|
|
@@ -93,7 +94,24 @@ function parseCollectionConfig(name, config) {
|
|
|
93
94
|
excerpt: {
|
|
94
95
|
maxLength: config.excerpt?.maxLength || 160,
|
|
95
96
|
field: config.excerpt?.field || null
|
|
96
|
-
}
|
|
97
|
+
},
|
|
98
|
+
// `deferred:` lists fields that are heavy (article body, full nested
|
|
99
|
+
// arrays). Those fields are stripped from the cascade payload that
|
|
100
|
+
// ships with `data: <name>` declarations, and per-record full files
|
|
101
|
+
// are emitted at public/data/<name>/<slug>.json. Components that
|
|
102
|
+
// need the full record fetch the per-record file on demand, either
|
|
103
|
+
// automatically on dynamic-route pages (entity-store routes the
|
|
104
|
+
// singular detail there) or via the kit's useEntityDetail hook.
|
|
105
|
+
deferred: Array.isArray(config.deferred) ? config.deferred.slice() : null,
|
|
106
|
+
// `queryable:` declares the queryable surface — which fields a
|
|
107
|
+
// foundation can offer for filtering UI, with their type and
|
|
108
|
+
// type-specific metadata (enum options, range bounds). Foundations
|
|
109
|
+
// read this metadata via the kit's useCollectionQueryable hook to
|
|
110
|
+
// render filter controls and compose where-objects from user
|
|
111
|
+
// interactions. The framework doesn't validate the shape here —
|
|
112
|
+
// foundations get whatever the author wrote; documentation defines
|
|
113
|
+
// the conventional types (enum/boolean/range/text).
|
|
114
|
+
queryable: (config.queryable && typeof config.queryable === 'object') ? config.queryable : null,
|
|
97
115
|
}
|
|
98
116
|
}
|
|
99
117
|
|
|
@@ -578,7 +596,7 @@ export async function processCollections(siteDir, collectionsConfig, collections
|
|
|
578
596
|
* })
|
|
579
597
|
* // Creates public/data/articles.json
|
|
580
598
|
*/
|
|
581
|
-
export async function writeCollectionFiles(siteDir, collections) {
|
|
599
|
+
export async function writeCollectionFiles(siteDir, collections, collectionsConfig = null) {
|
|
582
600
|
if (!collections || Object.keys(collections).length === 0) {
|
|
583
601
|
return
|
|
584
602
|
}
|
|
@@ -587,9 +605,44 @@ export async function writeCollectionFiles(siteDir, collections) {
|
|
|
587
605
|
await mkdir(dataDir, { recursive: true })
|
|
588
606
|
|
|
589
607
|
for (const [name, items] of Object.entries(collections)) {
|
|
590
|
-
const
|
|
591
|
-
|
|
592
|
-
|
|
608
|
+
const rawConfig = collectionsConfig?.[name]
|
|
609
|
+
const parsed = rawConfig ? parseCollectionConfig(name, rawConfig) : null
|
|
610
|
+
const deferred = parsed?.deferred
|
|
611
|
+
|
|
612
|
+
if (deferred && deferred.length > 0) {
|
|
613
|
+
// `deferred:` is set — emit two payloads:
|
|
614
|
+
// 1. The cascade JSON at /data/<name>.json with deferred fields stripped.
|
|
615
|
+
// This is what `data: <name>` declarations deliver everywhere.
|
|
616
|
+
// 2. Per-record full files at /data/<name>/<slug>.json with every field.
|
|
617
|
+
// Dynamic-route singular fetches and useEntityDetail hooks read these.
|
|
618
|
+
const recordsDir = join(dataDir, name)
|
|
619
|
+
await mkdir(recordsDir, { recursive: true })
|
|
620
|
+
|
|
621
|
+
let perRecordCount = 0
|
|
622
|
+
for (const item of items) {
|
|
623
|
+
if (!item || typeof item !== 'object' || !item.slug) continue
|
|
624
|
+
const recordPath = join(recordsDir, `${item.slug}.json`)
|
|
625
|
+
await writeFile(recordPath, JSON.stringify(item, null, 2))
|
|
626
|
+
perRecordCount++
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
const stripped = items.map((item) => {
|
|
630
|
+
if (!item || typeof item !== 'object') return item
|
|
631
|
+
const out = { ...item }
|
|
632
|
+
for (const field of deferred) delete out[field]
|
|
633
|
+
return out
|
|
634
|
+
})
|
|
635
|
+
const cascadePath = join(dataDir, `${name}.json`)
|
|
636
|
+
await writeFile(cascadePath, JSON.stringify(stripped, null, 2))
|
|
637
|
+
console.log(
|
|
638
|
+
`[collection-processor] Generated ${cascadePath} (${items.length} items, ` +
|
|
639
|
+
`deferred: [${deferred.join(', ')}]) + ${perRecordCount} per-record files`
|
|
640
|
+
)
|
|
641
|
+
} else {
|
|
642
|
+
const filepath = join(dataDir, `${name}.json`)
|
|
643
|
+
await writeFile(filepath, JSON.stringify(items, null, 2))
|
|
644
|
+
console.log(`[collection-processor] Generated ${filepath} (${items.length} items)`)
|
|
645
|
+
}
|
|
593
646
|
}
|
|
594
647
|
}
|
|
595
648
|
|
package/src/site/data-fetcher.js
CHANGED
|
@@ -20,6 +20,7 @@ import { readFile } from 'node:fs/promises'
|
|
|
20
20
|
import { join } from 'node:path'
|
|
21
21
|
import { existsSync } from 'node:fs'
|
|
22
22
|
import yaml from 'js-yaml'
|
|
23
|
+
import { matchWhere } from '@uniweb/core'
|
|
23
24
|
|
|
24
25
|
/**
|
|
25
26
|
* Infer schema name from path or URL
|
|
@@ -157,19 +158,52 @@ export function applySort(items, sortExpr) {
|
|
|
157
158
|
}
|
|
158
159
|
|
|
159
160
|
/**
|
|
160
|
-
* Apply
|
|
161
|
+
* Apply a where-object predicate to an array of items.
|
|
162
|
+
*
|
|
163
|
+
* The where-object is the new query language (see @uniweb/core's
|
|
164
|
+
* matchWhere and the architecture doc at
|
|
165
|
+
* kb/framework/architecture/data-fetching.md). Structured JSON
|
|
166
|
+
* predicate; the runtime evaluator walks the object against each
|
|
167
|
+
* record. Same shape ships to backends that declare `supports: [where]`.
|
|
168
|
+
*
|
|
169
|
+
* @param {Array} items - Items to filter
|
|
170
|
+
* @param {object} where - Where-object predicate
|
|
171
|
+
* @returns {Array} Filtered items in source order
|
|
172
|
+
*/
|
|
173
|
+
export function applyWhere(items, where) {
|
|
174
|
+
if (!where || !Array.isArray(items)) return items
|
|
175
|
+
return matchWhere(where, items)
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Apply post-processing to fetched data (where, filter, sort, limit)
|
|
180
|
+
*
|
|
181
|
+
* Order of operations:
|
|
182
|
+
* 1. where (where-object predicate, new) — narrows the record set
|
|
183
|
+
* 2. filter (legacy DSL string) — narrows further if both are set; deprecated
|
|
184
|
+
* 3. sort
|
|
185
|
+
* 4. limit
|
|
186
|
+
*
|
|
187
|
+
* `where:` and `filter:` may both appear during the deprecation window
|
|
188
|
+
* but in practice authors should pick one. Using `filter:` emits a dev
|
|
189
|
+
* warning at parse time (see parseFetchConfig).
|
|
161
190
|
*
|
|
162
191
|
* @param {any} data - Fetched data
|
|
163
|
-
* @param {object} config - Fetch config with optional filter, sort, limit
|
|
192
|
+
* @param {object} config - Fetch config with optional where, filter, sort, limit
|
|
164
193
|
* @returns {any} Processed data
|
|
165
194
|
*/
|
|
166
195
|
export function applyPostProcessing(data, config) {
|
|
167
196
|
if (!data || !Array.isArray(data)) return data
|
|
168
|
-
if (!config.filter && !config.sort && !config.limit) return data
|
|
197
|
+
if (!config.where && !config.filter && !config.sort && !config.limit) return data
|
|
169
198
|
|
|
170
199
|
let result = data
|
|
171
200
|
|
|
172
|
-
// Apply
|
|
201
|
+
// Apply where-object predicate first (new path)
|
|
202
|
+
if (config.where) {
|
|
203
|
+
result = applyWhere(result, config.where)
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// Apply legacy filter expression (deprecated)
|
|
173
207
|
if (config.filter) {
|
|
174
208
|
result = applyFilter(result, config.filter)
|
|
175
209
|
}
|
|
@@ -225,20 +259,37 @@ export function parseFetchConfig(fetch) {
|
|
|
225
259
|
// Full config object
|
|
226
260
|
if (typeof fetch !== 'object') return null
|
|
227
261
|
|
|
228
|
-
//
|
|
229
|
-
// No URL — merges with the parent fetch config at runtime; only carries
|
|
230
|
-
|
|
262
|
+
// Refine config: { refine: true, detail: false, limit: 3 }
|
|
263
|
+
// No URL — merges with the parent fetch config at runtime; only carries
|
|
264
|
+
// override props. The legacy spelling `inherit: true` is accepted for one
|
|
265
|
+
// release with a warning, then removed.
|
|
266
|
+
//
|
|
267
|
+
// Note on build-vs-runtime scope: this parser passes `sort` and `filter`
|
|
268
|
+
// through on refine configs, but the runtime EntityStore only applies
|
|
269
|
+
// `detail`, `limit`, and `order` overrides. `sort` / `filter` on a refine
|
|
270
|
+
// block are currently accepted by the parser but not honored at runtime.
|
|
271
|
+
// Preserved as-is in this rename commit; revisit separately if needed.
|
|
272
|
+
if (fetch.refine === true || fetch.inherit === true) {
|
|
273
|
+
if (fetch.inherit === true && fetch.refine !== true) {
|
|
274
|
+
console.warn(
|
|
275
|
+
"[uniweb] 'fetch: { inherit: true }' is deprecated; rename to 'fetch: { refine: true }'. " +
|
|
276
|
+
'Accepted for one release; will be removed in the next minor.'
|
|
277
|
+
)
|
|
278
|
+
}
|
|
279
|
+
if (fetch.filter !== undefined) warnFilterDeprecated()
|
|
231
280
|
return {
|
|
232
|
-
|
|
281
|
+
refine: true,
|
|
233
282
|
...(fetch.detail !== undefined ? { detail: fetch.detail } : {}),
|
|
234
283
|
...(fetch.limit !== undefined ? { limit: fetch.limit } : {}),
|
|
235
284
|
...(fetch.sort !== undefined ? { sort: fetch.sort } : {}),
|
|
285
|
+
...(fetch.where !== undefined ? { where: fetch.where } : {}),
|
|
236
286
|
...(fetch.filter !== undefined ? { filter: fetch.filter } : {}),
|
|
237
287
|
}
|
|
238
288
|
}
|
|
239
289
|
|
|
240
290
|
// Collection reference: { collection: 'articles', limit: 3 }
|
|
241
291
|
if (fetch.collection) {
|
|
292
|
+
if (fetch.filter !== undefined) warnFilterDeprecated()
|
|
242
293
|
return {
|
|
243
294
|
path: `/data/${fetch.collection}.json`,
|
|
244
295
|
url: undefined,
|
|
@@ -246,9 +297,11 @@ export function parseFetchConfig(fetch) {
|
|
|
246
297
|
prerender: fetch.prerender ?? true,
|
|
247
298
|
merge: fetch.merge ?? false,
|
|
248
299
|
transform: fetch.transform,
|
|
249
|
-
//
|
|
300
|
+
// Query operators
|
|
301
|
+
where: fetch.where,
|
|
250
302
|
limit: fetch.limit,
|
|
251
303
|
sort: fetch.sort,
|
|
304
|
+
// Legacy post-processing (deprecated, see warning above)
|
|
252
305
|
filter: fetch.filter,
|
|
253
306
|
}
|
|
254
307
|
}
|
|
@@ -261,15 +314,19 @@ export function parseFetchConfig(fetch) {
|
|
|
261
314
|
merge = false,
|
|
262
315
|
transform,
|
|
263
316
|
detail,
|
|
264
|
-
//
|
|
317
|
+
// Query operators
|
|
318
|
+
where,
|
|
265
319
|
limit,
|
|
266
320
|
sort,
|
|
321
|
+
// Legacy post-processing (deprecated)
|
|
267
322
|
filter,
|
|
268
323
|
} = fetch
|
|
269
324
|
|
|
270
325
|
// Must have either path or url
|
|
271
326
|
if (!path && !url) return null
|
|
272
327
|
|
|
328
|
+
if (filter !== undefined) warnFilterDeprecated()
|
|
329
|
+
|
|
273
330
|
return {
|
|
274
331
|
path,
|
|
275
332
|
url,
|
|
@@ -278,13 +335,27 @@ export function parseFetchConfig(fetch) {
|
|
|
278
335
|
merge,
|
|
279
336
|
transform,
|
|
280
337
|
detail,
|
|
281
|
-
//
|
|
338
|
+
// Query operators
|
|
339
|
+
where,
|
|
282
340
|
limit,
|
|
283
341
|
sort,
|
|
342
|
+
// Legacy post-processing (deprecated)
|
|
284
343
|
filter,
|
|
285
344
|
}
|
|
286
345
|
}
|
|
287
346
|
|
|
347
|
+
let filterDeprecationWarned = false
|
|
348
|
+
function warnFilterDeprecated() {
|
|
349
|
+
if (filterDeprecationWarned) return
|
|
350
|
+
filterDeprecationWarned = true
|
|
351
|
+
console.warn(
|
|
352
|
+
"[uniweb] 'fetch: { filter: ... }' (DSL string) is deprecated; use 'where: { ... }' " +
|
|
353
|
+
'with a where-object. Example: where: { tags: \"featured\" } instead of ' +
|
|
354
|
+
"filter: 'tags contains featured'. " +
|
|
355
|
+
'Accepted for one release; will be removed in the next minor.'
|
|
356
|
+
)
|
|
357
|
+
}
|
|
358
|
+
|
|
288
359
|
/**
|
|
289
360
|
* Execute a fetch operation
|
|
290
361
|
*
|
package/src/site/plugin.js
CHANGED
|
@@ -556,7 +556,7 @@ export function siteContentPlugin(options = {}) {
|
|
|
556
556
|
if (collectionsConfig) {
|
|
557
557
|
console.log('[site-content] Processing content collections...')
|
|
558
558
|
const collections = await processCollections(resolvedSitePath, collectionsConfig, resolvedCollectionsBase, basePath)
|
|
559
|
-
await writeCollectionFiles(resolvedSitePath, collections)
|
|
559
|
+
await writeCollectionFiles(resolvedSitePath, collections, collectionsConfig)
|
|
560
560
|
}
|
|
561
561
|
} catch (err) {
|
|
562
562
|
console.warn('[site-content] Early collection processing failed:', err.message)
|
|
@@ -593,7 +593,7 @@ export function siteContentPlugin(options = {}) {
|
|
|
593
593
|
if (isProduction && siteContent.config?.collections) {
|
|
594
594
|
console.log('[site-content] Processing content collections...')
|
|
595
595
|
const collections = await processCollections(resolvedSitePath, siteContent.config.collections, resolvedCollectionsBase, basePath)
|
|
596
|
-
await writeCollectionFiles(resolvedSitePath, collections)
|
|
596
|
+
await writeCollectionFiles(resolvedSitePath, collections, siteContent.config.collections)
|
|
597
597
|
}
|
|
598
598
|
|
|
599
599
|
// Execute data fetches in dev mode
|
|
@@ -656,7 +656,7 @@ export function siteContentPlugin(options = {}) {
|
|
|
656
656
|
const collections = collectionsConfig || siteContent?.config?.collections
|
|
657
657
|
if (collections) {
|
|
658
658
|
const processed = await processCollections(resolvedSitePath, collections, resolvedCollectionsBase, basePath)
|
|
659
|
-
await writeCollectionFiles(resolvedSitePath, processed)
|
|
659
|
+
await writeCollectionFiles(resolvedSitePath, processed, collections)
|
|
660
660
|
}
|
|
661
661
|
// Send full reload to client
|
|
662
662
|
server.ws.send({ type: 'full-reload' })
|