@uniweb/build 0.9.5 → 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.5",
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.5",
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.2"
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
+ })
@@ -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
  }
@@ -242,17 +276,20 @@ export function parseFetchConfig(fetch) {
242
276
  'Accepted for one release; will be removed in the next minor.'
243
277
  )
244
278
  }
279
+ if (fetch.filter !== undefined) warnFilterDeprecated()
245
280
  return {
246
281
  refine: true,
247
282
  ...(fetch.detail !== undefined ? { detail: fetch.detail } : {}),
248
283
  ...(fetch.limit !== undefined ? { limit: fetch.limit } : {}),
249
284
  ...(fetch.sort !== undefined ? { sort: fetch.sort } : {}),
285
+ ...(fetch.where !== undefined ? { where: fetch.where } : {}),
250
286
  ...(fetch.filter !== undefined ? { filter: fetch.filter } : {}),
251
287
  }
252
288
  }
253
289
 
254
290
  // Collection reference: { collection: 'articles', limit: 3 }
255
291
  if (fetch.collection) {
292
+ if (fetch.filter !== undefined) warnFilterDeprecated()
256
293
  return {
257
294
  path: `/data/${fetch.collection}.json`,
258
295
  url: undefined,
@@ -260,9 +297,11 @@ export function parseFetchConfig(fetch) {
260
297
  prerender: fetch.prerender ?? true,
261
298
  merge: fetch.merge ?? false,
262
299
  transform: fetch.transform,
263
- // Post-processing options
300
+ // Query operators
301
+ where: fetch.where,
264
302
  limit: fetch.limit,
265
303
  sort: fetch.sort,
304
+ // Legacy post-processing (deprecated, see warning above)
266
305
  filter: fetch.filter,
267
306
  }
268
307
  }
@@ -275,15 +314,19 @@ export function parseFetchConfig(fetch) {
275
314
  merge = false,
276
315
  transform,
277
316
  detail,
278
- // Post-processing options (also supported for path/url fetches)
317
+ // Query operators
318
+ where,
279
319
  limit,
280
320
  sort,
321
+ // Legacy post-processing (deprecated)
281
322
  filter,
282
323
  } = fetch
283
324
 
284
325
  // Must have either path or url
285
326
  if (!path && !url) return null
286
327
 
328
+ if (filter !== undefined) warnFilterDeprecated()
329
+
287
330
  return {
288
331
  path,
289
332
  url,
@@ -292,13 +335,27 @@ export function parseFetchConfig(fetch) {
292
335
  merge,
293
336
  transform,
294
337
  detail,
295
- // Post-processing options
338
+ // Query operators
339
+ where,
296
340
  limit,
297
341
  sort,
342
+ // Legacy post-processing (deprecated)
298
343
  filter,
299
344
  }
300
345
  }
301
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
+
302
359
  /**
303
360
  * Execute a fetch operation
304
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' })