@uniweb/build 0.37.0 → 0.38.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 +8 -10
- package/src/prerender.js +92 -37
- package/src/runtime-schema.js +17 -2
- package/src/schema.js +73 -0
- package/src/site/build-site-data.js +5 -0
- package/src/site/content-collector.js +59 -7
- package/src/site/data-fetcher.js +168 -28
- package/src/site/plugin.js +17 -5
- package/src/site/queries-config.js +2 -3
- package/src/site/query-processor.js +24 -7
- package/src/site/records-config.js +1 -1
- package/src/uwx/foundation-schema.js +10 -3
- package/src/uwx/records.js +22 -8
- package/src/uwx/registry-package.js +10 -1
- package/src/uwx/site-project.js +6 -1
- package/src/uwx/site.js +8 -4
- package/src/dev-backend.js +0 -247
package/src/dev-backend.js
DELETED
|
@@ -1,247 +0,0 @@
|
|
|
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 recordsByQuery (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} — all records
|
|
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 --recordsByQuery <path> [--port N]
|
|
23
|
-
*
|
|
24
|
-
* Example (academic-metrics):
|
|
25
|
-
* node scripts/framework/dev-backend.js \
|
|
26
|
-
* --recordsByQuery framework/templates/academic-metrics/site/recordsByQuery \
|
|
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
|
-
entities: { type: 'string', short: 'e' },
|
|
49
|
-
port: { type: 'string', short: 'p', default: '8080' },
|
|
50
|
-
},
|
|
51
|
-
})
|
|
52
|
-
|
|
53
|
-
if (!values.entities) {
|
|
54
|
-
console.error('Usage: dev-backend.js --entities <path> [--port N]')
|
|
55
|
-
process.exit(1)
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
const ENTITIES_ROOT = resolve(values.entities)
|
|
59
|
-
const PORT = Number(values.port)
|
|
60
|
-
|
|
61
|
-
if (!existsSync(ENTITIES_ROOT)) {
|
|
62
|
-
console.error(`Entities directory not found: ${ENTITIES_ROOT}`)
|
|
63
|
-
process.exit(1)
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
// ─── Load recordsByQuery from disk ─────────────────────────────────────────────
|
|
67
|
-
|
|
68
|
-
async function loadRecords(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 loadAllRecords() {
|
|
98
|
-
const entries = await readdir(ENTITIES_ROOT)
|
|
99
|
-
const recordsByQuery = {}
|
|
100
|
-
for (const name of entries) {
|
|
101
|
-
const fullPath = join(ENTITIES_ROOT, name)
|
|
102
|
-
const s = await stat(fullPath)
|
|
103
|
-
if (!s.isDirectory()) continue
|
|
104
|
-
recordsByQuery[name] = await loadRecords(fullPath)
|
|
105
|
-
console.log(`[dev-backend] Loaded ${recordsByQuery[name].length} items from "${name}"`)
|
|
106
|
-
}
|
|
107
|
-
return recordsByQuery
|
|
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, recordsByQuery) {
|
|
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 [, queryName, slug] = match
|
|
193
|
-
const items = recordsByQuery[queryName]
|
|
194
|
-
if (!items) return send(res, 404, { error: `Unknown query: ${queryName}` })
|
|
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 recordsByQuery = await loadAllRecords()
|
|
225
|
-
const knownQueries = Object.keys(recordsByQuery)
|
|
226
|
-
if (knownQueries.length === 0) {
|
|
227
|
-
console.warn('[dev-backend] No recordsByQuery found.')
|
|
228
|
-
}
|
|
229
|
-
|
|
230
|
-
const server = createServer((req, res) => {
|
|
231
|
-
handleRequest(req, res, recordsByQuery).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] Queries: ${knownQueries.join(', ') || '(none)'}`)
|
|
240
|
-
console.log('[dev-backend] Endpoints:')
|
|
241
|
-
for (const name of knownQueries) {
|
|
242
|
-
console.log(` GET /api/${name} — all records`)
|
|
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
|
-
})
|