@uniweb/build 0.35.0 → 0.36.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@uniweb/build",
3
- "version": "0.35.0",
3
+ "version": "0.36.0",
4
4
  "description": "Build tooling for the Uniweb Component Web Platform",
5
5
  "type": "module",
6
6
  "exports": {
@@ -59,15 +59,15 @@
59
59
  "js-yaml": "^4.1.0",
60
60
  "sharp": "^0.35.3",
61
61
  "yaml": "^2.5.0",
62
- "@uniweb/projections": "^0.5.6",
63
- "@uniweb/theming": "^0.1.15",
64
62
  "@uniweb/semantic-parser": "^1.4.0",
65
63
  "@uniweb/content-writer": "^0.3.4",
64
+ "@uniweb/theming": "^0.1.15",
66
65
  "@uniweb/content-reader": "^1.2.4",
67
- "@uniweb/schemas": "^0.2.13"
66
+ "@uniweb/schemas": "^0.2.13",
67
+ "@uniweb/projections": "^0.5.7"
68
68
  },
69
69
  "optionalDependencies": {
70
- "@uniweb/runtime": "^0.13.7"
70
+ "@uniweb/runtime": "^0.14.0"
71
71
  },
72
72
  "peerDependencies": {
73
73
  "vite": "^5.0.0 || ^6.0.0 || ^7.0.0",
@@ -76,7 +76,7 @@
76
76
  "@tailwindcss/vite": "^4.0.0",
77
77
  "@vitejs/plugin-react": "^4.0.0 || ^5.0.0",
78
78
  "vite-plugin-svgr": "^4.0.0",
79
- "@uniweb/core": "^0.18.0"
79
+ "@uniweb/core": "^0.19.0"
80
80
  },
81
81
  "peerDependenciesMeta": {
82
82
  "vite": {
package/src/prerender.js CHANGED
@@ -12,7 +12,7 @@ import { existsSync, readdirSync, statSync } from 'node:fs'
12
12
  import { join, dirname, resolve } from 'node:path'
13
13
  import { pathToFileURL } from 'node:url'
14
14
  import { resolveDefaultLocale, isDataUrl } from '@uniweb/core'
15
- import { executeFetch, mergeDataIntoContent } from './site/data-fetcher.js'
15
+ import { executeFetch, mergeDataIntoContent, toFetchList } from './site/data-fetcher.js'
16
16
  import { shouldSplitContent } from './site/split-content.js'
17
17
  import { FONT_LINKS_MARKER } from './site/head-markers.js'
18
18
  import { getAdapter } from './hosts/index.js'
@@ -98,11 +98,13 @@ async function executeAllFetches(siteContent, siteDir, onProgress, localeInfo) {
98
98
  ? { siteRoot: localeInfo.distDir, publicDir: '.' }
99
99
  : fetchOptions
100
100
 
101
- // 1. Site-level fetch
102
- const siteFetch = siteContent.config?.fetch
103
- if (siteFetch && siteFetch.prerender !== false) {
104
- const cfg = localizeFetch(siteFetch)
105
- const opts = cfg !== siteFetch ? localizedFetchOptions : fetchOptions
101
+ // 1. Site-level fetch. ⛔ `toFetchList` rather than a property read: a `fetch:`
102
+ // or `data:` LIST parses to an array, and `siteFetch.prerender` on one is
103
+ // `undefined` which passes the `!== false` test and then fetches nothing.
104
+ for (const oneFetch of toFetchList(siteContent.config?.fetch)) {
105
+ if (oneFetch.prerender === false) continue
106
+ const cfg = localizeFetch(oneFetch)
107
+ const opts = cfg !== oneFetch ? localizedFetchOptions : fetchOptions
106
108
  onProgress(` Fetching site data: ${cfg.path || cfg.url}`)
107
109
  const result = await executeFetch(cfg, opts)
108
110
  if (result.data && !result.error) {
@@ -114,20 +116,25 @@ async function executeAllFetches(siteContent, siteDir, onProgress, localeInfo) {
114
116
  const pageFetchedData = new Map()
115
117
 
116
118
  for (const page of siteContent.pages || []) {
117
- // Page-level fetch
118
- const pageFetch = page.fetch
119
- if (pageFetch && pageFetch.prerender !== false) {
120
- const cfg = localizeFetch(pageFetch)
121
- const opts = cfg !== pageFetch ? localizedFetchOptions : fetchOptions
119
+ // Page-level fetch — every declaration on the page.
120
+ for (const oneFetch of toFetchList(page.fetch)) {
121
+ if (oneFetch.prerender === false) continue
122
+ const cfg = localizeFetch(oneFetch)
123
+ const opts = cfg !== oneFetch ? localizedFetchOptions : fetchOptions
122
124
  onProgress(` Fetching page data for ${page.route}: ${cfg.path || cfg.url}`)
123
125
  const result = await executeFetch(cfg, opts)
124
126
  if (result.data && !result.error) {
125
127
  fetchedData.push({ config: cfg, data: result.data, _scope: page.route })
126
- // Store for dynamic route expansion
127
- pageFetchedData.set(page.route, {
128
- schema: pageFetch.schema,
129
- data: result.data,
130
- })
128
+ // ⚖️ Dynamic-route expansion consumes ONE query — a `[slug]` template
129
+ // expands over a single record set. With several declared, the first
130
+ // that prerenders is the route query, matching `parentSchema` in the
131
+ // collector; `expandDynamicPages` is what reads this back.
132
+ if (!pageFetchedData.has(page.route)) {
133
+ pageFetchedData.set(page.route, {
134
+ schema: oneFetch.as,
135
+ data: result.data,
136
+ })
137
+ }
131
138
  }
132
139
  }
133
140
 
@@ -295,17 +302,18 @@ async function processSectionFetches(sections, fetchOptions, onProgress) {
295
302
  if (!sections || !Array.isArray(sections)) return
296
303
 
297
304
  for (const section of sections) {
298
- // Execute section-level fetch
299
- const sectionFetch = section.fetch
300
- if (sectionFetch && sectionFetch.prerender !== false) {
305
+ // Execute every section-level fetch. Each merges under its own key, so
306
+ // several accumulate into one `parsedContent.data` — the same keyed map the
307
+ // runtime's EntityStore builds.
308
+ for (const sectionFetch of toFetchList(section.fetch)) {
309
+ if (sectionFetch.prerender === false) continue
301
310
  onProgress(` Fetching section data: ${sectionFetch.path || sectionFetch.url}`)
302
311
  const result = await executeFetch(sectionFetch, fetchOptions)
303
312
  if (result.data && !result.error) {
304
- // Merge fetched data into section's parsedContent
305
313
  section.parsedContent = mergeDataIntoContent(
306
314
  section.parsedContent || {},
307
315
  result.data,
308
- sectionFetch.schema,
316
+ sectionFetch.as,
309
317
  sectionFetch.merge
310
318
  )
311
319
  }
@@ -32,7 +32,7 @@ import yaml from 'js-yaml'
32
32
  import { collectSectionAssets, mergeAssetCollections, collectConfigAssets } from './assets.js'
33
33
  import { collectSectionIcons, mergeIconCollections, buildIconManifest } from './icons.js'
34
34
  import { normalizeHideIn, dropUnpublishedPages } from './nav-visibility.js'
35
- import { parseFetchConfig } from './data-fetcher.js'
35
+ import { parseFetchConfig, toFetchList } from './data-fetcher.js'
36
36
  import { resolveExtensionUrls } from './extension-urls.js'
37
37
  import { buildTheme, extractFoundationVars } from '../theme/index.js'
38
38
  import { resolveDefaultLocale, resolvePublishableLocales, validateLanguageConfig } from '@uniweb/core'
@@ -127,6 +127,28 @@ function detectVersions(folderNames) {
127
127
  return versions
128
128
  }
129
129
 
130
+ /**
131
+ * Desugar a `data:` declaration into a `fetch:` one.
132
+ *
133
+ * `data: team` → `{ query: 'team' }`; `data: [team, articles]` → one config per
134
+ * entry. ⭐ **A list means "fetch each"** — see `parseFetchConfig` for why the
135
+ * declaration is plural by necessity and why that is not a statement about
136
+ * request count.
137
+ *
138
+ * ⛔ Before 2026-09-02 a list kept `[0]` and dropped the rest **silently**: no
139
+ * warning, no error, and the array was not carried forward on the section, so
140
+ * nothing downstream could recover it. An author writing a list got one dataset
141
+ * and a section rendering empty.
142
+ *
143
+ * @param {string|Array<string>|undefined} data
144
+ * @returns {Object|Array<Object>|undefined}
145
+ */
146
+ function fetchFromDataShorthand(data) {
147
+ if (!data) return undefined
148
+ if (Array.isArray(data)) return data.map((query) => ({ query }))
149
+ return { query: data }
150
+ }
151
+
130
152
  /**
131
153
  * Build version metadata from detected versions and page.yml config
132
154
  * @param {Array<Object>} detectedVersions - Detected version infos
@@ -860,14 +882,28 @@ async function processMarkdownFile(filePath, id, siteRoot, defaultStableId = nul
860
882
  // Extract @ component references → insets (mutates doc)
861
883
  const insets = extractInsets(proseMirrorContent)
862
884
 
863
- // Support 'data:' shorthand for collection fetch
864
- // data: team → fetch: { query: team }
865
- // data: [team, articles] fetch: { query: team } (first item, others via inheritData)
866
- let resolvedFetch = fetch
867
- if (!fetch && data) {
868
- const queryName = Array.isArray(data) ? data[0] : data
869
- resolvedFetch = { query: queryName }
870
- }
885
+ // `data:` shorthand — `data: team` `fetch: { query: team }`.
886
+ //
887
+ // **A LIST KEEPS ONLY `[0]`. THE REST ARE INERT.** This comment used to say
888
+ // *"others via inheritData"*, and that is wrong in a way worth spelling out,
889
+ // because it read as a mechanism and was copied into a second doc as one.
890
+ //
891
+ // `inheritData` is a BOOLEAN OPT-OUT on a section's `meta.js`
892
+ // (`meta.inheritData === false` → deliver nothing). It never consumed list
893
+ // elements. What actually happens is that delivery is default-on and
894
+ // collect-all: `EntityStore._getRequestedSchemas` returns `[]`, so a section
895
+ // receives EVERY fetch config in the section → page → site cascade whether or
896
+ // not it named any of them.
897
+ //
898
+ // ⇒ So `articles` arrives only if something ELSE already declared a fetch for
899
+ // it — in which case this section would have received it anyway. **Naming it
900
+ // here contributes nothing.** Measured 2026-09-02: `data: [team, articles]`
901
+ // with no other declaration yields exactly one config, `team`, and the array
902
+ // is not carried forward on the section, so nothing downstream can recover it.
903
+ //
904
+ // ⚠️ The list form appears in no `docs/` page, so nothing promises it works.
905
+ // Whether it should mean "fetch each" or be refused outright is undecided.
906
+ const resolvedFetch = fetch || fetchFromDataShorthand(data)
871
907
 
872
908
  // Stable ID for scroll targeting: frontmatter id > filename-derived > null
873
909
  // This ID is stable across reordering (unlike the positional id)
@@ -1378,11 +1414,22 @@ async function processPage(pagePath, pageName, siteRoot, { isIndex = false, pare
1378
1414
  // Layout panel visibility (from object form of layout config)
1379
1415
  const layoutObj = typeof layoutConfig === 'object' && layoutConfig !== null ? layoutConfig : {}
1380
1416
 
1381
- // For dynamic routes, determine the parent's data schema
1382
- // This tells prerender which data array to iterate over
1417
+ // For dynamic routes, determine the parent's data schema — this tells
1418
+ // prerender which data array to iterate over.
1419
+ //
1420
+ // ⚖️ **A `[slug]` template expands over exactly ONE record set**, so a plural
1421
+ // parent declaration has to resolve to one query here. The first is taken,
1422
+ // matching what prerender records in `pageFetchedData`; the two must agree or
1423
+ // expansion iterates a set the route was not built from.
1424
+ //
1425
+ // ⛔ This is a genuine cardinality constraint, not a limit worth lifting: a
1426
+ // route pattern names one variable, and "which collection does `:slug` index"
1427
+ // has no second answer. A page that needs another dataset alongside its
1428
+ // dynamic one still declares it — plurality is what makes that sayable.
1383
1429
  let parentSchema = null
1384
1430
  if (isDynamic && parentFetch) {
1385
- parentSchema = parentFetch.schema
1431
+ const [first] = toFetchList(parentFetch)
1432
+ parentSchema = first ? first.as : null
1386
1433
  }
1387
1434
 
1388
1435
  return {
@@ -1450,9 +1497,7 @@ async function processPage(pagePath, pageName, siteRoot, { isIndex = false, pare
1450
1497
  // Support 'data:' shorthand at page level
1451
1498
  // data: team → fetch: { query: team }
1452
1499
  fetch: parseFetchConfig(
1453
- pageConfig.fetch || (pageConfig.data
1454
- ? { query: Array.isArray(pageConfig.data) ? pageConfig.data[0] : pageConfig.data }
1455
- : undefined)
1500
+ pageConfig.fetch || fetchFromDataShorthand(pageConfig.data)
1456
1501
  ),
1457
1502
 
1458
1503
  hasContent: hierarchicalSections.length > 0,
@@ -251,7 +251,7 @@ export function applyPostProcessing(data, config) {
251
251
  // not understand, but we can refuse to pretend it was never there. Reported
252
252
  // once per key name per process so a 200-record build does not print 200 lines.
253
253
  const RECOGNIZED_FETCH_KEYS = {
254
- refine: new Set(['refine', 'inherit', 'detail', 'limit', 'sort', 'where', 'filter']),
254
+ refine: new Set(['refine', 'detail', 'limit', 'sort', 'where', 'filter']),
255
255
  query: new Set([
256
256
  'query', 'as', 'schema', 'prerender', 'merge', 'transform',
257
257
  'where', 'limit', 'sort', 'detailPage', 'filter',
@@ -283,18 +283,68 @@ export function _resetUnknownFetchKeyWarnings() {
283
283
  warnedUnknownFetchKeys.clear()
284
284
  }
285
285
 
286
+ /**
287
+ * Normalize a parsed `fetch` to a list. **Use this at every consumption point.**
288
+ *
289
+ * `parseFetchConfig` returns an object for one declaration and an array for
290
+ * several, so `cfg.path` on a multi-fetch page reads `undefined` rather than
291
+ * throwing — the silent-empty class. Reaching for this instead of a property is
292
+ * what keeps that from happening.
293
+ *
294
+ * @param {Object|Array|null} fetch - a PARSED fetch (post-`parseFetchConfig`).
295
+ * @returns {Array<Object>} zero, one, or many configs.
296
+ */
297
+ export function toFetchList(fetch) {
298
+ if (!fetch) return []
299
+ return Array.isArray(fetch) ? fetch : [fetch]
300
+ }
301
+
302
+ /**
303
+ * Parse a `fetch:` (or desugared `data:`) declaration.
304
+ *
305
+ * ⭐ **A LIST MEANS "FETCH EACH".** `data: [team, articles]` declares two needs
306
+ * and they land under two keys in `content.data` — a component reads
307
+ * `content.data.team` and `content.data.articles` independently, so the
308
+ * declaration is plural by necessity.
309
+ *
310
+ * ⚖️ **Plural DECLARATIONS are not plural REQUESTS.** How many round trips this
311
+ * becomes belongs to the fetcher: `EntityStore` already assembles every config
312
+ * before dispatching any of them and awaits them together, which is exactly
313
+ * where a batching source would coalesce. Nothing here should encode a
314
+ * transport assumption — the file lane genuinely has two artifacts, and most
315
+ * sources cannot batch at all.
316
+ *
317
+ * ⛔ **A one-entry list collapses to an object, deliberately.** The returned
318
+ * shape reflects the cardinality of the RESULT, not of the input syntax, so
319
+ * every declaration that resolves to a single fetch is byte-identical to what
320
+ * this emitted before — the array shape appears only where content could not
321
+ * previously have worked. (Before 2026-09-02 a list kept `[0]` and discarded the
322
+ * rest silently, so the only content whose shape changes is content that was
323
+ * already broken.)
324
+ *
325
+ * @param {string|Object|Array|null} fetch
326
+ * @returns {Object|Array<Object>|null}
327
+ */
286
328
  export function parseFetchConfig(fetch) {
287
329
  if (!fetch) return null
288
330
 
331
+ if (Array.isArray(fetch)) {
332
+ const parsed = fetch.map((f) => parseFetchConfig(f)).filter(Boolean)
333
+ // Flatten: a nested array is not a meaningful authoring shape, and letting
334
+ // one through would put an array inside an array where every consumer
335
+ // expects configs.
336
+ const flat = parsed.flat()
337
+ if (flat.length === 0) return null
338
+ return flat.length === 1 ? flat[0] : flat
339
+ }
340
+
289
341
  // Simple string: "/data/team.json"
290
342
  if (typeof fetch === 'string') {
291
343
  const inferred = inferSchemaFromPath(fetch)
292
344
  return {
293
345
  path: fetch,
294
346
  url: undefined,
295
- // Both spellings — see the note in the named-query branch below.
296
347
  as: inferred,
297
- schema: inferred,
298
348
  prerender: true,
299
349
  merge: false,
300
350
  transform: undefined,
@@ -304,24 +354,30 @@ export function parseFetchConfig(fetch) {
304
354
  // Full config object
305
355
  if (typeof fetch !== 'object') return null
306
356
 
357
+ // ⛔ THE RETIRED ALIAS IS AN ERROR, NOT A WARNING — same reasoning as
358
+ // `collection:` below. `inherit: true` was the earlier spelling of
359
+ // `refine: true`, accepted with a warning from April 2026 and removed on
360
+ // 2026-09-02. Ignored, `{ inherit: true, limit: 3 }` would fall through to
361
+ // the source shape, find neither `path` nor `url`, and resolve to null — a
362
+ // silently empty block.
363
+ if (fetch.inherit !== undefined) {
364
+ throw new Error(
365
+ '[uniweb] fetch: `inherit: true` is retired. Write `refine: true` — the same ' +
366
+ 'per-instance refinement of the ancestor fetch, under its current name.'
367
+ )
368
+ }
369
+
307
370
  // Refine config: { refine: true, detail: false, limit: 3 }
308
371
  // No URL — merges with the parent fetch config at runtime; only carries
309
- // override props. The legacy spelling `inherit: true` is accepted for one
310
- // release with a warning, then removed.
372
+ // override props.
311
373
  //
312
374
  // Note on build-vs-runtime scope: this parser passes `sort` and `filter`
313
375
  // through on refine configs, but the runtime EntityStore only applies
314
376
  // `detail`, `limit`, and `order` overrides. `sort` / `filter` on a refine
315
377
  // block are currently accepted by the parser but not honored at runtime.
316
378
  // Preserved as-is in this rename commit; revisit separately if needed.
317
- if (fetch.refine === true || fetch.inherit === true) {
379
+ if (fetch.refine === true) {
318
380
  warnUnknownFetchKeys(fetch, 'refine')
319
- if (fetch.inherit === true && fetch.refine !== true) {
320
- console.warn(
321
- "[uniweb] 'fetch: { inherit: true }' is deprecated; rename to 'fetch: { refine: true }'. " +
322
- 'Accepted for one release; will be removed in the next minor.'
323
- )
324
- }
325
381
  if (fetch.filter !== undefined) warnFilterDeprecated()
326
382
  return {
327
383
  refine: true,
@@ -370,27 +426,15 @@ export function parseFetchConfig(fetch) {
370
426
  query: fetch.query,
371
427
  path: queryDataUrl(fetch.query),
372
428
  url: undefined,
373
- // ⭐ **`as`, not `schema`.** The BINDING KEY — the `content.data.<key>` a
374
- // component reads — defaults to the query name. It was called `schema`
375
- // until 2026-09-02, which collided with the MODEL REF of the same name on a
376
- // `queries` declaration; `fetch.schema` is still accepted as input so
377
- // existing content keeps working, and is never emitted.
378
- // **BOTH SPELLINGS ARE EMITTED, and this is not the overload coming back.**
379
- // `bindingKey()` reads `as ?? schema`, so NEW core needs only `as`. But a
380
- // published site renders at ITS OWN pinned runtime version, and every
381
- // runtime shipped to date bundles a core whose resolver does
382
- // `if (!cfg?.schema) continue` — so an `as`-only payload served by an older
383
- // runtime is SKIPPED ENTIRELY. No data, nothing thrown, nothing logged.
384
- //
385
- // ⭐ The compatibility runs the OTHER WAY from `bindingKey`'s: that one is
386
- // old payloads meeting new code, this one is new payloads meeting old code,
387
- // and only the first was covered. Caught by frontend before it shipped.
388
- //
389
- // ⇒ Emit both until every serving runtime carries `bindingKey`, then drop
390
- // `schema` here. Until then a reader may see this as redundant; it is a
391
- // compatibility duplicate and the comment is what tells them apart.
429
+ // ⭐ **`as` is the BINDING KEY** — the `content.data.<key>` a component
430
+ // reads — defaulting to the query name. It was called `schema` until
431
+ // 2026-09-02, which collided with the MODEL REF of the same name on a
432
+ // `queries` declaration. `fetch.schema` is still accepted as INPUT, so
433
+ // content authored before the rename keeps working; it is never emitted.
434
+ // `fetch.schema` here is an AUTHOR's older spelling in a content file
435
+ // the same boundary normalization as the source-shape branch below, not
436
+ // the internal alias (removed 2026-09-02). One name travels inside.
392
437
  as: fetch.as || fetch.schema || fetch.query,
393
- schema: fetch.as || fetch.schema || fetch.query,
394
438
  prerender: fetch.prerender ?? true,
395
439
  merge: fetch.merge ?? false,
396
440
  transform: fetch.transform,
@@ -433,9 +477,12 @@ export function parseFetchConfig(fetch) {
433
477
  return {
434
478
  path,
435
479
  url,
436
- // Both spellings see the note in the named-query branch above.
480
+ // **The one place `schema` is still read, and it is a BOUNDARY
481
+ // normalization — not the internal alias.** An author may have written
482
+ // `fetch: { schema: person }` before the 2026-09-02 rename; that is content
483
+ // on someone's disk, not a field our own packages pass to each other.
484
+ // Translating it here means one name — `as` — everywhere inside.
437
485
  as: as ?? schema ?? inferSchemaFromPath(path || url),
438
- schema: as ?? schema ?? inferSchemaFromPath(path || url),
439
486
  prerender,
440
487
  merge,
441
488
  transform,
@@ -611,7 +658,7 @@ export function mergeDataIntoContent(content, fetchedData, schema, merge = false
611
658
  *
612
659
  * @param {object[]} configs - Array of normalized fetch configs
613
660
  * @param {object} options - Execution options (same as executeFetch)
614
- * @returns {Promise<Map<string, any>>} Map of schema -> data
661
+ * @returns {Promise<Map<string, any>>} Map of binding key (`as`) -> data
615
662
  */
616
663
  export async function executeMultipleFetches(configs, options = {}) {
617
664
  if (!configs || configs.length === 0) {
@@ -621,7 +668,7 @@ export async function executeMultipleFetches(configs, options = {}) {
621
668
  const results = await Promise.all(
622
669
  configs.map(async (config) => {
623
670
  const result = await executeFetch(config, options)
624
- return { schema: config.schema, data: result.data }
671
+ return { schema: config.as, data: result.data }
625
672
  })
626
673
  )
627
674
 
@@ -3,7 +3,7 @@
3
3
  // ⛔ WHY THIS EXISTS. A `fetch:` declaration has three shapes, and the keys each one
4
4
  // accepts differ (`data-fetcher.js` RECOGNIZED_FETCH_KEYS):
5
5
  //
6
- // refine refine · inherit · detail · limit · sort · where · filter
6
+ // refine refine · detail · limit · sort · where · filter
7
7
  // query query · schema · … — and NOT `path`/`url`
8
8
  // source path · url · schema · …
9
9
 
@@ -39,7 +39,7 @@
39
39
  /** Which of the three shapes a declaration is — the same order `data-fetcher` uses. */
40
40
  export function fetchShapeOf(fetch) {
41
41
  if (!fetch || typeof fetch !== 'object') return null
42
- if (fetch.refine === true || fetch.inherit === true) return 'refine'
42
+ if (fetch.refine === true) return 'refine'
43
43
  if (fetch.query) return 'query'
44
44
  return 'source'
45
45
  }
@@ -51,7 +51,7 @@ import { processAssets, rewriteSiteContentPaths } from './asset-processor.js'
51
51
  import { processAdvancedAssets } from './advanced-processors.js'
52
52
  import { processQueries, writeQueryFiles } from './query-processor.js'
53
53
  import { ENTITIES_DIR } from './entity-pool.js'
54
- import { executeFetch, mergeDataIntoContent } from './data-fetcher.js'
54
+ import { executeFetch, mergeDataIntoContent, toFetchList } from './data-fetcher.js'
55
55
  import { shouldSplitContent } from './split-content.js'
56
56
  import { FONT_LINKS_MARKER } from './head-markers.js'
57
57
  import { collectExtensionAssets } from './emit-extensions.js'
@@ -137,9 +137,9 @@ async function executeDevFetches(siteContent, siteDir) {
137
137
  const fetchOptions = { siteRoot: siteDir, publicDir: 'public' }
138
138
  const fetchedData = []
139
139
 
140
- // Site-level fetch
141
- const siteFetch = siteContent.config?.fetch
142
- if (shouldPrefetchInDev(siteFetch)) {
140
+ // Site-level fetch — every declaration.
141
+ for (const siteFetch of toFetchList(siteContent.config?.fetch)) {
142
+ if (!shouldPrefetchInDev(siteFetch)) continue
143
143
  const result = await executeFetch(siteFetch, fetchOptions)
144
144
  if (result.data && !result.error) {
145
145
  fetchedData.push({ config: siteFetch, data: result.data })
@@ -148,9 +148,9 @@ async function executeDevFetches(siteContent, siteDir) {
148
148
 
149
149
  // Process each page
150
150
  for (const page of siteContent.pages || []) {
151
- // Page-level fetch
152
- const pageFetch = page.fetch
153
- if (shouldPrefetchInDev(pageFetch)) {
151
+ // Page-level fetch — every declaration.
152
+ for (const pageFetch of toFetchList(page.fetch)) {
153
+ if (!shouldPrefetchInDev(pageFetch)) continue
154
154
  const result = await executeFetch(pageFetch, fetchOptions)
155
155
  if (result.data && !result.error) {
156
156
  fetchedData.push({ config: pageFetch, data: result.data })
@@ -176,17 +176,16 @@ async function processDevSectionFetches(sections, fetchOptions) {
176
176
  if (!sections || !Array.isArray(sections)) return
177
177
 
178
178
  for (const section of sections) {
179
- // Execute section-level fetch
180
- const sectionFetch = section.fetch
181
- if (shouldPrefetchInDev(sectionFetch)) {
179
+ // Execute every section-level fetch, so dev shows what prerender will.
180
+ for (const sectionFetch of toFetchList(section.fetch)) {
181
+ if (!shouldPrefetchInDev(sectionFetch)) continue
182
182
  const result = await executeFetch(sectionFetch, fetchOptions)
183
183
  if (result.data && !result.error) {
184
- // Merge fetched data into section's parsedContent (not cascadedData)
185
- // This matches prerender behavior - section's own fetch goes to content.data
184
+ // A section's own fetch goes to content.data, matching prerender.
186
185
  section.parsedContent = mergeDataIntoContent(
187
186
  section.parsedContent || {},
188
187
  result.data,
189
- sectionFetch.schema,
188
+ sectionFetch.as,
190
189
  sectionFetch.merge
191
190
  )
192
191
  }
package/src/uwx/site.js CHANGED
@@ -220,10 +220,13 @@ function buildPageData(config, ctx) {
220
220
  setIf(data, 'rewrite', config.rewrite)
221
221
  setIf(data, 'layout', config.layout)
222
222
  setIf(data, 'seo', config.seo)
223
+ // ⭐ A `data:` LIST means "fetch each" — one declaration per entry. Before
224
+ // 2026-09-02 this kept `[0]` and dropped the rest silently, so the wire
225
+ // carried one dataset for a page that asked for several.
223
226
  let fetch =
224
227
  config.fetch ??
225
228
  (config.data
226
- ? { query: Array.isArray(config.data) ? config.data[0] : config.data }
229
+ ? (Array.isArray(config.data) ? config.data.map((query) => ({ query })) : { query: config.data })
227
230
  : undefined)
228
231
  // Resolve the authored `query:` shorthand to the runtime-fetchable
229
232
  // `path: /data/<name>.json` (the static convention the default-fetcher uses).
@@ -232,8 +235,14 @@ function buildPageData(config, ctx) {
232
235
  // it would never resolve at render (the static build resolves it the same way
233
236
  // in site/data-fetcher.js parseFetchConfig). The gateway serves the collection
234
237
  // at `<base>/data/<name>.json`.
235
- if (fetch && typeof fetch.query === 'string') {
236
- const { query, ...rest } = fetch
238
+ // **Mapped, not read.** A `data:`/`fetch:` LIST reaches here as an array, and
239
+ // `fetch.query` on one is `undefined` — so a property test would skip the
240
+ // resolution below and put bare `{ query }` entries on the wire with no
241
+ // `path`, no `as` and no `schema`. That is the silent-empty class: a payload
242
+ // that arrives, parses, and resolves to nothing.
243
+ const resolveWireFetch = (one) => {
244
+ if (!one || typeof one.query !== 'string') return one
245
+ const { query, ...rest } = one
237
246
  // ⭐ BOTH, deliberately, and they are not redundant.
238
247
  //
239
248
  // `query` — the author's named query, unresolved. A consumer that can ask
@@ -249,9 +258,9 @@ function buildPageData(config, ctx) {
249
258
  // `path` once it has resolved an address — matching parseFetchConfig, which
250
259
  // has always returned early on the shorthand.
251
260
  //
252
- // `schema` (the query name) is BOTH the content.data key and part of the
253
- // dataStore cache key (deriveCacheKey hashes {path,url,endpoint,schema,…};
254
- // the shorthand is ignored). Mirrors the static build's parseFetchConfig —
261
+ // `as` (the query name) is BOTH the content.data key and part of the
262
+ // dataStore cache key (deriveCacheKey hashes {path,url,endpoint,as,…}; the
263
+ // shorthand is ignored). Mirrors the static build's parseFetchConfig —
255
264
  // ⚠️ which it did NOT until 2026-09-02. This line emitted `query` and that
256
265
  // one did not, for the same declaration, so `resolveQuerySource` fired on a
257
266
  // published site and never on a `--link`-deployed one: same site, two verbs,
@@ -263,15 +272,22 @@ function buildPageData(config, ctx) {
263
272
  // validating — so `fetch` is a blob they carry and framework owns its
264
273
  // vocabulary. ⇒ There was nothing to coordinate, and inventing a coordination
265
274
  // is how a name stays wrong.
275
+ // ⚠️ Owning the vocabulary is not the same as nobody reading it. A backend
276
+ // that re-implements the render payload for a lane with no build step reads
277
+ // ONE key out of this blob — `as`, to derive which record set a dynamic route
278
+ // iterates — and that read went silently empty when the key moved on
279
+ // 2026-09-02. So `fetch.as` is on the coupled surface even though `fetch`
280
+ // is carried: a consumer that interprets a name is a reader of it, however
281
+ // little of the object it looks at.
266
282
  // ⛔ `as`, the BINDING KEY — not to be confused with `schema` at
267
283
  // DECL_EMITTED_ABOVE / `setIf(data,'schema',d.schema)` below, which is a
268
284
  // queries decl's MODEL REF and keeps its name. One word meant both until
269
- // 2026-09-02; this is the half that moved.
270
- // Both spellings, deliberately an `as`-only payload is skipped entirely by
271
- // any runtime older than `bindingKey` (`if (!cfg?.schema) continue`), with no
272
- // data and no error. Drop `schema` when every serving runtime carries it.
273
- fetch = { query, path: queryDataUrl(query), as: query, schema: query, ...rest }
285
+ // 2026-09-02; this is the half that moved, and the compatibility duplicate
286
+ // that briefly rode alongside it was dropped 2026-09-02 once frontend and
287
+ // hosting had dropped theirs. A site synced before that must be re-pushed.
288
+ return { query, path: queryDataUrl(query), as: query, ...rest }
274
289
  }
290
+ fetch = Array.isArray(fetch) ? fetch.map(resolveWireFetch) : resolveWireFetch(fetch)
275
291
  setIf(data, 'fetch', fetch)
276
292
  if (isDynamic) {
277
293
  data.is_dynamic = true
@@ -37,6 +37,7 @@ import { validateAndNormalizeSchema } from './resolve-data-schema.js'
37
37
  // reach it here) even though the implementation moved next to the vocabulary.
38
38
  export { validateItem, isStaticallyCheckable } from '@uniweb/schemas/conform'
39
39
  import { buildSchema } from './schema.js'
40
+ import { toFetchList } from './site/data-fetcher.js'
40
41
  import { resolveFoundationSrcPath } from './utils/foundation-source-root.js'
41
42
  import { collectSiteContent } from './site/content-collector.js'
42
43
  import { processQueries } from './site/query-processor.js'
@@ -94,6 +95,10 @@ export async function validateDataInputs({ siteRoot, foundationPath }) {
94
95
  byQuery = await processQueries(siteRoot, config.queries, config.paths?.entities, basePath)
95
96
  }
96
97
 
98
+ // Declared here rather than beside pass 2's other accumulators because pass 1
99
+ // now writes to it as well — see THE JOIN, RUN THE OTHER WAY below.
100
+ const setupErrors = []
101
+
97
102
  // Pass 1 — discover unique (file, schema-ref) pairs and who uses each.
98
103
  const work = new Map() // pairKey -> { path, ref, schema, users: [{ route, section, key }] }
99
104
  const deferred = []
@@ -103,8 +108,47 @@ export async function validateDataInputs({ siteRoot, foundationPath }) {
103
108
  const type = section.type
104
109
  if (!type) return
105
110
  const bindings = foundation[type]?.data
106
- for (const input of collectInputs(section, page.fetch, config.fetch)) {
107
- const key = input.as ?? input.schema // the content.data KEY; `schema` is its pre-2026-09-02 name
111
+ const inputs = collectInputs(section, page.fetch, config.fetch)
112
+
113
+ // ⭐ **THE JOIN, RUN THE OTHER WAY: data arrived, but under no name this
114
+ // section reads.** Everything below asks "for each input, is there a
115
+ // binding?". This asks "for each binding, was anything delivered?" — and
116
+ // the answer was silence until 2026-09-02.
117
+ //
118
+ // A section reads `content.data.<key>` for the keys its `meta.js` `data:`
119
+ // declares. When the page delivers a query under a DIFFERENT name, the
120
+ // section renders its heading and nothing else: no error, no warning, HTTP
121
+ // 200, a clean console. Reported by `flows`, measured in a real browser —
122
+ // two records-backed sections carrying 8 and 6 characters of text beside
123
+ // static ones carrying 182/572/289/529/99.
124
+ //
125
+ // ⚖️ **Narrow on purpose: only when SOMETHING was delivered.** `data:` in
126
+ // `meta.js` is a hint rather than a delivery gate (`docs/reference/
127
+ // data-fetching.md`), so a section declaring keys on a page with no data at
128
+ // all is ordinary and silent. What is not ordinary is a page that fetched
129
+ // something and a section on it that reads none of it — there the author
130
+ // demonstrably intended data to arrive and the names did not meet.
131
+ const declaredKeys = bindings ? Object.keys(bindings) : []
132
+ if (declaredKeys.length > 0 && inputs.length > 0) {
133
+ const delivered = inputs.map((i) => i.as).filter(Boolean)
134
+ if (delivered.length > 0 && !declaredKeys.some((k) => delivered.includes(k))) {
135
+ setupErrors.push({
136
+ file: `${page.route || '/'} · ${type}`,
137
+ message:
138
+ `section reads ${declaredKeys.map((k) => `content.data.${k}`).join(' or ')}, ` +
139
+ `but this page delivers ${delivered.map((k) => `\`${k}\``).join(', ')}. ` +
140
+ `The section will render with no data and nothing else will say so. ` +
141
+ `Name the query for the key the section reads, or give the section its own ` +
142
+ `\`fetch: { query: <name> }\`.`,
143
+ // One user per declared key, so `uniweb validate` can print
144
+ // `used by /team › Team › data.team` — the key is the thing to rename.
145
+ users: declaredKeys.map((k) => ({ route: page.route, section: type, key: k })),
146
+ })
147
+ }
148
+ }
149
+
150
+ for (const input of inputs) {
151
+ const key = input.as // the content.data KEY
108
152
 
109
153
  if (input.url) {
110
154
  deferred.push({ route: page.route, section: type, key, reason: 'remote url: source', url: input.url })
@@ -137,7 +181,6 @@ export async function validateDataInputs({ siteRoot, foundationPath }) {
137
181
 
138
182
  // Pass 2 — validate each unique pair ONCE, attribute findings to its users.
139
183
  const violations = []
140
- const setupErrors = []
141
184
  const schemasSeen = new Set()
142
185
  let recordCount = 0
143
186
 
@@ -436,15 +479,19 @@ async function loadStandardSchemas() {
436
479
  */
437
480
  function collectInputs(section, pageFetch, siteFetch) {
438
481
  const byKey = new Map()
439
- for (const f of [siteFetch, pageFetch, section.fetch]) {
440
- // The binding key is `as`; `schema` is its pre-2026-09-02 name and still
441
- // arrives on stored payloads. A gate on the old name alone silently yields
442
- // NOTHING here no inputs collected, no violations found, a green run — which
443
- // is exactly how this was caught: the integration test went from flagging the
444
- // seeded violations to flagging none.
445
- const key = f?.as ?? f?.schema
446
- if (f && (f.path || f.url) && typeof key === 'string') {
447
- byKey.set(key, f)
482
+ // Each level may declare SEVERAL — `data: [team, articles]` — so each is
483
+ // flattened rather than read. Order is least- to most-specific and `set`
484
+ // overwrites, which is what makes a section's declaration win the key.
485
+ for (const source of [siteFetch, pageFetch, section.fetch]) {
486
+ for (const f of toFetchList(source)) {
487
+ // Gate on the BINDING KEY. A gate on the wrong name silently yields
488
+ // NOTHING here no inputs collected, no violations found, a green run —
489
+ // which is exactly how the rename was caught: the integration test went
490
+ // from flagging the seeded violations to flagging none.
491
+ const key = f?.as
492
+ if (f && (f.path || f.url) && typeof key === 'string') {
493
+ byKey.set(key, f)
494
+ }
448
495
  }
449
496
  }
450
497
  return [...byKey.values()]