@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@uniweb/build",
3
- "version": "0.9.4",
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.6.1"
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
- // Initialize the Uniweb runtime using the shared SSR module
513
- const uniweb = initPrerender(siteContent, foundation, { onProgress })
514
-
515
- // Build-specific: pre-populate DataStore so EntityStore can resolve data during prerender
516
- if (fetchedData.length > 0 && uniweb.activeWebsite?.dataStore) {
517
- for (const entry of fetchedData) {
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 extensions) {
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
- uniweb.registerExtension(extModule)
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
 
@@ -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: boolean or array for cascaded data from page/site fetches
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
- // Supports both old format (data: 'person:6') and new consolidated format
206
- // (data: { entity: 'person:6', schemas: {...}, inherit: [...] })
207
- if (fullMeta.data) {
208
- if (typeof fullMeta.data === 'string') {
209
- // Old format: data: 'person:6'
210
- const parsed = parseDataString(fullMeta.data)
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
- } else if (typeof fullMeta.data === 'object') {
215
- // New format: data: { entity, schemas, inherit }
216
- if (fullMeta.data.entity) {
217
- const parsed = parseDataString(fullMeta.data.entity)
218
- if (parsed) {
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
- if (fullMeta.data.inherit !== undefined) {
229
- runtime.inheritData = fullMeta.data.inherit
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
- // detail: false → opt out of single-item resolution on dynamic pages,
232
- // returning the collection instead (minus the active item)
233
- if (fullMeta.data.detail !== undefined) {
234
- runtime.inheritDetail = fullMeta.data.detail
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
- if (fullMeta.data.limit !== undefined) {
237
- runtime.inheritLimit = fullMeta.data.limit
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
- // Data inheritance - component receives cascaded data from page/site level fetches
271
- // Can be: true (inherit all), false (inherit none), or ['schema1', 'schema2'] (selective)
272
- // Top-level inheritData supported for backwards compat (lower priority than data.inherit)
273
- if (fullMeta.inheritData !== undefined && runtime.inheritData === undefined) {
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
- // Auto-derive inheritData from entity type when no explicit inherit is set.
278
- // data: { entity: 'articles' } implies inheritData: ['articles']
279
- if (runtime.data && runtime.inheritData === undefined) {
280
- runtime.inheritData = [runtime.data.type]
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 filepath = join(dataDir, `${name}.json`)
591
- await writeFile(filepath, JSON.stringify(items, null, 2))
592
- console.log(`[collection-processor] Generated ${filepath} (${items.length} items)`)
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
 
@@ -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 post-processing to fetched data (filter, sort, limit)
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 filter first
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
- // Inherit-merge config: { inherit: true, detail: false, limit: 3 }
229
- // No URL — merges with the parent fetch config at runtime; only carries override props.
230
- if (fetch.inherit === true) {
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
- inherit: true,
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
- // Post-processing options
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
- // Post-processing options (also supported for path/url fetches)
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
- // Post-processing options
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
  *
@@ -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' })