@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.
@@ -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
@@ -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
- * // Simple string
154
- * parseFetchConfig('/data/team.json')
155
- * // Returns: { path: '/data/team.json', schema: 'team', prerender: true, merge: false }
136
+ * // A query name
137
+ * parseFetchConfig('team')
138
+ * // Returns: { query: 'team', path: '/data/team.json', as: 'team', merge: false }
156
139
  *
157
- * // Full config
158
- * parseFetchConfig({ path: '/team', schema: 'person', prerender: false })
159
- * // Returns: { path: '/team', schema: 'person', prerender: false, merge: false }
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
- // ⭐ `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.
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', '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',
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
- // Simple string: "/data/team.json"
493
+ // ⭐ A STRING IS A QUERY NAME — `fetch: team` ≡ `fetch: { query: team }`.
316
494
  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
- }
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
- // ⛔ 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
- }
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
- // 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
-
518
+ // A binding: { query: 'articles', limit: 3 }
519
+ warnUnknownFetchKeys(fetch, 'query')
520
+ warnSchemaRetired(fetch, fetch.as || fetch.query)
445
521
  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,
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 - Normalized fetch config from parseFetchConfig
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
- // Remote URL
657
- const response = await globalThis.fetch(url)
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 ?? [] }