@uniweb/build 0.47.1 → 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.
@@ -1,17 +1,16 @@
1
1
  /**
2
2
  * Data Fetcher Utilities
3
3
  *
4
- * Handles parsing fetch configurations and executing data fetches
5
- * from local files (public/) or remote URLs.
6
- *
7
- * Supports:
8
- * - Simple string paths: "/data/team.json"
9
- * - Full config objects with schema, prerender, merge, transform options
10
- * - Named-query references: { query: 'articles', limit: 3 }
11
- * - Local JSON/YAML files
12
- * - Remote URLs
13
- * - Transform paths to extract nested data
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
- * // Simple string
154
- * parseFetchConfig('/data/team.json')
155
- * // Returns: { path: '/data/team.json', schema: 'team', prerender: true, merge: false }
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
- * // Collection reference
162
- * parseFetchConfig({ query: 'articles', limit: 3, sort: 'date desc' })
163
- * // Returns: { path: '/data/articles.json', schema: 'articles', limit: 3, sort: 'date desc', ... }
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
- // ⭐ `scope` is recognized since 2026-09-11, when a folder branch became `scope:`
186
- // on both lanes and `where: { path: { under } }` was retired in its favour. It
187
- // was dropped here as "unrecognized" until then, so a page could not narrow a
188
- // query to a branch at all.
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', 'transform',
191
- 'scope', 'where', 'limit', 'sort', 'detailPage',
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
- // Simple string: "/data/team.json"
487
+ // ⭐ A STRING IS A QUERY NAME — `fetch: team` ≡ `fetch: { query: team }`.
316
488
  if (typeof fetch === 'string') {
317
- const inferred = inferSchemaFromPath(fetch)
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
- // ⛔ THE RETIRED ALIAS IS AN ERROR, NOT A WARNING — same reasoning as
332
- // `collection:` below. `inherit: true` was the earlier spelling of
333
- // `refine: true`, accepted with a warning from April 2026 and removed on
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
- // Named-query reference: { query: 'articles', limit: 3 }
379
- if (fetch.query) {
380
- warnUnknownFetchKeys(fetch, 'query')
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
- path,
447
- url,
448
- // ⛔ `schema` is NOT read here — by ruling (2026-09-03), the retired
449
- // spelling has no alias anywhere, authored content included. A fetch that
450
- // still says `schema:` gets the inferred key and is re-authored to `as:`.
451
- as: as ?? inferSchemaFromPath(path || url),
452
- prerender,
453
- merge,
454
- transform,
455
- detail,
456
- // Canonical detail page for a list card's href (page:<stable_id>).
457
- detailPage,
458
- // Query operators
459
- scope,
460
- where,
461
- limit,
462
- sort,
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 - Normalized fetch config from parseFetchConfig
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
- // Remote URL
657
- const response = await globalThis.fetch(url)
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
  }
@@ -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 has three shapes, and the keys each one
4
- // accepts differ (`data-fetcher.js` RECOGNIZED_FETCH_KEYS):
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
- // refine refine · detail · limit · sort · where · filter
7
- // query query · schema · … — and NOT `path`/`url`
8
- // source path · url · schema · …
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 of the three shapes a declaration is — the same order `data-fetcher` uses. */
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