@uniweb/build 0.47.2 → 0.48.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 +124 -47
- package/src/site/content-collector.js +121 -190
- package/src/site/data-fetcher.js +294 -188
- package/src/site/fetch-shapes.js +9 -8
- package/src/site/layout-folder.js +221 -0
- package/src/site/plugin.js +23 -19
- package/src/site/queries-config.js +80 -5
- package/src/site/query-processor.js +23 -25
- 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
|
|
@@ -150,17 +127,13 @@ export function applyPostProcessing(data, config) {
|
|
|
150
127
|
* @returns {object|null} Normalized config or null if invalid
|
|
151
128
|
*
|
|
152
129
|
* @example
|
|
153
|
-
* //
|
|
154
|
-
* parseFetchConfig('
|
|
155
|
-
* // Returns: { path: '/data/team.json',
|
|
156
|
-
*
|
|
157
|
-
* // Full config
|
|
158
|
-
* parseFetchConfig({ path: '/team', schema: 'person', prerender: false })
|
|
159
|
-
* // Returns: { path: '/team', schema: 'person', prerender: false, merge: false }
|
|
130
|
+
* // A query name
|
|
131
|
+
* parseFetchConfig('team')
|
|
132
|
+
* // Returns: { query: 'team', path: '/data/team.json', as: 'team', merge: false }
|
|
160
133
|
*
|
|
161
|
-
* //
|
|
162
|
-
* parseFetchConfig({ query: 'articles', limit: 3, sort: 'date desc' })
|
|
163
|
-
* // Returns: { path: '/data/articles.json',
|
|
134
|
+
* // A binding that adapts its query
|
|
135
|
+
* parseFetchConfig({ query: 'articles', as: 'latest', limit: 3, sort: 'date desc' })
|
|
136
|
+
* // Returns: { query: 'articles', path: '/data/articles.json', as: 'latest', limit: 3, sort: 'date desc', merge: false }
|
|
164
137
|
*/
|
|
165
138
|
// ─── Unrecognized-key reporting ───────────────────────────────────────
|
|
166
139
|
//
|
|
@@ -174,7 +147,6 @@ export function applyPostProcessing(data, config) {
|
|
|
174
147
|
// not understand, but we can refuse to pretend it was never there. Reported
|
|
175
148
|
// once per key name per process so a 200-record build does not print 200 lines.
|
|
176
149
|
const RECOGNIZED_FETCH_KEYS = {
|
|
177
|
-
refine: new Set(['refine', 'detail', 'limit', 'sort', 'where']),
|
|
178
150
|
// ⛔ `schema` IS NOT ON EITHER LIST, and its absence is the point. It was the
|
|
179
151
|
// binding key until 2026-09-02 and stopped being READ on 2026-09-03 (`e4fe077`,
|
|
180
152
|
// one name no alias) — but it was left on these lists, which exempted it from
|
|
@@ -182,17 +154,15 @@ const RECOGNIZED_FETCH_KEYS = {
|
|
|
182
154
|
// dropped in the one way the author could not see: no warning, and a plausible
|
|
183
155
|
// key inferred from the path in its place. It has its own message below, since
|
|
184
156
|
// "unrecognized" understates a key that used to work.
|
|
185
|
-
//
|
|
186
|
-
//
|
|
187
|
-
//
|
|
188
|
-
//
|
|
157
|
+
// ⛔ `scope` is not a binding key — it is the query's, and refused on a binding
|
|
158
|
+
// (`refuseBindingScope`, ruled 2026-09-13 [Diego]). It was recognized here from
|
|
159
|
+
// 2026-09-11 to 2026-09-13, and replaced the query's scope on the records service.
|
|
160
|
+
// ⛔ `path`, `url`, `method`, `body`, `transform` and `detail` are not binding keys
|
|
161
|
+
// either — a binding names a query, which supplies its source (`refuseBinding`).
|
|
162
|
+
// The source shape that took them (`{ path }`, `{ url }`) was retired on 2026-09-13.
|
|
189
163
|
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',
|
|
164
|
+
'query', 'as', 'prerender', 'merge',
|
|
165
|
+
'where', 'limit', 'sort', 'current', 'detailPage',
|
|
196
166
|
]),
|
|
197
167
|
}
|
|
198
168
|
|
|
@@ -226,6 +196,204 @@ export function refuseUnder(where, context) {
|
|
|
226
196
|
walk(where)
|
|
227
197
|
}
|
|
228
198
|
|
|
199
|
+
/**
|
|
200
|
+
* ⛔ A `where` OUTSIDE THE LANGUAGE STOPS THE BUILD — a retired operator (`like`,
|
|
201
|
+
* `nin`), an unknown one, an empty `and` / `or`, a text operator with an empty
|
|
202
|
+
* argument. Every lane answers such a where with no records (`@uniweb/core`'s
|
|
203
|
+
* `whereOutsideLanguage`), which on a page reads as "nothing matched"; an authoring
|
|
204
|
+
* error is said where the author wrote it instead. ⛔ `like` and `nin` worked in this
|
|
205
|
+
* evaluator until 2026-09-13, when it took the language ruled that day.
|
|
206
|
+
*
|
|
207
|
+
* @param {Object|undefined} where
|
|
208
|
+
* @param {string} context - where the declaration sits, for the message
|
|
209
|
+
*/
|
|
210
|
+
export function refuseOutsideLanguage(where, context) {
|
|
211
|
+
const problem = whereOutsideLanguage(where)
|
|
212
|
+
if (problem) throw new Error(`[uniweb] ${context}: ${problem}.`)
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* ⛔ `scope` ON A BINDING STOPS THE BUILD — ruled 2026-09-13 [Diego]: *"it belongs to
|
|
217
|
+
* the query."* Which branch of the folder a query reads decides what the query is;
|
|
218
|
+
* a binding reuses the query and adapts it with `where`, `sort` and `limit` only.
|
|
219
|
+
* Refused rather than ignored, because ignored it would read as a branch the page
|
|
220
|
+
* narrowed to while the query's whole scope arrived. Every lane reads the query's
|
|
221
|
+
* (`@uniweb/core/fetch-config`).
|
|
222
|
+
*
|
|
223
|
+
* @param {Object|undefined} fetch - one authored binding
|
|
224
|
+
* @param {string} context - where the declaration sits, for the message
|
|
225
|
+
*/
|
|
226
|
+
export function refuseBindingScope(fetch, context) {
|
|
227
|
+
if (!fetch || typeof fetch !== 'object' || Array.isArray(fetch)) return
|
|
228
|
+
if (fetch.scope === undefined || typeof fetch.query !== 'string') return
|
|
229
|
+
throw new Error(
|
|
230
|
+
`[uniweb] ${context}: \`scope\` is the query's, not a binding's. Put ` +
|
|
231
|
+
`\`scope: ${JSON.stringify(fetch.scope)}\` on the query \`${fetch.query}\` — or declare ` +
|
|
232
|
+
`another query with that scope — and narrow this binding with \`where\`, \`sort\` and \`limit\`.`
|
|
233
|
+
)
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* ⛔ `refine: true` IS RETIRED — ruled 2026-09-13 [Diego], for `current:`. It was a flag
|
|
238
|
+
* three methods consulted separately, and it did one thing: on a parametric page,
|
|
239
|
+
* `refine: true, detail: false` delivered the route query's records without the
|
|
240
|
+
* page's own. Its `sort` and `where` changed nothing. `inherit: true`, its earlier
|
|
241
|
+
* spelling, has been refused since 2026-09-02 and now names the same replacement.
|
|
242
|
+
*
|
|
243
|
+
* `current:` is validated here too: one of `only`, `exclude`, `include`, and only on
|
|
244
|
+
* a section's binding — how a page's record is used is a section's choice.
|
|
245
|
+
*
|
|
246
|
+
* @param {Object} fetch - one authored binding
|
|
247
|
+
* @param {string} context - where the declaration sits, for the message
|
|
248
|
+
* @param {'section'|'page'|'site'|null} level - the level it sits at, when known
|
|
249
|
+
*/
|
|
250
|
+
function refuseRefineAndMisplacedCurrent(fetch, context, level) {
|
|
251
|
+
const retired = fetch.refine !== undefined ? 'refine' : fetch.inherit !== undefined ? 'inherit' : null
|
|
252
|
+
if (retired) {
|
|
253
|
+
throw new Error(
|
|
254
|
+
`[uniweb] ${context}: \`${retired}: true\` is retired. Name the query, and say how this section uses ` +
|
|
255
|
+
`its page's record with \`current:\` — \`fetch: { query: <name>, current: exclude, limit: 3 }\` for ` +
|
|
256
|
+
`the others, \`current: include\` for all of them, \`current: only\` (the default) for the record.`
|
|
257
|
+
)
|
|
258
|
+
}
|
|
259
|
+
if (fetch.current === undefined) return
|
|
260
|
+
if (!CURRENT_MODES.includes(fetch.current)) {
|
|
261
|
+
throw new Error(
|
|
262
|
+
`[uniweb] ${context}: \`current: ${JSON.stringify(fetch.current)}\` — write \`only\`, \`exclude\` or \`include\`.`
|
|
263
|
+
)
|
|
264
|
+
}
|
|
265
|
+
if (level && level !== 'section') {
|
|
266
|
+
throw new Error(
|
|
267
|
+
`[uniweb] ${context}: \`current:\` is read on a section's binding, not on a ${level}'s — ` +
|
|
268
|
+
`each section of a parametric page says how it uses the page's record.`
|
|
269
|
+
)
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* ⭐ WHAT A BINDING MAY NOT SAY — every refusal, in one place, for the two readers
|
|
275
|
+
* of an authored binding: the build's parse (`parseFetchConfig`) and the sync push,
|
|
276
|
+
* which carries a page's declaration without parsing it (`uwx/site.js`). A site
|
|
277
|
+
* that cannot build must not sync either.
|
|
278
|
+
*
|
|
279
|
+
* @param {Object} fetch - one authored binding
|
|
280
|
+
* @param {string} context - where the declaration sits, for the message
|
|
281
|
+
* @param {Object} [options]
|
|
282
|
+
* @param {'section'|'page'|'site'|null} [options.level] - the level it sits at, when known
|
|
283
|
+
*/
|
|
284
|
+
export function refuseBinding(fetch, context, { level = null } = {}) {
|
|
285
|
+
if (fetch === null || fetch === undefined) return
|
|
286
|
+
if (typeof fetch === 'string') {
|
|
287
|
+
refuseQueryName(fetch, context)
|
|
288
|
+
return
|
|
289
|
+
}
|
|
290
|
+
if (typeof fetch !== 'object' || Array.isArray(fetch)) return
|
|
291
|
+
refuseRefineAndMisplacedCurrent(fetch, context, level)
|
|
292
|
+
refuseSourceKeys(fetch, context)
|
|
293
|
+
if (fetch.collection === undefined) refuseQueryName(fetch.query, context, { object: true })
|
|
294
|
+
refuseUnder(fetch.where, context)
|
|
295
|
+
refuseOutsideLanguage(fetch.where, context)
|
|
296
|
+
refuseBindingScope(fetch, context)
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/**
|
|
300
|
+
* A binding's string, or the string it has for `query:` — is it a query name?
|
|
301
|
+
*
|
|
302
|
+
* ⛔ A NAME, NOT A PATH — ruled 2026-09-13 [Diego]: *"Referencing "/data" is never
|
|
303
|
+
* allowed."* `/data/<name>.json` is the file the build generates from a query for a
|
|
304
|
+
* site with no backend to answer it; naming the query is what lets the same page read
|
|
305
|
+
* that file locally and a host's live records once published. So a string with a
|
|
306
|
+
* slash, or a data file's extension, is refused rather than looked up as a name.
|
|
307
|
+
*
|
|
308
|
+
* @param {*} name
|
|
309
|
+
* @param {string} context
|
|
310
|
+
* @param {{ object?: boolean }} [options] - whether it came from `{ query: … }`
|
|
311
|
+
*/
|
|
312
|
+
function refuseQueryName(name, context, { object = false } = {}) {
|
|
313
|
+
if (typeof name === 'string' && name.trim() !== '' && !/[\\/]|\.(json|ya?ml)$/i.test(name)) return
|
|
314
|
+
if (typeof name === 'string' && name.trim() !== '') {
|
|
315
|
+
const guess = name.replace(/^.*[\\/]/, '').replace(/\.(json|ya?ml)$/i, '')
|
|
316
|
+
throw new Error(
|
|
317
|
+
`[uniweb] ${context}: ${object ? `\`query: ${JSON.stringify(name)}\`` : `\`fetch: ${JSON.stringify(name)}\``} — a fetch ` +
|
|
318
|
+
`names a query, and a query name is not a path. \`/data/…\` is the file the build generates from a query ` +
|
|
319
|
+
`for a site with no backend; declare the query in queries.yml and name it${guess ? ` — \`query: ${guess}\`` : ''}.`
|
|
320
|
+
)
|
|
321
|
+
}
|
|
322
|
+
throw new Error(
|
|
323
|
+
`[uniweb] ${context}: a fetch names a query — \`fetch: { query: <name>, … }\`, or the shorthand \`query: <name>\`. ` +
|
|
324
|
+
`Declare the query in queries.yml.`
|
|
325
|
+
)
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/**
|
|
329
|
+
* ⛔ A BINDING HAS NO SOURCE OF ITS OWN — ruled 2026-09-13 [Diego]. `path:` is the
|
|
330
|
+
* generated file (above); `url:`, `method:`, `body:` and `transform:` describe an
|
|
331
|
+
* external source, which is a named query (*"Making the external source a named query
|
|
332
|
+
* is smart"*) — so every one a site uses is listed in one file; and `detail:` is
|
|
333
|
+
* `current:` on a parametric page's section, or `record:` on an external query.
|
|
334
|
+
*
|
|
335
|
+
* @param {Object} fetch - one authored binding
|
|
336
|
+
* @param {string} context
|
|
337
|
+
*/
|
|
338
|
+
function refuseSourceKeys(fetch, context) {
|
|
339
|
+
if (fetch.path !== undefined) {
|
|
340
|
+
throw new Error(
|
|
341
|
+
`[uniweb] ${context}: \`path:\` is not a fetch key — a fetch names a query, and \`/data/…\` is the file the ` +
|
|
342
|
+
`build generates from one for a site with no backend. ` +
|
|
343
|
+
(typeof fetch.query === 'string' ? 'Delete the `path:` line.' : 'Declare a query in queries.yml and write `query: <name>`.')
|
|
344
|
+
)
|
|
345
|
+
}
|
|
346
|
+
for (const key of ['url', 'method', 'body', 'transform']) {
|
|
347
|
+
if (fetch[key] === undefined) continue
|
|
348
|
+
throw new Error(
|
|
349
|
+
`[uniweb] ${context}: \`${key}:\` belongs on an external query, not on a fetch. Declare it in queries.yml — ` +
|
|
350
|
+
`\`<name>: { url: …${key === 'url' ? '' : `, ${key}: …`} }\` — and name it here with \`query: <name>\`.`
|
|
351
|
+
)
|
|
352
|
+
}
|
|
353
|
+
if (fetch.detail !== undefined) {
|
|
354
|
+
throw new Error(
|
|
355
|
+
`[uniweb] ${context}: \`detail:\` is retired. On a section of a parametric page, say how it uses the page's ` +
|
|
356
|
+
`record with \`current:\`; for an API's single-record endpoint, declare \`record: { url: … }\` on its external query.`
|
|
357
|
+
)
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
/**
|
|
362
|
+
* ⚠️ TWO BINDINGS UNDER ONE KEY AT ONE LEVEL — ruled 2026-09-13 [Diego]: *"Duplicates
|
|
363
|
+
* should not exist."* The first is used on every lane (`resolveFetchConfigs` keeps
|
|
364
|
+
* the first per key, and so does the build's prerender) and the rest are ignored;
|
|
365
|
+
* this says so once per key per file. Warned, not refused: the first binding still
|
|
366
|
+
* delivers what it says.
|
|
367
|
+
*
|
|
368
|
+
* @param {Array<Object>} list - one level's bindings, parsed or as authored
|
|
369
|
+
* @param {string} context - where the declaration sits, for the message
|
|
370
|
+
*/
|
|
371
|
+
const warnedDuplicateBindings = new Set()
|
|
372
|
+
export function warnDuplicateBindings(list, context) {
|
|
373
|
+
if (!Array.isArray(list)) return
|
|
374
|
+
const seen = new Set()
|
|
375
|
+
for (const one of list) {
|
|
376
|
+
const key = one && typeof one === 'object' ? (one.as || one.query) : undefined
|
|
377
|
+
if (typeof key !== 'string' || !key) continue
|
|
378
|
+
if (!seen.has(key)) {
|
|
379
|
+
seen.add(key)
|
|
380
|
+
continue
|
|
381
|
+
}
|
|
382
|
+
const memo = `${context}::${key}`
|
|
383
|
+
if (warnedDuplicateBindings.has(memo)) continue
|
|
384
|
+
warnedDuplicateBindings.add(memo)
|
|
385
|
+
console.warn(
|
|
386
|
+
`[uniweb] ${context}: more than one binding delivers content.data.${key} — the first is used ` +
|
|
387
|
+
`and the rest are ignored. Give each binding its own \`as:\`.`
|
|
388
|
+
)
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
/** Test seam — reset the duplicate-binding memo so suites do not leak into each other. */
|
|
393
|
+
export function _resetDuplicateBindingWarnings() {
|
|
394
|
+
warnedDuplicateBindings.clear()
|
|
395
|
+
}
|
|
396
|
+
|
|
229
397
|
// Keys that are neither recognized nor merely unknown: they USED to work, and a
|
|
230
398
|
// generic "unrecognized key" line understates that. Each has a dedicated message
|
|
231
399
|
// naming its replacement, so this table only has to keep the generic report from
|
|
@@ -297,70 +465,36 @@ export function toFetchList(fetch) {
|
|
|
297
465
|
* already broken.)
|
|
298
466
|
*
|
|
299
467
|
* @param {string|Object|Array|null} fetch
|
|
468
|
+
* @param {string} [context='fetch'] - where the declaration sits (a file), for messages
|
|
469
|
+
* @param {Object} [options]
|
|
470
|
+
* @param {'section'|'page'|'site'|null} [options.level] - the level it sits at, when known
|
|
300
471
|
* @returns {Object|Array<Object>|null}
|
|
301
472
|
*/
|
|
302
|
-
export function parseFetchConfig(fetch) {
|
|
473
|
+
export function parseFetchConfig(fetch, context = 'fetch', { level = null } = {}) {
|
|
303
474
|
if (!fetch) return null
|
|
304
475
|
|
|
305
476
|
if (Array.isArray(fetch)) {
|
|
306
|
-
const parsed = fetch.map((f) => parseFetchConfig(f)).filter(Boolean)
|
|
477
|
+
const parsed = fetch.map((f) => parseFetchConfig(f, context, { level })).filter(Boolean)
|
|
307
478
|
// Flatten: a nested array is not a meaningful authoring shape, and letting
|
|
308
479
|
// one through would put an array inside an array where every consumer
|
|
309
480
|
// expects configs.
|
|
310
481
|
const flat = parsed.flat()
|
|
311
482
|
if (flat.length === 0) return null
|
|
483
|
+
warnDuplicateBindings(flat, context)
|
|
312
484
|
return flat.length === 1 ? flat[0] : flat
|
|
313
485
|
}
|
|
314
486
|
|
|
315
|
-
//
|
|
487
|
+
// ⭐ A STRING IS A QUERY NAME — `fetch: team` ≡ `fetch: { query: team }`.
|
|
316
488
|
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
|
-
}
|
|
489
|
+
refuseBinding(fetch, context, { level })
|
|
490
|
+
return parseFetchConfig({ query: fetch }, context, { level })
|
|
326
491
|
}
|
|
327
492
|
|
|
328
|
-
// Full config object
|
|
329
493
|
if (typeof fetch !== 'object') return null
|
|
330
494
|
|
|
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
|
-
}
|
|
495
|
+
// ⛔ RETIRED SPELLINGS ARE ERRORS, NOT WARNINGS — ignored, a declaration falls
|
|
496
|
+
// through to a shape it is not and resolves to null: a silently empty block.
|
|
497
|
+
refuseBinding(fetch, context, { level })
|
|
364
498
|
|
|
365
499
|
// ⛔ THE RETIRED SPELLING IS AN ERROR, NOT A WARNING. An unrecognized key is
|
|
366
500
|
// warned about and IGNORED, so `fetch: { collection: X }` would fall through to
|
|
@@ -375,91 +509,52 @@ export function parseFetchConfig(fetch) {
|
|
|
375
509
|
)
|
|
376
510
|
}
|
|
377
511
|
|
|
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
|
-
|
|
512
|
+
// A binding: { query: 'articles', limit: 3 }
|
|
513
|
+
warnUnknownFetchKeys(fetch, 'query')
|
|
514
|
+
warnSchemaRetired(fetch, fetch.as || fetch.query)
|
|
445
515
|
return {
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
//
|
|
449
|
-
//
|
|
450
|
-
//
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
//
|
|
457
|
-
|
|
458
|
-
//
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
516
|
+
// ⭐ **`query` IS EMITTED, and that is what makes the two producers agree.**
|
|
517
|
+
// The sync lane has always emitted it (`uwx/site.js`) and this one did not,
|
|
518
|
+
// for the same declaration — so `resolveQuerySource` fired on a published
|
|
519
|
+
// site and never on a `--link`-deployed one. Measured 2026-09-02 against a
|
|
520
|
+
// host offering the `records` service:
|
|
521
|
+
//
|
|
522
|
+
// --link endpoint undefined, path /data/articles.json ← the STATIC file
|
|
523
|
+
// publish endpoint /_api/q/articles ← the live lane
|
|
524
|
+
//
|
|
525
|
+
// Same site, same declaration, two verbs, two data sources. Publishing to a
|
|
526
|
+
// platform that declares a live lane is supposed to READ from it — the
|
|
527
|
+
// compiled `/data/*.json` is the escape hatch for entities with no known
|
|
528
|
+
// data schema, which never sync, not a second way to serve the ones that do.
|
|
529
|
+
//
|
|
530
|
+
// ⚠️ Not in the cache key: `deriveCacheKey` hashes {path,url,endpoint,schema,
|
|
531
|
+
// transform}, so adding this moves no cached entry.
|
|
532
|
+
query: fetch.query,
|
|
533
|
+
// The compiled file's address, for a consumer that cannot resolve the query —
|
|
534
|
+
// derived, never authored (`refuseSourceKeys`).
|
|
535
|
+
path: queryDataUrl(fetch.query),
|
|
536
|
+
// ⭐ **`as` is the BINDING KEY** — the `content.data.<key>` a component
|
|
537
|
+
// reads — defaulting to the query name. It was called `schema` until
|
|
538
|
+
// 2026-09-02, which collided with the MODEL REF of the same name on a
|
|
539
|
+
// `queries` declaration. ⛔ **The old spelling is NOT read here, by
|
|
540
|
+
// ruling (2026-09-03): one name, no alias.** A fetch authored as
|
|
541
|
+
// `schema: posts` binds to nothing and is re-authored, not translated.
|
|
542
|
+
as: fetch.as || fetch.query,
|
|
543
|
+
// ⚠️ Only when authored: its default depends on the query — a query over the
|
|
544
|
+
// site's records is prerendered, an external query is the browser's
|
|
545
|
+
// (`@uniweb/core/fetch-config`) — and the parser does not know which this is.
|
|
546
|
+
prerender: fetch.prerender,
|
|
547
|
+
merge: fetch.merge ?? false,
|
|
548
|
+
// A binding's adaptations of its query (`@uniweb/core/fetch-config`,
|
|
549
|
+
// `narrowQuery`): `where` joins the query's, `sort` and `limit` replace its.
|
|
550
|
+
where: fetch.where,
|
|
551
|
+
limit: fetch.limit,
|
|
552
|
+
sort: fetch.sort,
|
|
553
|
+
// How a section on a parametric page uses the page's record (`currentOf`).
|
|
554
|
+
current: fetch.current,
|
|
555
|
+
// Canonical detail page for a list card's href (page:<stable_id> ref;
|
|
556
|
+
// resolved to a route template + interpolated per record at runtime).
|
|
557
|
+
detailPage: fetch.detailPage,
|
|
463
558
|
}
|
|
464
559
|
}
|
|
465
560
|
|
|
@@ -598,7 +693,8 @@ export function stripBuildOnlyFetchKeys(siteContent) {
|
|
|
598
693
|
/**
|
|
599
694
|
* Execute a fetch operation
|
|
600
695
|
*
|
|
601
|
-
* @param {object} config -
|
|
696
|
+
* @param {object} config - A resolved fetch config (`resolveFetchConfigs`): a compiled
|
|
697
|
+
* `path`, or an external query's `url` with its `method`, `body` and `transform`
|
|
602
698
|
* @param {object} options - Execution options
|
|
603
699
|
* @param {string} options.siteRoot - Site root directory
|
|
604
700
|
* @param {string} [options.publicDir='public'] - Public directory name
|
|
@@ -653,8 +749,18 @@ export async function executeFetch(config, options = {}) {
|
|
|
653
749
|
}
|
|
654
750
|
}
|
|
655
751
|
} else if (url) {
|
|
656
|
-
//
|
|
657
|
-
|
|
752
|
+
// An external query's address — a GET, or a POST with a JSON body, as the
|
|
753
|
+
// runtime's default fetcher sends it.
|
|
754
|
+
const post = typeof config.method === 'string' && config.method.toUpperCase() === 'POST'
|
|
755
|
+
const init = post
|
|
756
|
+
? {
|
|
757
|
+
method: 'POST',
|
|
758
|
+
...(config.body !== undefined && config.body !== null
|
|
759
|
+
? { headers: { 'Content-Type': 'application/json' }, body: typeof config.body === 'string' ? config.body : JSON.stringify(config.body) }
|
|
760
|
+
: {}),
|
|
761
|
+
}
|
|
762
|
+
: undefined
|
|
763
|
+
const response = await globalThis.fetch(url, init)
|
|
658
764
|
if (!response.ok) {
|
|
659
765
|
throw new Error(`HTTP ${response.status}: ${response.statusText}`)
|
|
660
766
|
}
|
package/src/site/fetch-shapes.js
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
// What shape a `fetch:` declaration is, and what a PROJECTION may write back.
|
|
2
2
|
//
|
|
3
|
-
// ⛔ WHY THIS EXISTS. A `fetch:` declaration
|
|
4
|
-
// accepts
|
|
3
|
+
// ⛔ WHY THIS EXISTS. A `fetch:` declaration is a BINDING — it names a query — and
|
|
4
|
+
// accepts `query · as · where · sort · limit · current · …`, NOT `path`/`url`
|
|
5
|
+
// (`data-fetcher.js` RECOGNIZED_FETCH_KEYS). A string is a query name.
|
|
5
6
|
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
7
|
+
// (The source shape — `{ path }`, `{ url }` — and `refine: true` were retired on
|
|
8
|
+
// 2026-09-13; the build refuses both. `source` below classifies what a store may
|
|
9
|
+
// still hold from before, so a pull passes it through rather than guessing.)
|
|
9
10
|
|
|
10
11
|
//
|
|
11
12
|
// The build RESOLVES a `query:` shorthand into a concrete location, so the
|
|
@@ -36,10 +37,9 @@
|
|
|
36
37
|
// a `query` are recoverable from the query itself; anything else survives and the
|
|
37
38
|
// validator's warning stays the honest signal.
|
|
38
39
|
|
|
39
|
-
/** Which
|
|
40
|
+
/** Which shape a declaration is — the same order `data-fetcher` uses. */
|
|
40
41
|
export function fetchShapeOf(fetch) {
|
|
41
42
|
if (!fetch || typeof fetch !== 'object') return null
|
|
42
|
-
if (fetch.refine === true) return 'refine'
|
|
43
43
|
if (fetch.query) return 'query'
|
|
44
44
|
return 'source'
|
|
45
45
|
}
|
|
@@ -47,7 +47,6 @@ export function fetchShapeOf(fetch) {
|
|
|
47
47
|
/** Keys a shape derives rather than the author writing them. */
|
|
48
48
|
const DERIVED_BY_SHAPE = {
|
|
49
49
|
query: ['path', 'url'],
|
|
50
|
-
refine: [],
|
|
51
50
|
source: []
|
|
52
51
|
}
|
|
53
52
|
|
|
@@ -95,8 +94,10 @@ export const DECLARATION_KEYS = Object.freeze(['query', 'fetch', 'data'])
|
|
|
95
94
|
*/
|
|
96
95
|
export function queryNamesOf(fetch) {
|
|
97
96
|
const nameOf = (one) => {
|
|
97
|
+
if (typeof one === 'string' && one !== '') return one
|
|
98
98
|
if (!one || typeof one !== 'object' || typeof one.query !== 'string' || one.query === '') return null
|
|
99
99
|
for (const [key, value] of Object.entries(one)) {
|
|
100
|
+
if (value === undefined) continue
|
|
100
101
|
if (key === 'query' || key === 'path' || key === 'url') continue
|
|
101
102
|
if (key === 'as' && value === one.query) continue
|
|
102
103
|
if (key === 'prerender' && value === true) continue
|