@uniweb/build 0.47.2 → 0.49.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 +6 -6
- package/src/prerender.js +128 -49
- package/src/site/build-site-data.js +2 -1
- package/src/site/content-collector.js +121 -190
- package/src/site/data-fetcher.js +306 -193
- package/src/site/fetch-shapes.js +9 -8
- package/src/site/layout-folder.js +221 -0
- package/src/site/plugin.js +29 -21
- package/src/site/queries-config.js +80 -5
- package/src/site/query-processor.js +32 -29
- package/src/utils/content-files.js +38 -0
- package/src/uwx/records-project.js +7 -2
- package/src/uwx/site-diff.js +7 -2
- package/src/uwx/site-project.js +73 -41
- package/src/uwx/site.js +54 -40
- package/src/validate-data.js +7 -4
package/src/site/data-fetcher.js
CHANGED
|
@@ -1,17 +1,16 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Data Fetcher Utilities
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
* - Post-processing: limit, sort
|
|
4
|
+
* Parses an authored `fetch:` — a BINDING, which names a query — and executes a
|
|
5
|
+
* resolved config: a compiled `/data/<query>.json` under `public/`, or an external
|
|
6
|
+
* query's address.
|
|
7
|
+
*
|
|
8
|
+
* ⭐ A binding always names a query (ruled 2026-09-13 [Diego]): `fetch: team` and
|
|
9
|
+
* `fetch: [team]` mean `fetch: { query: team }` and `fetch: [{ query: team }]`.
|
|
10
|
+
* The query decides where its records live — the compiled file a static build
|
|
11
|
+
* generates, a host's live records, or an external API — so one declaration
|
|
12
|
+
* debugs locally against static data and reads live records once published,
|
|
13
|
+
* with nothing changed. ⛔ `/data/…` is that generated file, never authored.
|
|
15
14
|
*
|
|
16
15
|
* @module @uniweb/build/site/data-fetcher
|
|
17
16
|
*/
|
|
@@ -20,29 +19,7 @@ import { readFile } from 'node:fs/promises'
|
|
|
20
19
|
import { join } from 'node:path'
|
|
21
20
|
import { existsSync } from 'node:fs'
|
|
22
21
|
import yaml from 'js-yaml'
|
|
23
|
-
import { matchWhere, sortRecords, queryDataUrl, applyScope } from '@uniweb/core'
|
|
24
|
-
|
|
25
|
-
/**
|
|
26
|
-
* Infer schema name from path or URL
|
|
27
|
-
* Extracts filename without extension as the schema key
|
|
28
|
-
*
|
|
29
|
-
* @param {string} pathOrUrl - File path or URL
|
|
30
|
-
* @returns {string} Schema name
|
|
31
|
-
*
|
|
32
|
-
* @example
|
|
33
|
-
* inferSchemaFromPath('/data/team-members.json') // 'team-members'
|
|
34
|
-
* inferSchemaFromPath('https://api.com/users') // 'users'
|
|
35
|
-
*/
|
|
36
|
-
function inferSchemaFromPath(pathOrUrl) {
|
|
37
|
-
if (!pathOrUrl) return 'data'
|
|
38
|
-
|
|
39
|
-
// Get the last path segment
|
|
40
|
-
const segment = pathOrUrl.split('/').pop()
|
|
41
|
-
// Remove query string
|
|
42
|
-
const filename = segment.split('?')[0]
|
|
43
|
-
// Remove extension
|
|
44
|
-
return filename.replace(/\.(json|yaml|yml)$/i, '')
|
|
45
|
-
}
|
|
22
|
+
import { matchWhere, sortRecords, queryDataUrl, applyScope, whereOutsideLanguage, CURRENT_MODES } from '@uniweb/core'
|
|
46
23
|
|
|
47
24
|
/**
|
|
48
25
|
* Get a nested value from an object using dot notation
|
|
@@ -75,13 +52,17 @@ function getNestedValue(obj, path) {
|
|
|
75
52
|
* records service refuses and the ruling dropped. A comma now THROWS here, at build
|
|
76
53
|
* time, which is where an authoring error on the file lane belongs.
|
|
77
54
|
*
|
|
55
|
+
* Texts are collated in `locale` — the page's (ruled 2026-09-14 [Diego]).
|
|
56
|
+
*
|
|
78
57
|
* @param {Array} items - Items to sort
|
|
79
58
|
* @param {string} sortExpr - Sort expression: `date`, `date desc`, `-date`
|
|
59
|
+
* @param {Object} [options]
|
|
60
|
+
* @param {string|null} [options.locale] - the page's locale
|
|
80
61
|
* @returns {Array} Sorted items (new array)
|
|
81
62
|
*/
|
|
82
|
-
export function applySort(items, sortExpr) {
|
|
63
|
+
export function applySort(items, sortExpr, { locale = null } = {}) {
|
|
83
64
|
if (!sortExpr || !Array.isArray(items)) return items
|
|
84
|
-
return sortRecords(items, sortExpr)
|
|
65
|
+
return sortRecords(items, sortExpr, { locale })
|
|
85
66
|
}
|
|
86
67
|
|
|
87
68
|
/**
|
|
@@ -111,9 +92,11 @@ export function applyWhere(items, where) {
|
|
|
111
92
|
*
|
|
112
93
|
* @param {any} data - Fetched data
|
|
113
94
|
* @param {object} config - Fetch config with optional where, sort, limit
|
|
95
|
+
* @param {object} [options]
|
|
96
|
+
* @param {string|null} [options.locale] - the page's locale, which texts sort in
|
|
114
97
|
* @returns {any} Processed data
|
|
115
98
|
*/
|
|
116
|
-
export function applyPostProcessing(data, config) {
|
|
99
|
+
export function applyPostProcessing(data, config, { locale = null } = {}) {
|
|
117
100
|
if (!data || !Array.isArray(data)) return data
|
|
118
101
|
if (!config.scope && !config.where && !config.sort && !config.limit) return data
|
|
119
102
|
|
|
@@ -132,7 +115,7 @@ export function applyPostProcessing(data, config) {
|
|
|
132
115
|
|
|
133
116
|
// Apply sort
|
|
134
117
|
if (config.sort) {
|
|
135
|
-
result = applySort(result, config.sort)
|
|
118
|
+
result = applySort(result, config.sort, { locale })
|
|
136
119
|
}
|
|
137
120
|
|
|
138
121
|
// Apply limit last
|
|
@@ -150,17 +133,13 @@ export function applyPostProcessing(data, config) {
|
|
|
150
133
|
* @returns {object|null} Normalized config or null if invalid
|
|
151
134
|
*
|
|
152
135
|
* @example
|
|
153
|
-
* //
|
|
154
|
-
* parseFetchConfig('
|
|
155
|
-
* // Returns: { path: '/data/team.json',
|
|
136
|
+
* // A query name
|
|
137
|
+
* parseFetchConfig('team')
|
|
138
|
+
* // Returns: { query: 'team', path: '/data/team.json', as: 'team', merge: false }
|
|
156
139
|
*
|
|
157
|
-
* //
|
|
158
|
-
* parseFetchConfig({
|
|
159
|
-
* // Returns: { path: '/
|
|
160
|
-
*
|
|
161
|
-
* // Collection reference
|
|
162
|
-
* parseFetchConfig({ query: 'articles', limit: 3, sort: 'date desc' })
|
|
163
|
-
* // Returns: { path: '/data/articles.json', schema: 'articles', limit: 3, sort: 'date desc', ... }
|
|
140
|
+
* // A binding that adapts its query
|
|
141
|
+
* parseFetchConfig({ query: 'articles', as: 'latest', limit: 3, sort: 'date desc' })
|
|
142
|
+
* // Returns: { query: 'articles', path: '/data/articles.json', as: 'latest', limit: 3, sort: 'date desc', merge: false }
|
|
164
143
|
*/
|
|
165
144
|
// ─── Unrecognized-key reporting ───────────────────────────────────────
|
|
166
145
|
//
|
|
@@ -174,7 +153,6 @@ export function applyPostProcessing(data, config) {
|
|
|
174
153
|
// not understand, but we can refuse to pretend it was never there. Reported
|
|
175
154
|
// once per key name per process so a 200-record build does not print 200 lines.
|
|
176
155
|
const RECOGNIZED_FETCH_KEYS = {
|
|
177
|
-
refine: new Set(['refine', 'detail', 'limit', 'sort', 'where']),
|
|
178
156
|
// ⛔ `schema` IS NOT ON EITHER LIST, and its absence is the point. It was the
|
|
179
157
|
// binding key until 2026-09-02 and stopped being READ on 2026-09-03 (`e4fe077`,
|
|
180
158
|
// one name no alias) — but it was left on these lists, which exempted it from
|
|
@@ -182,17 +160,15 @@ const RECOGNIZED_FETCH_KEYS = {
|
|
|
182
160
|
// dropped in the one way the author could not see: no warning, and a plausible
|
|
183
161
|
// key inferred from the path in its place. It has its own message below, since
|
|
184
162
|
// "unrecognized" understates a key that used to work.
|
|
185
|
-
//
|
|
186
|
-
//
|
|
187
|
-
//
|
|
188
|
-
//
|
|
163
|
+
// ⛔ `scope` is not a binding key — it is the query's, and refused on a binding
|
|
164
|
+
// (`refuseBindingScope`, ruled 2026-09-13 [Diego]). It was recognized here from
|
|
165
|
+
// 2026-09-11 to 2026-09-13, and replaced the query's scope on the records service.
|
|
166
|
+
// ⛔ `path`, `url`, `method`, `body`, `transform` and `detail` are not binding keys
|
|
167
|
+
// either — a binding names a query, which supplies its source (`refuseBinding`).
|
|
168
|
+
// The source shape that took them (`{ path }`, `{ url }`) was retired on 2026-09-13.
|
|
189
169
|
query: new Set([
|
|
190
|
-
'query', 'as', 'prerender', 'merge',
|
|
191
|
-
'
|
|
192
|
-
]),
|
|
193
|
-
source: new Set([
|
|
194
|
-
'path', 'url', 'as', 'prerender', 'merge', 'transform', 'detail',
|
|
195
|
-
'detailPage', 'scope', 'where', 'limit', 'sort',
|
|
170
|
+
'query', 'as', 'prerender', 'merge',
|
|
171
|
+
'where', 'limit', 'sort', 'current', 'detailPage',
|
|
196
172
|
]),
|
|
197
173
|
}
|
|
198
174
|
|
|
@@ -226,6 +202,204 @@ export function refuseUnder(where, context) {
|
|
|
226
202
|
walk(where)
|
|
227
203
|
}
|
|
228
204
|
|
|
205
|
+
/**
|
|
206
|
+
* ⛔ A `where` OUTSIDE THE LANGUAGE STOPS THE BUILD — a retired operator (`like`,
|
|
207
|
+
* `nin`), an unknown one, an empty `and` / `or`, a text operator with an empty
|
|
208
|
+
* argument. Every lane answers such a where with no records (`@uniweb/core`'s
|
|
209
|
+
* `whereOutsideLanguage`), which on a page reads as "nothing matched"; an authoring
|
|
210
|
+
* error is said where the author wrote it instead. ⛔ `like` and `nin` worked in this
|
|
211
|
+
* evaluator until 2026-09-13, when it took the language ruled that day.
|
|
212
|
+
*
|
|
213
|
+
* @param {Object|undefined} where
|
|
214
|
+
* @param {string} context - where the declaration sits, for the message
|
|
215
|
+
*/
|
|
216
|
+
export function refuseOutsideLanguage(where, context) {
|
|
217
|
+
const problem = whereOutsideLanguage(where)
|
|
218
|
+
if (problem) throw new Error(`[uniweb] ${context}: ${problem}.`)
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* ⛔ `scope` ON A BINDING STOPS THE BUILD — ruled 2026-09-13 [Diego]: *"it belongs to
|
|
223
|
+
* the query."* Which branch of the folder a query reads decides what the query is;
|
|
224
|
+
* a binding reuses the query and adapts it with `where`, `sort` and `limit` only.
|
|
225
|
+
* Refused rather than ignored, because ignored it would read as a branch the page
|
|
226
|
+
* narrowed to while the query's whole scope arrived. Every lane reads the query's
|
|
227
|
+
* (`@uniweb/core/fetch-config`).
|
|
228
|
+
*
|
|
229
|
+
* @param {Object|undefined} fetch - one authored binding
|
|
230
|
+
* @param {string} context - where the declaration sits, for the message
|
|
231
|
+
*/
|
|
232
|
+
export function refuseBindingScope(fetch, context) {
|
|
233
|
+
if (!fetch || typeof fetch !== 'object' || Array.isArray(fetch)) return
|
|
234
|
+
if (fetch.scope === undefined || typeof fetch.query !== 'string') return
|
|
235
|
+
throw new Error(
|
|
236
|
+
`[uniweb] ${context}: \`scope\` is the query's, not a binding's. Put ` +
|
|
237
|
+
`\`scope: ${JSON.stringify(fetch.scope)}\` on the query \`${fetch.query}\` — or declare ` +
|
|
238
|
+
`another query with that scope — and narrow this binding with \`where\`, \`sort\` and \`limit\`.`
|
|
239
|
+
)
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* ⛔ `refine: true` IS RETIRED — ruled 2026-09-13 [Diego], for `current:`. It was a flag
|
|
244
|
+
* three methods consulted separately, and it did one thing: on a parametric page,
|
|
245
|
+
* `refine: true, detail: false` delivered the route query's records without the
|
|
246
|
+
* page's own. Its `sort` and `where` changed nothing. `inherit: true`, its earlier
|
|
247
|
+
* spelling, has been refused since 2026-09-02 and now names the same replacement.
|
|
248
|
+
*
|
|
249
|
+
* `current:` is validated here too: one of `only`, `exclude`, `include`, and only on
|
|
250
|
+
* a section's binding — how a page's record is used is a section's choice.
|
|
251
|
+
*
|
|
252
|
+
* @param {Object} fetch - one authored binding
|
|
253
|
+
* @param {string} context - where the declaration sits, for the message
|
|
254
|
+
* @param {'section'|'page'|'site'|null} level - the level it sits at, when known
|
|
255
|
+
*/
|
|
256
|
+
function refuseRefineAndMisplacedCurrent(fetch, context, level) {
|
|
257
|
+
const retired = fetch.refine !== undefined ? 'refine' : fetch.inherit !== undefined ? 'inherit' : null
|
|
258
|
+
if (retired) {
|
|
259
|
+
throw new Error(
|
|
260
|
+
`[uniweb] ${context}: \`${retired}: true\` is retired. Name the query, and say how this section uses ` +
|
|
261
|
+
`its page's record with \`current:\` — \`fetch: { query: <name>, current: exclude, limit: 3 }\` for ` +
|
|
262
|
+
`the others, \`current: include\` for all of them, \`current: only\` (the default) for the record.`
|
|
263
|
+
)
|
|
264
|
+
}
|
|
265
|
+
if (fetch.current === undefined) return
|
|
266
|
+
if (!CURRENT_MODES.includes(fetch.current)) {
|
|
267
|
+
throw new Error(
|
|
268
|
+
`[uniweb] ${context}: \`current: ${JSON.stringify(fetch.current)}\` — write \`only\`, \`exclude\` or \`include\`.`
|
|
269
|
+
)
|
|
270
|
+
}
|
|
271
|
+
if (level && level !== 'section') {
|
|
272
|
+
throw new Error(
|
|
273
|
+
`[uniweb] ${context}: \`current:\` is read on a section's binding, not on a ${level}'s — ` +
|
|
274
|
+
`each section of a parametric page says how it uses the page's record.`
|
|
275
|
+
)
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/**
|
|
280
|
+
* ⭐ WHAT A BINDING MAY NOT SAY — every refusal, in one place, for the two readers
|
|
281
|
+
* of an authored binding: the build's parse (`parseFetchConfig`) and the sync push,
|
|
282
|
+
* which carries a page's declaration without parsing it (`uwx/site.js`). A site
|
|
283
|
+
* that cannot build must not sync either.
|
|
284
|
+
*
|
|
285
|
+
* @param {Object} fetch - one authored binding
|
|
286
|
+
* @param {string} context - where the declaration sits, for the message
|
|
287
|
+
* @param {Object} [options]
|
|
288
|
+
* @param {'section'|'page'|'site'|null} [options.level] - the level it sits at, when known
|
|
289
|
+
*/
|
|
290
|
+
export function refuseBinding(fetch, context, { level = null } = {}) {
|
|
291
|
+
if (fetch === null || fetch === undefined) return
|
|
292
|
+
if (typeof fetch === 'string') {
|
|
293
|
+
refuseQueryName(fetch, context)
|
|
294
|
+
return
|
|
295
|
+
}
|
|
296
|
+
if (typeof fetch !== 'object' || Array.isArray(fetch)) return
|
|
297
|
+
refuseRefineAndMisplacedCurrent(fetch, context, level)
|
|
298
|
+
refuseSourceKeys(fetch, context)
|
|
299
|
+
if (fetch.collection === undefined) refuseQueryName(fetch.query, context, { object: true })
|
|
300
|
+
refuseUnder(fetch.where, context)
|
|
301
|
+
refuseOutsideLanguage(fetch.where, context)
|
|
302
|
+
refuseBindingScope(fetch, context)
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/**
|
|
306
|
+
* A binding's string, or the string it has for `query:` — is it a query name?
|
|
307
|
+
*
|
|
308
|
+
* ⛔ A NAME, NOT A PATH — ruled 2026-09-13 [Diego]: *"Referencing "/data" is never
|
|
309
|
+
* allowed."* `/data/<name>.json` is the file the build generates from a query for a
|
|
310
|
+
* site with no backend to answer it; naming the query is what lets the same page read
|
|
311
|
+
* that file locally and a host's live records once published. So a string with a
|
|
312
|
+
* slash, or a data file's extension, is refused rather than looked up as a name.
|
|
313
|
+
*
|
|
314
|
+
* @param {*} name
|
|
315
|
+
* @param {string} context
|
|
316
|
+
* @param {{ object?: boolean }} [options] - whether it came from `{ query: … }`
|
|
317
|
+
*/
|
|
318
|
+
function refuseQueryName(name, context, { object = false } = {}) {
|
|
319
|
+
if (typeof name === 'string' && name.trim() !== '' && !/[\\/]|\.(json|ya?ml)$/i.test(name)) return
|
|
320
|
+
if (typeof name === 'string' && name.trim() !== '') {
|
|
321
|
+
const guess = name.replace(/^.*[\\/]/, '').replace(/\.(json|ya?ml)$/i, '')
|
|
322
|
+
throw new Error(
|
|
323
|
+
`[uniweb] ${context}: ${object ? `\`query: ${JSON.stringify(name)}\`` : `\`fetch: ${JSON.stringify(name)}\``} — a fetch ` +
|
|
324
|
+
`names a query, and a query name is not a path. \`/data/…\` is the file the build generates from a query ` +
|
|
325
|
+
`for a site with no backend; declare the query in queries.yml and name it${guess ? ` — \`query: ${guess}\`` : ''}.`
|
|
326
|
+
)
|
|
327
|
+
}
|
|
328
|
+
throw new Error(
|
|
329
|
+
`[uniweb] ${context}: a fetch names a query — \`fetch: { query: <name>, … }\`, or the shorthand \`query: <name>\`. ` +
|
|
330
|
+
`Declare the query in queries.yml.`
|
|
331
|
+
)
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
/**
|
|
335
|
+
* ⛔ A BINDING HAS NO SOURCE OF ITS OWN — ruled 2026-09-13 [Diego]. `path:` is the
|
|
336
|
+
* generated file (above); `url:`, `method:`, `body:` and `transform:` describe an
|
|
337
|
+
* external source, which is a named query (*"Making the external source a named query
|
|
338
|
+
* is smart"*) — so every one a site uses is listed in one file; and `detail:` is
|
|
339
|
+
* `current:` on a parametric page's section, or `record:` on an external query.
|
|
340
|
+
*
|
|
341
|
+
* @param {Object} fetch - one authored binding
|
|
342
|
+
* @param {string} context
|
|
343
|
+
*/
|
|
344
|
+
function refuseSourceKeys(fetch, context) {
|
|
345
|
+
if (fetch.path !== undefined) {
|
|
346
|
+
throw new Error(
|
|
347
|
+
`[uniweb] ${context}: \`path:\` is not a fetch key — a fetch names a query, and \`/data/…\` is the file the ` +
|
|
348
|
+
`build generates from one for a site with no backend. ` +
|
|
349
|
+
(typeof fetch.query === 'string' ? 'Delete the `path:` line.' : 'Declare a query in queries.yml and write `query: <name>`.')
|
|
350
|
+
)
|
|
351
|
+
}
|
|
352
|
+
for (const key of ['url', 'method', 'body', 'transform']) {
|
|
353
|
+
if (fetch[key] === undefined) continue
|
|
354
|
+
throw new Error(
|
|
355
|
+
`[uniweb] ${context}: \`${key}:\` belongs on an external query, not on a fetch. Declare it in queries.yml — ` +
|
|
356
|
+
`\`<name>: { url: …${key === 'url' ? '' : `, ${key}: …`} }\` — and name it here with \`query: <name>\`.`
|
|
357
|
+
)
|
|
358
|
+
}
|
|
359
|
+
if (fetch.detail !== undefined) {
|
|
360
|
+
throw new Error(
|
|
361
|
+
`[uniweb] ${context}: \`detail:\` is retired. On a section of a parametric page, say how it uses the page's ` +
|
|
362
|
+
`record with \`current:\`; for an API's single-record endpoint, declare \`record: { url: … }\` on its external query.`
|
|
363
|
+
)
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
/**
|
|
368
|
+
* ⚠️ TWO BINDINGS UNDER ONE KEY AT ONE LEVEL — ruled 2026-09-13 [Diego]: *"Duplicates
|
|
369
|
+
* should not exist."* The first is used on every lane (`resolveFetchConfigs` keeps
|
|
370
|
+
* the first per key, and so does the build's prerender) and the rest are ignored;
|
|
371
|
+
* this says so once per key per file. Warned, not refused: the first binding still
|
|
372
|
+
* delivers what it says.
|
|
373
|
+
*
|
|
374
|
+
* @param {Array<Object>} list - one level's bindings, parsed or as authored
|
|
375
|
+
* @param {string} context - where the declaration sits, for the message
|
|
376
|
+
*/
|
|
377
|
+
const warnedDuplicateBindings = new Set()
|
|
378
|
+
export function warnDuplicateBindings(list, context) {
|
|
379
|
+
if (!Array.isArray(list)) return
|
|
380
|
+
const seen = new Set()
|
|
381
|
+
for (const one of list) {
|
|
382
|
+
const key = one && typeof one === 'object' ? (one.as || one.query) : undefined
|
|
383
|
+
if (typeof key !== 'string' || !key) continue
|
|
384
|
+
if (!seen.has(key)) {
|
|
385
|
+
seen.add(key)
|
|
386
|
+
continue
|
|
387
|
+
}
|
|
388
|
+
const memo = `${context}::${key}`
|
|
389
|
+
if (warnedDuplicateBindings.has(memo)) continue
|
|
390
|
+
warnedDuplicateBindings.add(memo)
|
|
391
|
+
console.warn(
|
|
392
|
+
`[uniweb] ${context}: more than one binding delivers content.data.${key} — the first is used ` +
|
|
393
|
+
`and the rest are ignored. Give each binding its own \`as:\`.`
|
|
394
|
+
)
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
/** Test seam — reset the duplicate-binding memo so suites do not leak into each other. */
|
|
399
|
+
export function _resetDuplicateBindingWarnings() {
|
|
400
|
+
warnedDuplicateBindings.clear()
|
|
401
|
+
}
|
|
402
|
+
|
|
229
403
|
// Keys that are neither recognized nor merely unknown: they USED to work, and a
|
|
230
404
|
// generic "unrecognized key" line understates that. Each has a dedicated message
|
|
231
405
|
// naming its replacement, so this table only has to keep the generic report from
|
|
@@ -297,70 +471,36 @@ export function toFetchList(fetch) {
|
|
|
297
471
|
* already broken.)
|
|
298
472
|
*
|
|
299
473
|
* @param {string|Object|Array|null} fetch
|
|
474
|
+
* @param {string} [context='fetch'] - where the declaration sits (a file), for messages
|
|
475
|
+
* @param {Object} [options]
|
|
476
|
+
* @param {'section'|'page'|'site'|null} [options.level] - the level it sits at, when known
|
|
300
477
|
* @returns {Object|Array<Object>|null}
|
|
301
478
|
*/
|
|
302
|
-
export function parseFetchConfig(fetch) {
|
|
479
|
+
export function parseFetchConfig(fetch, context = 'fetch', { level = null } = {}) {
|
|
303
480
|
if (!fetch) return null
|
|
304
481
|
|
|
305
482
|
if (Array.isArray(fetch)) {
|
|
306
|
-
const parsed = fetch.map((f) => parseFetchConfig(f)).filter(Boolean)
|
|
483
|
+
const parsed = fetch.map((f) => parseFetchConfig(f, context, { level })).filter(Boolean)
|
|
307
484
|
// Flatten: a nested array is not a meaningful authoring shape, and letting
|
|
308
485
|
// one through would put an array inside an array where every consumer
|
|
309
486
|
// expects configs.
|
|
310
487
|
const flat = parsed.flat()
|
|
311
488
|
if (flat.length === 0) return null
|
|
489
|
+
warnDuplicateBindings(flat, context)
|
|
312
490
|
return flat.length === 1 ? flat[0] : flat
|
|
313
491
|
}
|
|
314
492
|
|
|
315
|
-
//
|
|
493
|
+
// ⭐ A STRING IS A QUERY NAME — `fetch: team` ≡ `fetch: { query: team }`.
|
|
316
494
|
if (typeof fetch === 'string') {
|
|
317
|
-
|
|
318
|
-
return {
|
|
319
|
-
path: fetch,
|
|
320
|
-
url: undefined,
|
|
321
|
-
as: inferred,
|
|
322
|
-
prerender: true,
|
|
323
|
-
merge: false,
|
|
324
|
-
transform: undefined,
|
|
325
|
-
}
|
|
495
|
+
refuseBinding(fetch, context, { level })
|
|
496
|
+
return parseFetchConfig({ query: fetch }, context, { level })
|
|
326
497
|
}
|
|
327
498
|
|
|
328
|
-
// Full config object
|
|
329
499
|
if (typeof fetch !== 'object') return null
|
|
330
500
|
|
|
331
|
-
// ⛔
|
|
332
|
-
//
|
|
333
|
-
|
|
334
|
-
// 2026-09-02. Ignored, `{ inherit: true, limit: 3 }` would fall through to
|
|
335
|
-
// the source shape, find neither `path` nor `url`, and resolve to null — a
|
|
336
|
-
// silently empty block.
|
|
337
|
-
if (fetch.inherit !== undefined) {
|
|
338
|
-
throw new Error(
|
|
339
|
-
'[uniweb] fetch: `inherit: true` is retired. Write `refine: true` — the same ' +
|
|
340
|
-
'per-instance refinement of the ancestor fetch, under its current name.'
|
|
341
|
-
)
|
|
342
|
-
}
|
|
343
|
-
refuseUnder(fetch.where, 'fetch')
|
|
344
|
-
|
|
345
|
-
// Refine config: { refine: true, detail: false, limit: 3 }
|
|
346
|
-
// No URL — merges with the parent fetch config at runtime; only carries
|
|
347
|
-
// override props.
|
|
348
|
-
//
|
|
349
|
-
// Note on build-vs-runtime scope: this parser passes `sort`
|
|
350
|
-
// through on refine configs, but the runtime EntityStore only applies
|
|
351
|
-
// `detail`, `limit`, and `order` overrides. `sort` on a refine
|
|
352
|
-
// block are currently accepted by the parser but not honored at runtime.
|
|
353
|
-
// Preserved as-is in this rename commit; revisit separately if needed.
|
|
354
|
-
if (fetch.refine === true) {
|
|
355
|
-
warnUnknownFetchKeys(fetch, 'refine')
|
|
356
|
-
return {
|
|
357
|
-
refine: true,
|
|
358
|
-
...(fetch.detail !== undefined ? { detail: fetch.detail } : {}),
|
|
359
|
-
...(fetch.limit !== undefined ? { limit: fetch.limit } : {}),
|
|
360
|
-
...(fetch.sort !== undefined ? { sort: fetch.sort } : {}),
|
|
361
|
-
...(fetch.where !== undefined ? { where: fetch.where } : {}),
|
|
362
|
-
}
|
|
363
|
-
}
|
|
501
|
+
// ⛔ RETIRED SPELLINGS ARE ERRORS, NOT WARNINGS — ignored, a declaration falls
|
|
502
|
+
// through to a shape it is not and resolves to null: a silently empty block.
|
|
503
|
+
refuseBinding(fetch, context, { level })
|
|
364
504
|
|
|
365
505
|
// ⛔ THE RETIRED SPELLING IS AN ERROR, NOT A WARNING. An unrecognized key is
|
|
366
506
|
// warned about and IGNORED, so `fetch: { collection: X }` would fall through to
|
|
@@ -375,91 +515,52 @@ export function parseFetchConfig(fetch) {
|
|
|
375
515
|
)
|
|
376
516
|
}
|
|
377
517
|
|
|
378
|
-
//
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
warnSchemaRetired(fetch, fetch.as || fetch.query)
|
|
382
|
-
return {
|
|
383
|
-
// ⭐ **`query` IS EMITTED, and that is what makes the two producers agree.**
|
|
384
|
-
// The sync lane has always emitted it (`uwx/site.js`) and this one did not,
|
|
385
|
-
// for the same declaration — so `resolveQuerySource` fired on a published
|
|
386
|
-
// site and never on a `--link`-deployed one. Measured 2026-09-02 against a
|
|
387
|
-
// host offering the `records` service:
|
|
388
|
-
//
|
|
389
|
-
// --link endpoint undefined, path /data/articles.json ← the STATIC file
|
|
390
|
-
// publish endpoint /_api/q/articles ← the live lane
|
|
391
|
-
//
|
|
392
|
-
// Same site, same declaration, two verbs, two data sources. Publishing to a
|
|
393
|
-
// platform that declares a live lane is supposed to READ from it — the
|
|
394
|
-
// compiled `/data/*.json` is the escape hatch for entities with no known
|
|
395
|
-
// data schema, which never sync, not a second way to serve the ones that do.
|
|
396
|
-
//
|
|
397
|
-
// ⚠️ Not in the cache key: `deriveCacheKey` hashes {path,url,endpoint,schema,
|
|
398
|
-
// transform}, so adding this moves no cached entry.
|
|
399
|
-
query: fetch.query,
|
|
400
|
-
path: queryDataUrl(fetch.query),
|
|
401
|
-
url: undefined,
|
|
402
|
-
// ⭐ **`as` is the BINDING KEY** — the `content.data.<key>` a component
|
|
403
|
-
// reads — defaulting to the query name. It was called `schema` until
|
|
404
|
-
// 2026-09-02, which collided with the MODEL REF of the same name on a
|
|
405
|
-
// `queries` declaration. ⛔ **The old spelling is NOT read here, by
|
|
406
|
-
// ruling (2026-09-03): one name, no alias.** A fetch authored as
|
|
407
|
-
// `schema: posts` binds to nothing and is re-authored, not translated.
|
|
408
|
-
as: fetch.as || fetch.query,
|
|
409
|
-
prerender: fetch.prerender ?? true,
|
|
410
|
-
merge: fetch.merge ?? false,
|
|
411
|
-
transform: fetch.transform,
|
|
412
|
-
// Query operators — a fetch's own override the named query's, per field
|
|
413
|
-
scope: fetch.scope,
|
|
414
|
-
where: fetch.where,
|
|
415
|
-
limit: fetch.limit,
|
|
416
|
-
sort: fetch.sort,
|
|
417
|
-
// Canonical detail page for a list card's href (page:<stable_id> ref;
|
|
418
|
-
// resolved to a route template + interpolated per record at runtime).
|
|
419
|
-
detailPage: fetch.detailPage,
|
|
420
|
-
}
|
|
421
|
-
}
|
|
422
|
-
|
|
423
|
-
warnUnknownFetchKeys(fetch, 'source')
|
|
424
|
-
const {
|
|
425
|
-
path,
|
|
426
|
-
url,
|
|
427
|
-
as,
|
|
428
|
-
prerender = url ? false : true,
|
|
429
|
-
merge = false,
|
|
430
|
-
transform,
|
|
431
|
-
detail,
|
|
432
|
-
detailPage,
|
|
433
|
-
// Query operators
|
|
434
|
-
scope,
|
|
435
|
-
where,
|
|
436
|
-
limit,
|
|
437
|
-
sort,
|
|
438
|
-
} = fetch
|
|
439
|
-
|
|
440
|
-
// Must have either path or url
|
|
441
|
-
if (!path && !url) return null
|
|
442
|
-
|
|
443
|
-
warnSchemaRetired(fetch, as ?? inferSchemaFromPath(path || url))
|
|
444
|
-
|
|
518
|
+
// A binding: { query: 'articles', limit: 3 }
|
|
519
|
+
warnUnknownFetchKeys(fetch, 'query')
|
|
520
|
+
warnSchemaRetired(fetch, fetch.as || fetch.query)
|
|
445
521
|
return {
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
//
|
|
449
|
-
//
|
|
450
|
-
//
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
//
|
|
457
|
-
|
|
458
|
-
//
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
522
|
+
// ⭐ **`query` IS EMITTED, and that is what makes the two producers agree.**
|
|
523
|
+
// The sync lane has always emitted it (`uwx/site.js`) and this one did not,
|
|
524
|
+
// for the same declaration — so `resolveQuerySource` fired on a published
|
|
525
|
+
// site and never on a `--link`-deployed one. Measured 2026-09-02 against a
|
|
526
|
+
// host offering the `records` service:
|
|
527
|
+
//
|
|
528
|
+
// --link endpoint undefined, path /data/articles.json ← the STATIC file
|
|
529
|
+
// publish endpoint /_api/q/articles ← the live lane
|
|
530
|
+
//
|
|
531
|
+
// Same site, same declaration, two verbs, two data sources. Publishing to a
|
|
532
|
+
// platform that declares a live lane is supposed to READ from it — the
|
|
533
|
+
// compiled `/data/*.json` is the escape hatch for entities with no known
|
|
534
|
+
// data schema, which never sync, not a second way to serve the ones that do.
|
|
535
|
+
//
|
|
536
|
+
// ⚠️ Not in the cache key: `deriveCacheKey` hashes {path,url,endpoint,schema,
|
|
537
|
+
// transform}, so adding this moves no cached entry.
|
|
538
|
+
query: fetch.query,
|
|
539
|
+
// The compiled file's address, for a consumer that cannot resolve the query —
|
|
540
|
+
// derived, never authored (`refuseSourceKeys`).
|
|
541
|
+
path: queryDataUrl(fetch.query),
|
|
542
|
+
// ⭐ **`as` is the BINDING KEY** — the `content.data.<key>` a component
|
|
543
|
+
// reads — defaulting to the query name. It was called `schema` until
|
|
544
|
+
// 2026-09-02, which collided with the MODEL REF of the same name on a
|
|
545
|
+
// `queries` declaration. ⛔ **The old spelling is NOT read here, by
|
|
546
|
+
// ruling (2026-09-03): one name, no alias.** A fetch authored as
|
|
547
|
+
// `schema: posts` binds to nothing and is re-authored, not translated.
|
|
548
|
+
as: fetch.as || fetch.query,
|
|
549
|
+
// ⚠️ Only when authored: its default depends on the query — a query over the
|
|
550
|
+
// site's records is prerendered, an external query is the browser's
|
|
551
|
+
// (`@uniweb/core/fetch-config`) — and the parser does not know which this is.
|
|
552
|
+
prerender: fetch.prerender,
|
|
553
|
+
merge: fetch.merge ?? false,
|
|
554
|
+
// A binding's adaptations of its query (`@uniweb/core/fetch-config`,
|
|
555
|
+
// `narrowQuery`): `where` joins the query's, `sort` and `limit` replace its.
|
|
556
|
+
where: fetch.where,
|
|
557
|
+
limit: fetch.limit,
|
|
558
|
+
sort: fetch.sort,
|
|
559
|
+
// How a section on a parametric page uses the page's record (`currentOf`).
|
|
560
|
+
current: fetch.current,
|
|
561
|
+
// Canonical detail page for a list card's href (page:<stable_id> ref;
|
|
562
|
+
// resolved to a route template + interpolated per record at runtime).
|
|
563
|
+
detailPage: fetch.detailPage,
|
|
463
564
|
}
|
|
464
565
|
}
|
|
465
566
|
|
|
@@ -598,10 +699,12 @@ export function stripBuildOnlyFetchKeys(siteContent) {
|
|
|
598
699
|
/**
|
|
599
700
|
* Execute a fetch operation
|
|
600
701
|
*
|
|
601
|
-
* @param {object} config -
|
|
702
|
+
* @param {object} config - A resolved fetch config (`resolveFetchConfigs`): a compiled
|
|
703
|
+
* `path`, or an external query's `url` with its `method`, `body` and `transform`
|
|
602
704
|
* @param {object} options - Execution options
|
|
603
705
|
* @param {string} options.siteRoot - Site root directory
|
|
604
706
|
* @param {string} [options.publicDir='public'] - Public directory name
|
|
707
|
+
* @param {string|null} [options.locale] - the page's locale, which a `sort` collates texts in
|
|
605
708
|
* @returns {Promise<{ data: any, error?: string }>} Fetched data or error
|
|
606
709
|
*
|
|
607
710
|
* @example
|
|
@@ -653,8 +756,18 @@ export async function executeFetch(config, options = {}) {
|
|
|
653
756
|
}
|
|
654
757
|
}
|
|
655
758
|
} else if (url) {
|
|
656
|
-
//
|
|
657
|
-
|
|
759
|
+
// An external query's address — a GET, or a POST with a JSON body, as the
|
|
760
|
+
// runtime's default fetcher sends it.
|
|
761
|
+
const post = typeof config.method === 'string' && config.method.toUpperCase() === 'POST'
|
|
762
|
+
const init = post
|
|
763
|
+
? {
|
|
764
|
+
method: 'POST',
|
|
765
|
+
...(config.body !== undefined && config.body !== null
|
|
766
|
+
? { headers: { 'Content-Type': 'application/json' }, body: typeof config.body === 'string' ? config.body : JSON.stringify(config.body) }
|
|
767
|
+
: {}),
|
|
768
|
+
}
|
|
769
|
+
: undefined
|
|
770
|
+
const response = await globalThis.fetch(url, init)
|
|
658
771
|
if (!response.ok) {
|
|
659
772
|
throw new Error(`HTTP ${response.status}: ${response.statusText}`)
|
|
660
773
|
}
|
|
@@ -667,7 +780,7 @@ export async function executeFetch(config, options = {}) {
|
|
|
667
780
|
}
|
|
668
781
|
|
|
669
782
|
// Apply post-processing (where, sort, limit)
|
|
670
|
-
data = applyPostProcessing(data, config)
|
|
783
|
+
data = applyPostProcessing(data, config, { locale: options.locale ?? null })
|
|
671
784
|
|
|
672
785
|
// Ensure we return an array or object, defaulting to empty array
|
|
673
786
|
return { data: data ?? [] }
|