@uniweb/build 0.45.0 → 0.47.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.45.0",
3
+ "version": "0.47.0",
4
4
  "description": "Build tooling for the Uniweb Component Web Platform",
5
5
  "type": "module",
6
6
  "exports": {
@@ -57,15 +57,15 @@
57
57
  "js-yaml": "^4.1.0",
58
58
  "sharp": "^0.35.3",
59
59
  "yaml": "^2.5.0",
60
+ "@uniweb/semantic-parser": "^1.4.0",
60
61
  "@uniweb/content-reader": "^1.2.4",
62
+ "@uniweb/theming": "^0.1.15",
63
+ "@uniweb/projections": "^0.6.2",
61
64
  "@uniweb/content-writer": "^0.3.4",
62
- "@uniweb/projections": "^0.6.1",
63
- "@uniweb/schemas": "^0.2.13",
64
- "@uniweb/semantic-parser": "^1.4.0",
65
- "@uniweb/theming": "^0.1.15"
65
+ "@uniweb/schemas": "^0.2.13"
66
66
  },
67
67
  "optionalDependencies": {
68
- "@uniweb/runtime": "^0.20.0"
68
+ "@uniweb/runtime": "^0.20.3"
69
69
  },
70
70
  "peerDependencies": {
71
71
  "vite": "^5.0.0 || ^6.0.0 || ^7.0.0",
@@ -74,7 +74,7 @@
74
74
  "@tailwindcss/vite": "^4.0.0",
75
75
  "@vitejs/plugin-react": "^4.0.0 || ^5.0.0",
76
76
  "vite-plugin-svgr": "^4.0.0",
77
- "@uniweb/core": "^0.25.0"
77
+ "@uniweb/core": "^0.26.1"
78
78
  },
79
79
  "peerDependenciesMeta": {
80
80
  "vite": {
package/src/prerender.js CHANGED
@@ -17,7 +17,7 @@ import {
17
17
  joinPathCapture,
18
18
  routeQuery,
19
19
  sectionFetches,
20
- routeParamValue,
20
+ routeParamValues,
21
21
  routeParamName,
22
22
  routeBinding,
23
23
  parentRouteOf,
@@ -129,7 +129,7 @@ export async function executeAllFetches(siteContent, siteDir, onProgress, locale
129
129
  const fetched = { site: new Map(), pages: new Map(), sections: new Map() }
130
130
 
131
131
  // 1. Site-level fetch. ⛔ `toFetchList` rather than a property read: a `fetch:`
132
- // or `data:` LIST parses to an array, and `siteFetch.prerender` on one is
132
+ // or `query:` LIST parses to an array, and `siteFetch.prerender` on one is
133
133
  // `undefined` — which passes the `!== false` test and then fetches nothing.
134
134
  for (const oneFetch of toFetchList(siteContent.config?.fetch)) {
135
135
  if (oneFetch.prerender === false) continue
@@ -324,62 +324,81 @@ export function expandDynamicPages(pages, fetched, onProgress = () => {}, stats
324
324
  // records and names three gets three pages and no idea why. The total is
325
325
  // said once at the end, and handed back on `stats` for a caller to assert.
326
326
  let unrouted = 0
327
+ // route → the value that claimed it, so a second claim is reported, not silent
328
+ const claimed = new Map()
327
329
 
328
330
  // Create a concrete page for each item
329
331
  for (const item of items) {
330
- // The value the record carries for the route's param read through the one
331
- // map (`routeParamValue`): `[slug]` its handle, `[uuid]` its identity, any
332
- // other name its field. ⛔ This read `item[paramName]` until 2026-09-11.
333
- const raw = routeParamValue(item, paramName)
334
- if (raw === undefined || raw === null || raw === '') {
332
+ // EVERY value the record answers to — one for a scalar, one per member for a
333
+ // `multi` field (`routeParamValues`, the map every lane matches through:
334
+ // `[slug]` its handle, `[uuid]` its identity, any other name its field). A
335
+ // record holding `['a','b']` gets /tags/a AND /tags/b; a `multi` holding one
336
+ // value the case the rule is for gets exactly one page. Ruled 2026-09-12
337
+ // [Diego]. ⛔ This read `item[paramName]` until 2026-09-11 and the whole array
338
+ // until 2026-09-12, which baked `/tags/a%2Cb`, a URL no lane matches.
339
+ const values = routeParamValues(item, paramName)
340
+ if (values.length === 0) {
335
341
  unrouted += 1
336
342
  continue
337
343
  }
338
- const paramValue = String(raw)
339
-
340
- // Create concrete route: /blog/:slug → /blog/my-post. Under `[...path]` the
341
- // record's URL is its placement (the folder `records.yml` put it in, carried
342
- // as `path`) plus its handle — the split rule in reverse. ⛔ A FILE PATH, so
343
- // decoded: the server decodes the request before looking the file up.
344
- const capture = catchAll ? joinPathCapture({ dir: item.path, slug: paramValue }) : null
345
- const concreteRoute = catchAll
346
- ? page.route.replace(new RegExp(`:${catchAll}\\*$`), capture)
347
- : page.route.replace(`:${paramName}`, paramValue)
348
-
349
- // Static sibling wins: skip a record whose concrete route collides with
350
- // an existing static page rather than overwriting its HTML at write time.
351
- if (staticRoutes.has(concreteRoute)) {
352
- onProgress(` Skipping ${concreteRoute}a static page already claims this route (${paramName}:'${paramValue}')`)
353
- continue
354
- }
344
+ for (const paramValue of values) {
345
+
346
+ // Create concrete route: /blog/:slug → /blog/my-post. Under `[...path]` the
347
+ // record's URL is its placement (the folder `records.yml` put it in, carried
348
+ // as `path`) plus its handle — the split rule in reverse. ⛔ A FILE PATH, so
349
+ // decoded: the server decodes the request before looking the file up.
350
+ const capture = catchAll ? joinPathCapture({ dir: item.path, slug: paramValue }) : null
351
+ const concreteRoute = catchAll
352
+ ? page.route.replace(new RegExp(`:${catchAll}\\*$`), capture)
353
+ : page.route.replace(`:${paramName}`, paramValue)
354
+
355
+ // TWO RECORDS, ONE ROUTE normal the moment the route field is not
356
+ // unique, which a `multi` member shared by two records makes easy. The first
357
+ // wins; WHICH is first is this lane's record order, and a hosted site orders
358
+ // by its own store so it is said out loud here rather than discovered as a
359
+ // different record on the same URL.
360
+ const claimant = claimed.get(concreteRoute)
361
+ if (claimant !== undefined) {
362
+ onProgress(` ⚠️ ${concreteRoute} is claimed by more than one ${key} record (${paramName}: '${claimant}', '${paramValue}') — the first keeps it`)
363
+ continue
364
+ }
365
+ claimed.set(concreteRoute, paramValue)
355
366
 
356
- // Deep clone the page with modifications
357
- const concretePage = JSON.parse(JSON.stringify(page))
358
- concretePage.route = concreteRoute
359
- concretePage.isDynamic = false // No longer dynamic
360
- concretePage.paramName = undefined
361
-
362
- // The route's binding, as the SPA makes it (`routeBinding`): the three
363
- // variables a query binds, the param and its value, and the template's
364
- // route. No `schema`: the key the URL narrows is worked out where it is
365
- // read (deleted 2026-09-11). The record (`currentItem`) and the full sibling
366
- // list (`allItems`) are deliberately NOT baked in: the record is delivered
367
- // via content.data and siblings via `fetch: { refine: true, detail: false }`,
368
- // and embedding `allItems` duplicated the whole collection onto every
369
- // prerendered page in split mode.
370
- const binding = routeBinding(page.route, catchAll ? { [catchAll]: capture } : { [paramName]: paramValue }, paramName)
371
- concretePage.dynamicContext = {
372
- templateRoute: page.route,
373
- params: binding.variables,
374
- paramName: binding.paramName,
375
- paramValue: binding.paramValue,
376
- }
367
+ // Static sibling wins: skip a record whose concrete route collides with
368
+ // an existing static page rather than overwriting its HTML at write time.
369
+ if (staticRoutes.has(concreteRoute)) {
370
+ onProgress(` Skipping ${concreteRoute} a static page already claims this route (${paramName}:'${paramValue}')`)
371
+ continue
372
+ }
373
+
374
+ // Deep clone the page with modifications
375
+ const concretePage = JSON.parse(JSON.stringify(page))
376
+ concretePage.route = concreteRoute
377
+ concretePage.isDynamic = false // No longer dynamic
378
+ concretePage.paramName = undefined
379
+
380
+ // The route's binding, as the SPA makes it (`routeBinding`): the three
381
+ // variables a query binds, the param and its value, and the template's
382
+ // route. No `schema`: the key the URL narrows is worked out where it is
383
+ // read (deleted 2026-09-11). The record (`currentItem`) and the full sibling
384
+ // list (`allItems`) are deliberately NOT baked in: the record is delivered
385
+ // via content.data and siblings via `fetch: { refine: true, detail: false }`,
386
+ // and embedding `allItems` duplicated the whole collection onto every
387
+ // prerendered page in split mode.
388
+ const binding = routeBinding(page.route, catchAll ? { [catchAll]: capture } : { [paramName]: paramValue }, paramName)
389
+ concretePage.dynamicContext = {
390
+ templateRoute: page.route,
391
+ params: binding.variables,
392
+ paramName: binding.paramName,
393
+ paramValue: binding.paramValue,
394
+ }
377
395
 
378
- // Use item data for page metadata if available
379
- if (item.title) concretePage.title = item.title
380
- if (item.description || item.excerpt) concretePage.description = item.description || item.excerpt
396
+ // Use item data for page metadata if available
397
+ if (item.title) concretePage.title = item.title
398
+ if (item.description || item.excerpt) concretePage.description = item.description || item.excerpt
381
399
 
382
- expandedPages.push(concretePage)
400
+ expandedPages.push(concretePage)
401
+ }
383
402
  }
384
403
 
385
404
  if (unrouted > 0) {
@@ -523,10 +542,41 @@ export function scopeFetchedData(fetchedData, scopeRoutes, currentRoute = null)
523
542
  // either mode: carried everywhere, a site of N such pages would embed N views —
524
543
  // or N whole records — in every page.
525
544
  const own = (e) => !e._routeBound || e._scope === currentRoute
526
- if (!scopeRoutes) return fetchedData.filter(own).map(stripFetchScope)
527
- return fetchedData
528
- .filter((e) => own(e) && (e._scope === '__site__' || scopeRoutes.has(e._scope)))
529
- .map(stripFetchScope)
545
+ const kept = scopeRoutes
546
+ ? fetchedData.filter((e) => own(e) && (e._scope === '__site__' || scopeRoutes.has(e._scope)))
547
+ : fetchedData.filter(own)
548
+ return dedupeByAddress(kept).map(stripFetchScope)
549
+ }
550
+
551
+ /**
552
+ * One entry per ADDRESS in a page's embedded data — first occurrence wins.
553
+ *
554
+ * ⛔ Entries are collected per PAGE (`executeAllFetches` tags each with the route
555
+ * that asked for it), so a query several pages declare produced one entry per page
556
+ * and unsplit mode embedded all of them in every page. Measured 2026-09-12 on a
557
+ * four-page site whose pages share two queries: 8 entries of which 2 were distinct,
558
+ * and **46% of the HTML was the duplicates**.
559
+ *
560
+ * ⭐ Keyed by `deriveCacheKey`, which is what the SPA looks each entry up under
561
+ * (`hydrateDataStore`) — so two entries with one key are the same answer to the
562
+ * same question by construction, and dropping the later ones cannot change what any
563
+ * page reads. A page's own route-bound view has its own address and survives.
564
+ *
565
+ * @param {Array<{config: Object}>} entries
566
+ * @returns {Array<Object>} the same entries, minus repeats of an address
567
+ */
568
+ function dedupeByAddress(entries) {
569
+ const seen = new Set()
570
+ const out = []
571
+ for (const entry of entries) {
572
+ const key = entry?.config ? deriveCacheKey(entry.config) : null
573
+ if (key !== null) {
574
+ if (seen.has(key)) continue
575
+ seen.add(key)
576
+ }
577
+ out.push(entry)
578
+ }
579
+ return out
530
580
  }
531
581
 
532
582
  /**
@@ -1047,7 +1097,7 @@ export async function prerenderSite(siteDir, options = {}) {
1047
1097
  // data — but the internal `_scope` tag must never leak into it, and a
1048
1098
  // route-bound entry belongs to its own page's HTML, not to every page.
1049
1099
  if (Array.isArray(manifest.fetchedData)) {
1050
- manifest.fetchedData = manifest.fetchedData.filter((e) => !e?._routeBound).map(stripFetchScope)
1100
+ manifest.fetchedData = dedupeByAddress(manifest.fetchedData.filter((e) => !e?._routeBound)).map(stripFetchScope)
1051
1101
  }
1052
1102
  await writeFile(localeContentPath, JSON.stringify(manifest))
1053
1103
  onProgress('Rewrote site-content.json as lightweight manifest')
@@ -24,7 +24,7 @@
24
24
  import { readFile, readdir, stat } from 'node:fs/promises'
25
25
  import { resolveQueriesConfig, toConfigQueries } from './queries-config.js'
26
26
  import { parseNumericPrefix, compareByNumericPrefix } from '../utils/numeric-prefix.js'
27
- import { join, parse, resolve, sep } from 'node:path'
27
+ import { join, parse, relative, resolve, sep } from 'node:path'
28
28
  import { existsSync, statSync, realpathSync, readdirSync } from 'node:fs'
29
29
  import yaml from 'js-yaml'
30
30
  import { collectSectionAssets, mergeAssetCollections, collectConfigAssets } from './assets.js'
@@ -220,27 +220,86 @@ function detectVersions(folderNames) {
220
220
  }
221
221
 
222
222
  /**
223
- * Desugar a `data:` declaration into a `fetch:` one.
224
- *
225
- * `data: team` `{ query: 'team' }`; `data: [team, articles]` one config per
226
- * entry. **A list means "fetch each"** — see `parseFetchConfig` for why the
227
- * declaration is plural by necessity and why that is not a statement about
228
- * request count.
223
+ * `query:` the shorthand for `fetch: { query }`, at every level that declares
224
+ * data: a section's frontmatter, `page.yml`, `folder.yml` and `site.yml`, on the
225
+ * build and on the sync push alike. It names a query, or a list of them — "fetch
226
+ * each", one `content.data` key per name (see `parseFetchConfig` for why a
227
+ * declaration is plural and why that says nothing about request count). Anything
228
+ * richer — a `limit`, a `where`, a source — is `fetch:`.
229
+ *
230
+ * ⛔ `data:` WAS THIS KEY until 2026-09-11 [Diego], and it is REFUSED, not
231
+ * ignored: in frontmatter an unreserved key becomes a section param, so a stale
232
+ * `data:` would render an empty section and say nothing. The word stays where it
233
+ * names the data itself — a section type's `meta.js` `data:` (the shape of each
234
+ * `content.data` key, which fetches nothing) and `content.data`.
235
+ *
236
+ * ⛔ `query:` beside `fetch:` is refused too. It was silent — `fetch:` won, and
237
+ * the other declaration was dropped with nothing saying so.
238
+ *
239
+ * @param {Object|null|undefined} config - the level's authored config
240
+ * @param {string} where - the file it was authored in, for the message
241
+ * @returns {Object|Array<Object>|undefined} the level's `fetch:` declaration, as authored
242
+ */
243
+ export function declaredFetch(config, where) {
244
+ checkDeclaration(config, where)
245
+ return config?.fetch ?? fetchFromQueryShorthand(config?.query)
246
+ }
247
+
248
+ /**
249
+ * The refusals `declaredFetch` makes, alone — for a reader that must keep the
250
+ * desugaring inline (the sync push's `settings.fetch`, whose sources
251
+ * `scripts/gen-emit-surface.mjs` reads off the expression).
229
252
  *
230
- * Before 2026-09-02 a list kept `[0]` and dropped the rest **silently**: no
231
- * warning, no error, and the array was not carried forward on the section, so
232
- * nothing downstream could recover it. An author writing a list got one dataset
233
- * and a section rendering empty.
253
+ * @param {Object|null|undefined} config
254
+ * @param {string} where
255
+ */
256
+ export function checkDeclaration(config, where) {
257
+ if (!config || typeof config !== 'object') return
258
+ if (config.data !== undefined) {
259
+ const other = config.query !== undefined ? 'query' : config.fetch !== undefined ? 'fetch' : null
260
+ throw new Error(
261
+ other
262
+ ? `[uniweb] ${where}: \`data:\` is retired, and this file already declares \`${other}:\` — delete the \`data:\` line.`
263
+ : `[uniweb] ${where}: \`data:\` is retired — the shorthand for \`fetch: { query }\` is now \`query:\`. ` +
264
+ `Write \`query: ${formatQueryNames(config.data)}\`.`
265
+ )
266
+ }
267
+ if (config.query !== undefined && config.fetch !== undefined) {
268
+ throw new Error(
269
+ `[uniweb] ${where}: declare \`query:\` or \`fetch:\`, not both — \`query: x\` is the shorthand for \`fetch: { query: x }\`.`
270
+ )
271
+ }
272
+ if (config.query !== undefined && !isQueryNames(config.query)) {
273
+ const declaring = config.query && typeof config.query === 'object' && !Array.isArray(config.query)
274
+ throw new Error(
275
+ `[uniweb] ${where}: \`query:\` takes a query name or a list of names; anything richer is \`fetch:\` — ` +
276
+ `e.g. \`fetch: { query: articles, limit: 3 }\`.` +
277
+ (declaring ? ' Declaring queries? That is `queries:` (in site.yml) or queries.yml.' : '')
278
+ )
279
+ }
280
+ }
281
+
282
+ /**
283
+ * Desugar a `query:` value: `team` → `{ query: 'team' }`, `[team, articles]` →
284
+ * one config per name. ⛔ Before 2026-09-02 a list kept `[0]` and dropped the rest
285
+ * silently — an author writing a list got one dataset and a section rendering
286
+ * empty.
234
287
  *
235
- * @param {string|Array<string>|undefined} data
288
+ * @param {string|Array<string>|undefined|null} query - already checked (`checkDeclaration`)
236
289
  * @returns {Object|Array<Object>|undefined}
237
290
  */
238
- export function fetchFromDataShorthand(data) {
239
- if (!data) return undefined
240
- if (Array.isArray(data)) return data.map((query) => ({ query }))
241
- return { query: data }
291
+ export function fetchFromQueryShorthand(query) {
292
+ if (query === undefined || query === null) return undefined
293
+ if (Array.isArray(query)) return query.map((name) => ({ query: name }))
294
+ return { query }
242
295
  }
243
296
 
297
+ const isQueryName = (name) => typeof name === 'string' && name.trim() !== ''
298
+ const isQueryNames = (value) =>
299
+ isQueryName(value) || (Array.isArray(value) && value.length > 0 && value.every(isQueryName))
300
+ const formatQueryNames = (value) =>
301
+ isQueryNames(value) ? (Array.isArray(value) ? `[${value.join(', ')}]` : value) : '<name>'
302
+
244
303
  /**
245
304
  * Build version metadata from detected versions and page.yml config
246
305
  * @param {Array<Object>} detectedVersions - Detected version infos
@@ -280,7 +339,7 @@ function buildVersionMetadata(detectedVersions, pageConfig = {}) {
280
339
  * this function reads `site.yml`, `page.yml`, `folder.yml`, `theme.yml` and
281
340
  * every section's frontmatter — i.e. every configuration surface an author
282
341
  * writes. So a single typo silently discarded that file's entire contribution:
283
- * page order, nesting, `sections:`, `data:` declarations, theme. **The build
342
+ * page order, nesting, `sections:`, `query:` declarations, theme. **The build
284
343
  * succeeded and shipped a site missing what the author asked for**, with one
285
344
  * line on stderr that named no file.
286
345
  *
@@ -965,7 +1024,9 @@ async function processMarkdownFile(filePath, id, siteRoot, defaultStableId = nul
965
1024
  console.warn(`[content-collector] ${err.message}`)
966
1025
  }
967
1026
 
968
- const { type, preset, input, props, fetch, data, id: frontmatterId, ...params } = frontMatter
1027
+ // `query`, `fetch` and `data` are never params: `query:` / `fetch:` declare the
1028
+ // section's own data, and a leftover `data:` is refused (`declaredFetch`).
1029
+ const { type, preset, input, props, fetch, query, data, id: frontmatterId, ...params } = frontMatter
969
1030
 
970
1031
  // Convert markdown to ProseMirror
971
1032
  const proseMirrorContent = markdownToProseMirror(markdown)
@@ -973,28 +1034,13 @@ async function processMarkdownFile(filePath, id, siteRoot, defaultStableId = nul
973
1034
  // Extract @ component references → insets (mutates doc)
974
1035
  const insets = extractInsets(proseMirrorContent)
975
1036
 
976
- // `data:` shorthand — `data: team` → `fetch: { query: team }`.
977
- //
978
- // **A LIST KEEPS ONLY `[0]`. THE REST ARE INERT.** This comment used to say
979
- // *"others via inheritData"*, and that is wrong in a way worth spelling out,
980
- // because it read as a mechanism and was copied into a second doc as one.
981
- //
982
- // `inheritData` is a BOOLEAN OPT-OUT on a section's `meta.js`
983
- // (`meta.inheritData === false` → deliver nothing). It never consumed list
984
- // elements. What actually happens is that delivery is default-on and
985
- // collect-all: `EntityStore._getRequestedSchemas` returns `[]`, so a section
986
- // receives EVERY fetch config in the section → page → site cascade whether or
987
- // not it named any of them.
988
- //
989
- // ⇒ So `articles` arrives only if something ELSE already declared a fetch for
990
- // it — in which case this section would have received it anyway. **Naming it
991
- // here contributes nothing.** Measured 2026-09-02: `data: [team, articles]`
992
- // with no other declaration yields exactly one config, `team`, and the array
993
- // is not carried forward on the section, so nothing downstream can recover it.
994
- //
995
- // ⚠️ The list form appears in no `docs/` page, so nothing promises it works.
996
- // Whether it should mean "fetch each" or be refused outright is undecided.
997
- const resolvedFetch = fetch || fetchFromDataShorthand(data)
1037
+ // `query: team` → `fetch: { query: team }`; a list, one config per name, each
1038
+ // delivered under its own `content.data` key — the one helper every level uses.
1039
+ // Unrelated to a section type's `meta.js` `data:`, which declares the SHAPE of
1040
+ // each `content.data` key and fetches nothing: delivery is default-on, so a
1041
+ // section receives every fetch in the section page site cascade whether or
1042
+ // not it names one here.
1043
+ const resolvedFetch = declaredFetch({ fetch, query, data }, relative(siteRoot, filePath))
998
1044
 
999
1045
  // Stable ID for scroll targeting: frontmatter id > filename-derived > null
1000
1046
  // This ID is stable across reordering (unlike the positional id)
@@ -1619,12 +1665,8 @@ async function processPage(pagePath, pageName, siteRoot, { isIndex = false, pare
1619
1665
  priority: seo.priority || null
1620
1666
  },
1621
1667
 
1622
- // Data fetching
1623
- // Support 'data:' shorthand at page level
1624
- // data: team → fetch: { query: team }
1625
- fetch: parseFetchConfig(
1626
- pageConfig.fetch || fetchFromDataShorthand(pageConfig.data)
1627
- ),
1668
+ // Data fetching — `fetch:`, or the `query:` shorthand (`declaredFetch`)
1669
+ fetch: parseFetchConfig(declaredFetch(pageConfig, relative(siteRoot, join(pagePath, 'page.yml')))),
1628
1670
 
1629
1671
  hasContent: hierarchicalSections.length > 0,
1630
1672
  sections: hierarchicalSections
@@ -1928,9 +1970,15 @@ async function collectPagesRecursive(dirPath, parentRoute, siteRoot, orderConfig
1928
1970
  assetCollection = mergeAssetCollections(assetCollection, pageAssets)
1929
1971
  iconCollection = mergeIconCollections(iconCollection, pageIcons)
1930
1972
 
1931
- // Modern pattern: blog/index/ (isIndex) inherits the container's fetch config
1932
- // when it has no fetch of its own. Without this, EntityStore can't find the
1933
- // fetch config for sections on the index page (page.parent is null for /blog).
1973
+ // A ROOT-PROMOTED folder inherits the container's fetch config when it has
1974
+ // no fetch of its own. Without this, EntityStore cannot find the fetch config
1975
+ // for that page's sections, because promotion leaves it with no parent.
1976
+ //
1977
+ // ⛔ **Root only, and the example this comment used to give was impossible.**
1978
+ // It read "blog/index/ (isIndex)" — a NESTED index folder — but `indexName` is
1979
+ // assigned only under `parentRoute === '/'` (see above), so `entry === indexName`
1980
+ // is false at every deeper level and a nested `index/` folder is an ordinary page.
1981
+ // Measured 2026-09-12: `pages/docs/index/` collects as `/docs/index`, isIndex=false.
1934
1982
  if (isIndex && !page.fetch && parentFetch) {
1935
1983
  page.fetch = parentFetch
1936
1984
  }
@@ -1987,7 +2035,10 @@ async function collectPagesRecursive(dirPath, parentRoute, siteRoot, orderConfig
1987
2035
  changefreq: dirConfig.seo?.changefreq || null,
1988
2036
  priority: dirConfig.seo?.priority || null
1989
2037
  },
1990
- fetch: parseFetchConfig(dirConfig.fetch) || null,
2038
+ // ⭐ `declaredFetch`, as every other level: this read `dirConfig.fetch`
2039
+ // alone until 2026-09-11, so a container's `folder.yml` shorthand reached
2040
+ // a backend on `push` and was dropped from a static build.
2041
+ fetch: parseFetchConfig(declaredFetch(dirConfig, relative(siteRoot, join(entryPath, 'folder.yml')))) || null,
1991
2042
  hasContent: false,
1992
2043
  sections: [],
1993
2044
  order: typeof dirConfig.order === 'number' ? dirConfig.order : undefined
@@ -2079,7 +2130,12 @@ async function collectPagesRecursive(dirPath, parentRoute, siteRoot, orderConfig
2079
2130
  changefreq: dirConfig.seo?.changefreq || null,
2080
2131
  priority: dirConfig.seo?.priority || null
2081
2132
  },
2082
- fetch: null,
2133
+ // ⭐ The folder's own declaration, as the folder-mode container above and
2134
+ // every other level read it. ⛔ This was `fetch: null` until 2026-09-11:
2135
+ // a `folder.yml` here lost its `fetch:` (and its shorthand) on a static
2136
+ // build while `push` carried it, so its pages had the folder's data on a
2137
+ // hosted site and none on an exported one.
2138
+ fetch: parseFetchConfig(declaredFetch(dirConfig, relative(siteRoot, join(entryPath, 'folder.yml')))) || null,
2083
2139
  hasContent: false,
2084
2140
  sections: [],
2085
2141
  order: typeof dirConfig.order === 'number' ? dirConfig.order : undefined
@@ -2091,8 +2147,11 @@ async function collectPagesRecursive(dirPath, parentRoute, siteRoot, orderConfig
2091
2147
  pages.push(containerPage)
2092
2148
  }
2093
2149
 
2150
+ // The container's own fetch config, or the parent's — as the folder-mode
2151
+ // container above passes it.
2094
2152
  const childDirPath = mounts?.get(entry) || entryPath
2095
- const subResult = await collectPagesRecursive(childDirPath, containerRoute, siteRoot, childOrderConfig, parentFetch, versionContext, 'pages', null, effectiveLayout)
2153
+ const containerFetch = containerPage.fetch || parentFetch
2154
+ const subResult = await collectPagesRecursive(childDirPath, containerRoute, siteRoot, childOrderConfig, containerFetch, versionContext, 'pages', null, effectiveLayout)
2096
2155
  pages.push(...subResult.pages)
2097
2156
  assetCollection = mergeAssetCollections(assetCollection, subResult.assetCollection)
2098
2157
  iconCollection = mergeIconCollections(iconCollection, subResult.iconCollection)
@@ -2656,7 +2715,9 @@ export async function collectSiteContent(sitePath, options = {}) {
2656
2715
  // `publishLanguages` is authoring/publish intent — it has no runtime
2657
2716
  // consumer and never ships in a payload (the visitor runtime is
2658
2717
  // list-unaware; the sync lane reads site.yml directly, not this output).
2659
- const { publishLanguages: _publishLanguages, ...runtimeSiteConfig } = siteConfig
2718
+ // The `query:` shorthand ships as `config.fetch`, desugared below; carried raw
2719
+ // it would sit beside `config.queries`, the declarations, and read as one.
2720
+ const { publishLanguages: _publishLanguages, query: _query, ...runtimeSiteConfig } = siteConfig
2660
2721
  // ⛔ `$`-prefixed keys are the project's BACKEND-SCOPED state — `$uuid`, `$org`,
2661
2722
  // `$backend`, `$services`, `$secrets` — and this payload is a PUBLISHED artifact
2662
2723
  // that a visitor can fetch. They have no runtime reader (nothing in core, runtime
@@ -2700,7 +2761,7 @@ export async function collectSiteContent(sitePath, options = {}) {
2700
2761
  `${distinctFailures.length} file${distinctFailures.length === 1 ? '' : 's'} could not be parsed as YAML:\n` +
2701
2762
  `${lines.join('\n')}\n\n` +
2702
2763
  ` Each one contributed NOTHING to this build — page order, nesting,\n` +
2703
- ` sections:, data: and theme settings in these files were dropped.\n` +
2764
+ ` sections:, query: and theme settings in these files were dropped.\n` +
2704
2765
  ` Fix them and rebuild; the dev server reports the same files without failing.`
2705
2766
  )
2706
2767
  }
@@ -2720,13 +2781,12 @@ export async function collectSiteContent(sitePath, options = {}) {
2720
2781
  ...(publishFilterActive && Array.isArray(siteConfig.languages)
2721
2782
  ? { languages: publishable }
2722
2783
  : {}),
2723
- // ⛔ `data:` IS THE SHORTHAND FOR `fetch:` AND BOTH LANES MUST READ IT.
2784
+ // ⛔ `query:` IS THE SHORTHAND FOR `fetch:` AND BOTH LANES MUST READ IT.
2724
2785
  // This read `siteConfig.fetch` alone until 2026-09-09, so a site-level
2725
- // `data: articles` reached a backend on the sync lane and was silently
2726
- // ignored on a static build — the works-on-one-lane shape. The page level
2727
- // has always used this helper (`pageConfig.fetch || fetchFromDataShorthand(…)`);
2728
- // the site level simply never did.
2729
- fetch: parseFetchConfig(siteConfig.fetch || fetchFromDataShorthand(siteConfig.data)),
2786
+ // shorthand reached a backend on the sync lane and was silently ignored on
2787
+ // a static build — the works-on-one-lane shape. Every level reads it
2788
+ // through `declaredFetch` now.
2789
+ fetch: parseFetchConfig(declaredFetch(siteConfig, 'site.yml')),
2730
2790
  fetcher: warnRetiredFetcherKeys(siteConfig.fetcher),
2731
2791
  // NOTE: `intelligence.yml` was read here and emitted as `config.intelligence`.
2732
2792
  // Removed 2026-08-12 — the assistant surface is `site.yml::assistant`, which
@@ -2751,7 +2811,19 @@ export async function collectSiteContent(sitePath, options = {}) {
2751
2811
  pages: dropUnpublished ? dropUnpublishedPages(pages) : pages,
2752
2812
  // Layout area sets: { default: { header: page, footer: page, ... }, marketing: { ... } }
2753
2813
  layouts,
2754
- notFound,
2814
+ // ⭐ THE SAME REACHABILITY AXIS, for the 404 slot. `hidden: true` means DRAFT —
2815
+ // "excluded from the published site" (`docs/reference/page-configuration.md`);
2816
+ // `hideIn: ['*']` is the control for "routed but in no nav".
2817
+ // ⛔ The 404 was exempt BY ACCIDENT until 2026-09-12: it is lifted out of
2818
+ // `pages` before the prune runs, and the prune only filters `pages`, so the
2819
+ // flag never reached it — we published a page its author had marked as not
2820
+ // for publishing, while a backend-published site dropped it. Ruled
2821
+ // [Diego, 2026-09-12]: the flag is literal and framework moves to the lane
2822
+ // that honoured it. The site still gets a `404.html` (the SPA fallback
2823
+ // shell), and `uniweb dev` keeps the page previewable like any other draft.
2824
+ // No cascade to resolve: this slot is root-level only, so its own flag is
2825
+ // the only one that can apply.
2826
+ notFound: dropUnpublished && notFound?.hidden ? null : notFound,
2755
2827
  // Versioned scopes: route → { versions, latestId }
2756
2828
  versionedScopes: versionedScopesObj,
2757
2829
  assets: assetCollection.assets,
@@ -274,9 +274,9 @@ export function toFetchList(fetch) {
274
274
  }
275
275
 
276
276
  /**
277
- * Parse a `fetch:` (or desugared `data:`) declaration.
277
+ * Parse a `fetch:` (or desugared `query:`) declaration.
278
278
  *
279
- * ⭐ **A LIST MEANS "FETCH EACH".** `data: [team, articles]` declares two needs
279
+ * ⭐ **A LIST MEANS "FETCH EACH".** `query: [team, articles]` declares two needs
280
280
  * and they land under two keys in `content.data` — a component reads
281
281
  * `content.data.team` and `content.data.articles` independently, so the
282
282
  * declaration is plural by necessity.
@@ -69,3 +69,57 @@ export function authorableFetch(fetch) {
69
69
  }
70
70
  return out
71
71
  }
72
+
73
+ // ── `query:` or `fetch:` — the key a projection writes back ─────────────────────
74
+ //
75
+ // An author declares a level's data with `fetch:` or with its shorthand, `query:`
76
+ // (`query: team` ≡ `fetch: { query: team }`). The wire carries only the desugared
77
+ // form, so it cannot say which key was typed — and a pull must give back the
78
+ // authored KEY as well as the value (the round-trip law of the sync format). So a
79
+ // projection writes back the key the file already uses, and a file it creates gets
80
+ // `query:` whenever the declaration is nothing but query names — the form the
81
+ // docs teach. The declaration keys are one group: exactly one is written, and a
82
+ // file holding both is refused by the build.
83
+
84
+ /** The keys that declare a level's data: the long form, its shorthand, and the retired shorthand. */
85
+ export const DECLARATION_KEYS = Object.freeze(['query', 'fetch', 'data'])
86
+
87
+ /**
88
+ * The query names a declaration consists of, when that is ALL it says — what
89
+ * `query:` can express. Keys the build derives or defaults beside a query (`path`,
90
+ * `url`, `as` equal to the name, `prerender: true`, `merge: false`) say nothing an
91
+ * author wrote; any other key makes it a `fetch:`.
92
+ *
93
+ * @param {object|object[]} fetch - a declaration (or a list) off the wire
94
+ * @returns {string|string[]|null} the name(s), or null when `query:` cannot say it
95
+ */
96
+ export function queryNamesOf(fetch) {
97
+ const nameOf = (one) => {
98
+ if (!one || typeof one !== 'object' || typeof one.query !== 'string' || one.query === '') return null
99
+ for (const [key, value] of Object.entries(one)) {
100
+ if (key === 'query' || key === 'path' || key === 'url') continue
101
+ if (key === 'as' && value === one.query) continue
102
+ if (key === 'prerender' && value === true) continue
103
+ if (key === 'merge' && value === false) continue
104
+ return null
105
+ }
106
+ return one.query
107
+ }
108
+ if (!Array.isArray(fetch)) return nameOf(fetch)
109
+ const names = fetch.map(nameOf)
110
+ return names.length > 0 && names.every((n) => n !== null) ? names : null
111
+ }
112
+
113
+ /**
114
+ * The declaration to write back, and under which key.
115
+ *
116
+ * @param {object|object[]} wireFetch - the level's `fetch` off the wire
117
+ * @param {object|null} [existing] - the authored file's current keys; null for a new file
118
+ * @returns {{ key: 'query'|'fetch', value: string|string[]|object|object[] }}
119
+ */
120
+ export function authorableDeclaration(wireFetch, existing = null) {
121
+ const fetch = Array.isArray(wireFetch) ? wireFetch.map((one) => authorableFetch(one)) : authorableFetch(wireFetch)
122
+ const names = queryNamesOf(fetch)
123
+ const typedFetch = !!existing && typeof existing === 'object' && existing.fetch !== undefined
124
+ return names !== null && !typedFetch ? { key: 'query', value: names } : { key: 'fetch', value: fetch }
125
+ }
@@ -147,7 +147,7 @@ function parseQueryConfig(name, config) {
147
147
  },
148
148
  // `deferred:` lists fields that are heavy (article body, full nested
149
149
  // arrays). Those fields are stripped from the cascade payload that
150
- // ships with `data: <name>` declarations, and per-record full files
150
+ // ships with `query: <name>` declarations, and per-record full files
151
151
  // are emitted at public/data/<name>/<slug>.json. Components that
152
152
  // need the full record fetch the per-record file on demand, either
153
153
  // automatically on dynamic-route pages (entity-store routes the
@@ -913,7 +913,7 @@ export async function writeQueryFiles(siteDir, byQuery, queriesConfig = null) {
913
913
  if (deferred && deferred.length > 0) {
914
914
  // `deferred:` is set — emit two payloads:
915
915
  // 1. The cascade JSON at /data/<name>.json with deferred fields stripped.
916
- // This is what `data: <name>` declarations deliver everywhere.
916
+ // This is what `query: <name>` declarations deliver everywhere.
917
917
  // 2. Per-record full files at /data/<name>/<slug>.json with every field.
918
918
  // Dynamic-route singular fetches and useEntityDetail hooks read these.
919
919
  const recordsDir = join(dataDir, name)
@@ -62,7 +62,7 @@
62
62
  ],
63
63
  "fetch": [
64
64
  "site.yml::fetch",
65
- "site.yml::data"
65
+ "site.yml::query"
66
66
  ],
67
67
  "fetcher": [
68
68
  "site.yml::fetcher"
@@ -19,15 +19,22 @@ import { parseFrontmatter } from './entity-source.js'
19
19
  import { renderEntityDocument } from './backfill.js'
20
20
  import { queriesYmlPath } from './queries-config.js'
21
21
  import { recordsYmlPath } from '../site/records-config.js'
22
+ import { DECLARATION_KEYS } from '../site/fetch-shapes.js'
22
23
 
23
24
  // Frontmatter keys that belong to the CCA framework / the developer's local
24
25
  // authoring, not to externally-editable params. On a section write an existing
25
26
  // reserved key is preserved and never overwritten by incoming params, so a
26
27
  // projection doesn't churn fields it didn't author (the surgical-update bar).
28
+ //
29
+ // ⭐ The declaration keys — `query`, `fetch`, and the retired `data` — are one
30
+ // group: a section that declares its data locally, under any of them, keeps that
31
+ // declaration, and an incoming one under a different key does not land beside it
32
+ // (a file holding two is refused by the build).
27
33
  export const DEFAULT_RESERVED_FRONTMATTER = new Set([
28
34
  'type',
29
35
  'preset',
30
36
  'input',
37
+ 'query',
31
38
  'fetch',
32
39
  'data',
33
40
  'nest',
@@ -168,6 +175,7 @@ export function writeSectionFile({ filePath, content, params, reserved = DEFAULT
168
175
  const { frontmatter, body: existingBody } = parseFrontmatter(existing, filePath)
169
176
 
170
177
  const nextFrontmatter = { ...frontmatter }
178
+ const declaresLocally = DECLARATION_KEYS.some((k) => k in frontmatter)
171
179
  if (params) {
172
180
  for (const [key, value] of Object.entries(params)) {
173
181
  // A reserved key is preserved only when it already exists locally (the
@@ -175,6 +183,7 @@ export function writeSectionFile({ filePath, content, params, reserved = DEFAULT
175
183
  // there is nothing to protect, so the incoming value fills it — that's
176
184
  // how a newly-projected section gets its `type`/`nest`/etc.
177
185
  if (reserved.has(key) && key in frontmatter) continue
186
+ if (reserved.has(key) && DECLARATION_KEYS.includes(key) && declaresLocally) continue
178
187
  if (value === null || value === undefined) delete nextFrontmatter[key]
179
188
  else nextFrontmatter[key] = value
180
189
  }
@@ -186,11 +195,12 @@ export function writeSectionFile({ filePath, content, params, reserved = DEFAULT
186
195
 
187
196
  // Shallow-merge `changes` into a YAML config file and write idempotently. A key
188
197
  // whose value is null/undefined is deleted; an object value is shallow-merged one
189
- // level deep (so partial `theme` / `build` updates don't drop sibling keys); any
190
- // other value replaces. NOTE: this re-dumps the file, so author comments/order are
191
- // not preserved — acceptable for machine-owned config, but comment-preserving
192
- // merges for hand-authored config files are a quality bar to revisit.
193
- function mergeYamlConfig(filePath, changes) {
198
+ // level deep (so partial `theme` / `build` updates don't drop sibling keys) unless
199
+ // the key is in `replace`; any other value replaces. NOTE: this re-dumps the file,
200
+ // so author comments/order are not preserved — acceptable for machine-owned
201
+ // config, but comment-preserving merges for hand-authored config files are a
202
+ // quality bar to revisit.
203
+ function mergeYamlConfig(filePath, changes, { replace = [] } = {}) {
194
204
  let existing = {}
195
205
  try {
196
206
  existing = yaml.load(readFileSync(filePath, 'utf8')) || {}
@@ -200,7 +210,7 @@ function mergeYamlConfig(filePath, changes) {
200
210
  for (const [key, value] of Object.entries(changes)) {
201
211
  if (value === null || value === undefined) {
202
212
  delete existing[key]
203
- } else if (typeof value === 'object' && !Array.isArray(value)) {
213
+ } else if (typeof value === 'object' && !Array.isArray(value) && !replace.includes(key)) {
204
214
  existing[key] = { ...(existing[key] || {}), ...value }
205
215
  } else {
206
216
  existing[key] = value
@@ -212,10 +222,15 @@ function mergeYamlConfig(filePath, changes) {
212
222
  /**
213
223
  * Merge `config` into `site.yml` (shallow). Preserves keys not present in the
214
224
  * update (foundation, base, paths, …).
225
+ *
226
+ * ⛔ The declaration keys (`query` / `fetch` / `data`) are written WHOLE, never
227
+ * merged: a declaration is one value, and merging an incoming `fetch:` into the
228
+ * local one kept whatever key the remote no longer has — a stale `limit` survived
229
+ * every pull.
215
230
  * @returns {'updated'|'unchanged'}
216
231
  */
217
232
  export function writeSiteConfig(siteRoot, config) {
218
- return mergeYamlConfig(join(siteRoot, 'site.yml'), config)
233
+ return mergeYamlConfig(join(siteRoot, 'site.yml'), config, { replace: DECLARATION_KEYS })
219
234
  }
220
235
 
221
236
  /**
@@ -39,7 +39,7 @@ import { createHash } from 'node:crypto'
39
39
  import yaml from 'js-yaml'
40
40
  import { writeSiteConfig, writeThemeFile, writeIfChanged, writeSectionFile, writeMergedYaml } from './project-writer.js'
41
41
  import { declarationsToQueriesYml } from './records-project.js'
42
- import { authorableFetch } from '../site/fetch-shapes.js'
42
+ import { authorableDeclaration, DECLARATION_KEYS } from '../site/fetch-shapes.js'
43
43
  import { createTranslationCollector, writeLocaleTranslations, writeFreeformTranslations, unwrapLocalizedContent } from './locale-sync.js'
44
44
  import { buildFreeformPath } from '../i18n/freeform.js'
45
45
  import { unwrapLocalized, unwrapLocalizedList } from './backfill.js'
@@ -138,8 +138,8 @@ const INFO_TO_SITE_YML = {
138
138
  // Authored-only, like `submit` and `assistant`: a host's tracking endpoint is
139
139
  // offered through `config.services.tracking` and resolved at render, so it
140
140
  // never enters `info` and a pull cannot launder it into authored config.
141
- // ⛔ `data` IS NOT VERBATIM — see the explicit branch below. It projects to
142
- // `site.yml::fetch`, not `site.yml::data`.
141
+ // ⛔ The site's declaration is not verbatim — see the explicit `settings.fetch`
142
+ // branch below: it projects to `site.yml::query` or `site.yml::fetch`.
143
143
  template: 'template',
144
144
  // ⭐ `tags` — authored, non-localized tokens; the filter facet for a list of site
145
145
  // cards. Round-trips verbatim like any authored list.
@@ -199,6 +199,16 @@ const SETTINGS_TO_SITE_YML = {
199
199
  }
200
200
 
201
201
 
202
+ /** An authored YAML config as it stands, or null when there is none to read. */
203
+ function readAuthoredYaml(filePath) {
204
+ try {
205
+ const value = yaml.load(readFileSync(filePath, 'utf8'))
206
+ return value && typeof value === 'object' && !Array.isArray(value) ? value : null
207
+ } catch {
208
+ return null
209
+ }
210
+ }
211
+
202
212
  /**
203
213
  * Project a site-content document's `info` (+ `extensions`) onto the site's
204
214
  * config files: `site.yml`, `theme.yml`, and `head.html`. Idempotent; only the
@@ -238,22 +248,21 @@ export function siteInfoToConfig({ document, siteRoot, sourceLocale = LOCALIZED_
238
248
  if (info[infoKey] !== undefined) siteChanges[ymlKey] = info[infoKey]
239
249
  }
240
250
 
241
- // ⭐ `settings.fetch` → `site.yml::fetch`.
242
- //
243
- // `data:` is the authoring SHORTHAND for `fetch:` and the wire carries the
244
- // desugared form, so `fetch:` is the key that describes what came back. The page
245
- // lane has always projected this way (`y.fetch = authorableFetch(record.fetch)`);
246
- // the site lane wrote `data:` verbatim, so an author who typed `fetch:` pushed,
247
- // pulled, and got a `data:` block back the value survived and the authored key
248
- // did not, which the round-trip law forbids (uwx-format.md).
251
+ // ⭐ `settings.fetch` → `site.yml::query` or `site.yml::fetch` — the key the file
252
+ // already uses, else `query:` when the declaration is nothing but query names
253
+ // (`authorableDeclaration`). The wire carries the desugared form and cannot say
254
+ // which was typed, and the round-trip law keeps the authored KEY as well as the
255
+ // value (the sync format's round-trip law): the site lane once wrote the shorthand back verbatim,
256
+ // so an author who typed `fetch:` pushed, pulled, and got the shorthand back.
257
+ // The other declaration keys are removed, so the file never holds two (the
258
+ // build refuses that) a retired `data:` included.
249
259
  //
250
260
  // ⛔ The producer always desugars, so the wire carries a config or a list of them —
251
261
  // never a bare string. Nothing here accommodates an older shape.
252
262
  const wireFetch = settingsSection.fetch
253
263
  if (wireFetch !== undefined) {
254
- siteChanges.fetch = Array.isArray(wireFetch)
255
- ? wireFetch.map((f) => authorableFetch(f))
256
- : authorableFetch(wireFetch)
264
+ const { key, value } = authorableDeclaration(wireFetch, readAuthoredYaml(join(siteRoot, 'site.yml')))
265
+ for (const k of DECLARATION_KEYS) siteChanges[k] = k === key ? value : null
257
266
  }
258
267
 
259
268
  // The `settings` Section — authored configuration that is not identity, so it is
@@ -409,8 +418,13 @@ export function sectionRecordToFile({ filePath, record, sourceLocale = LOCALIZED
409
418
  if (theme_override !== undefined) frontmatter.theme = theme_override
410
419
  if (preset !== undefined) frontmatter.preset = preset
411
420
  if (input !== undefined) frontmatter.input = input
412
- // Invert the build's resolution rather than copy it — see fetch-shapes.js.
413
- if (fetch !== undefined) frontmatter.fetch = authorableFetch(fetch)
421
+ // Invert the build's resolution rather than copy it — see fetch-shapes.js. A new
422
+ // file gets `query:` for a declaration of names alone; a section that declares
423
+ // its data locally keeps it (`writeSectionFile`, the declaration keys).
424
+ if (fetch !== undefined) {
425
+ const { key, value } = authorableDeclaration(fetch)
426
+ frontmatter[key] = value
427
+ }
414
428
  if (stable_id !== undefined) frontmatter.id = stable_id
415
429
 
416
430
  const body = insets ? reinlineInsets(sourceContent, insets) : sourceContent
@@ -496,10 +510,13 @@ export function pageSectionsToFiles({ pageDir, pageSections, ctx, pageContext })
496
510
  // page.yml/folder.yml. On a merge write these are replaced wholesale (a managed
497
511
  // key the record no longer carries is dropped); any other key is author-authored
498
512
  // and preserved. Keep in sync with pageRecordToYml below.
513
+ // ⚠️ `query` and `data` are managed with `fetch`: the declaration is written under
514
+ // ONE key (`authorableDeclaration`), so the other two — a retired `data:`
515
+ // included — are dropped rather than left beside it.
499
516
  const PAGE_YML_MANAGED_KEYS = new Set([
500
517
  'id', 'title', 'description', 'label', 'keywords', 'index', 'hidden',
501
518
  'hideIn', 'knowledge', 'trackSections', 'redirect', 'rewrite', 'layout', 'seo',
502
- 'fetch', 'sections',
519
+ 'query', 'fetch', 'data', 'sections',
503
520
  ])
504
521
 
505
522
  // Inverse of site.js buildPageData → the `page.yml` / `folder.yml` object.
@@ -507,7 +524,7 @@ const PAGE_YML_MANAGED_KEYS = new Set([
507
524
  // directory (name, page.yml vs folder.yml, `[param]/`), not the config body.
508
525
  // Identity (the backend uuid) is NOT written here — it lives in the gitignored
509
526
  // `.uniweb/` index so authored files stay clean.
510
- function pageRecordToYml(record, sectionsArray, sourceLocale) {
527
+ function pageRecordToYml(record, sectionsArray, sourceLocale, existing = null) {
511
528
  const y = {}
512
529
  if (record.stable_id !== undefined) y.id = record.stable_id
513
530
  const title = unwrapLocalized(record.title, sourceLocale)
@@ -531,8 +548,12 @@ function pageRecordToYml(record, sectionsArray, sourceLocale) {
531
548
  if (record.rewrite !== undefined) y.rewrite = record.rewrite
532
549
  if (record.layout !== undefined) y.layout = record.layout
533
550
  if (record.seo !== undefined) y.seo = record.seo
534
- // Invert the build's resolution rather than copy it — see fetch-shapes.js.
535
- if (record.fetch !== undefined) y.fetch = authorableFetch(record.fetch)
551
+ // Invert the build's resolution rather than copy it — see fetch-shapes.js. Under
552
+ // the key the file already uses (`existing`), else `query:` for names alone.
553
+ if (record.fetch !== undefined) {
554
+ const { key, value } = authorableDeclaration(record.fetch, existing)
555
+ y[key] = value
556
+ }
536
557
  // `sections:` exists to preserve ORDER and NESTING, which the projected filenames
537
558
  // can't carry (they're `<stableId>.md`, with no numeric prefix). It must not also
538
559
  // decide MEMBERSHIP — and a bare list does: the collector reads a list without
@@ -635,7 +656,7 @@ function writePagesTree(pages, pagesDir, sourceLocale, report, ctx, routePrefix
635
656
  const ymlPath = join(pageDir, ymlName)
636
657
  // Merge (not full-dump) so author-added keys survive a pull; the projector
637
658
  // owns only PAGE_YML_MANAGED_KEYS.
638
- writeMergedYaml(ymlPath, pageRecordToYml(record, sectionsArray, sourceLocale), PAGE_YML_MANAGED_KEYS)
659
+ writeMergedYaml(ymlPath, pageRecordToYml(record, sectionsArray, sourceLocale, readAuthoredYaml(ymlPath)), PAGE_YML_MANAGED_KEYS)
639
660
  report.pages.push(ymlPath)
640
661
 
641
662
  writePagesTree(record.$children || [], pageDir, sourceLocale, report, ctx, route)
package/src/uwx/site.js CHANGED
@@ -39,7 +39,7 @@
39
39
 
40
40
  import { readdir, readFile } from 'node:fs/promises'
41
41
  import { existsSync } from 'node:fs'
42
- import { join, parse } from 'node:path'
42
+ import { join, parse, relative } from 'node:path'
43
43
  import {
44
44
  readYamlFile,
45
45
  readFolderConfig,
@@ -52,7 +52,9 @@ import {
52
52
  parseWildcardArray,
53
53
  applyWildcardOrder,
54
54
  processMarkdownFile,
55
- fetchFromDataShorthand,
55
+ declaredFetch,
56
+ checkDeclaration,
57
+ fetchFromQueryShorthand,
56
58
  assertRouteFolder,
57
59
  } from '../site/content-collector.js'
58
60
  import { refuseUnder } from '../site/data-fetcher.js'
@@ -196,7 +198,7 @@ function mapSectionData(section) {
196
198
  }
197
199
 
198
200
  function buildPageData(config, ctx) {
199
- const { slug, mode, isDynamic, paramName, isRoot, siteIndex, sourceLocale, translations } =
201
+ const { slug, mode, isDynamic, paramName, isRoot, siteIndex, sourceLocale, translations, where } =
200
202
  ctx
201
203
  // The page `slug` is the localized route source — a `{lang: slug}` map (the
202
204
  // site-content Model declares it localized; greenlit 2026-06-13). A single-locale
@@ -234,10 +236,12 @@ function buildPageData(config, ctx) {
234
236
  setIf(data, 'rewrite', config.rewrite)
235
237
  setIf(data, 'layout', config.layout)
236
238
  setIf(data, 'seo', config.seo)
237
- // ⭐ A `data:` LIST means "fetch each" — one declaration per entry. Before
239
+ // ⭐ A `query:` or `fetch:` LIST means "fetch each" — one declaration per entry. Before
238
240
  // 2026-09-02 this kept `[0]` and dropped the rest silently, so the wire
239
241
  // carried one dataset for a page that asked for several.
240
- let fetch = config.fetch ?? fetchFromDataShorthand(config.data)
242
+ // `fetch:`, or the `query:` shorthand, read and refused exactly as the build
243
+ // reads them (`declaredFetch`) — a `folder.yml` as much as a `page.yml`.
244
+ let fetch = declaredFetch(config, where ?? 'page.yml')
241
245
  // `where: { path: { under } }` is refused here as the build refuses it
242
246
  // (`parseFetchConfig`): a site that cannot build must not sync either. A
243
247
  // section's fetch is refused where the collector parses it.
@@ -249,7 +253,7 @@ function buildPageData(config, ctx) {
249
253
  // it would never resolve at render (the static build resolves it the same way
250
254
  // in site/data-fetcher.js parseFetchConfig). The gateway serves the collection
251
255
  // at `<base>/data/<name>.json`.
252
- // ⛔ **Mapped, not read.** A `data:`/`fetch:` LIST reaches here as an array, and
256
+ // ⛔ **Mapped, not read.** A `query:`/`fetch:` LIST reaches here as an array, and
253
257
  // `fetch.query` on one is `undefined` — so a property test would skip the
254
258
  // resolution below and put bare `{ query }` entries on the wire with no
255
259
  // `path`, no `as` and no `schema`. That is the silent-empty class: a payload
@@ -567,6 +571,7 @@ async function walkPagesNested(ctx, dirPath, parentSlugPath, inheritedMode, pare
567
571
  const slugPath = parentSlugPath ? `${parentSlugPath}/${slug}` : slug
568
572
 
569
573
  const data = buildPageData(f.config, {
574
+ where: relative(siteRoot, join(f.path, f.source)),
570
575
  slug,
571
576
  mode,
572
577
  isDynamic: !!dyn,
@@ -1080,14 +1085,15 @@ function settingsNested(siteYml, { headHtml, themeYml, sourceLocale, translation
1080
1085
  // summarized.
1081
1086
  setIf(settings, 'agents', siteYml.agents)
1082
1087
 
1083
- // ⭐ The site-level fetch, DESUGARED and under its real name. `data:` is the
1088
+ // ⭐ The site-level fetch, DESUGARED and under its real name. `query:` is the
1084
1089
  // authoring shorthand for `fetch:` and every other tier already calls the wire
1085
- // field `fetch`; the site tier called it `data` until 2026-09-09.
1086
- // `under` is refused as the build refuses it; the `data:` shorthand carries no
1087
- // `where`. ⚠️ The source expression stays inside `setIf`: `gen-emit-surface.mjs`
1088
- // reads the published key's sources off it.
1090
+ // field `fetch`; the site tier's wire field was `data` until 2026-09-09.
1091
+ // `query:` / `fetch:` checked as the build checks them, and `under` refused;
1092
+ // the `query:` shorthand carries no `where`. ⚠️ The desugaring stays inline in
1093
+ // `setIf`: `gen-emit-surface.mjs` reads the published key's sources off it.
1094
+ checkDeclaration(siteYml, 'site.yml')
1089
1095
  for (const one of [siteYml.fetch].flat()) refuseUnder(one?.where, 'site.yml fetch')
1090
- setIf(settings, 'fetch', siteYml.fetch ?? fetchFromDataShorthand(siteYml.data))
1096
+ setIf(settings, 'fetch', siteYml.fetch ?? fetchFromQueryShorthand(siteYml.query))
1091
1097
 
1092
1098
  // ⭐ The SITE TIER of framework's own `{name, hide, params}` layout object, which
1093
1099
  // the page and folder tiers have always had. `hide` is a non-destructive per-area
@@ -1265,15 +1271,6 @@ export async function siteProjectToDocument(siteRoot, opts = {}) {
1265
1271
  // endpoint; here it leaves the site with the RIGHT answer.
1266
1272
  //
1267
1273
  // The provisioned record rides the `$services` section instead (see servicesNested).
1268
- // ⭐ DESUGARED, like every other tier. `data:` is the shorthand for `fetch:`
1269
- // (`data: articles` → `{ query: 'articles' }`), and the page level has always
1270
- // desugared before emitting. The site level shipped the bare string until
1271
- // 2026-09-09, so `info.data` carried two different shapes depending on which
1272
- // key the author happened to type.
1273
- //
1274
- // 📌 The wire NAME is still `data` and becomes `fetch` when the Section moves —
1275
- // renaming it now would be a second destructive wire change for a cosmetic gain;
1276
- // renaming it during the move is free.
1277
1274
  // ⛔ THE CONFIGURATION KEYS ARE NOT HERE — they ride the `settings` Section
1278
1275
  // (`settingsNested` above). `info` is the BRIEF: what a card or a select dropdown
1279
1276
  // renders, plus what a listing can filter on. Eighteen keys moved off it on
@@ -54,7 +54,7 @@ import { processQueries } from './site/query-processor.js'
54
54
  * agree on which schema governs what by construction.
55
55
  *
56
56
  * Inputs are consumed from what the canonical build parsers already compute —
57
- * `section.fetch` (the binding resolved from `data:` / `fetch:`) and
57
+ * `section.fetch` (the binding resolved from `query:` / `fetch:`) and
58
58
  * `schema.json[type].data` (the key→ref bindings). Re-deriving either would let
59
59
  * this command and the build disagree about what feeds what.
60
60
  *
@@ -139,7 +139,7 @@ export async function validateDataInputs({ siteRoot, foundationPath }) {
139
139
  `but this page delivers ${delivered.map((k) => `\`${k}\``).join(', ')}. ` +
140
140
  `The section will render with no data and nothing else will say so. ` +
141
141
  `Name the query for the key the section reads, or give the section its own ` +
142
- `\`fetch: { query: <name> }\`.`,
142
+ `\`query: <name>\`.`,
143
143
  // One user per declared key, so `uniweb validate` can print
144
144
  // `used by /team › Team › data.team` — the key is the thing to rename.
145
145
  users: declaredKeys.map((k) => ({ route: page.route, section: type, key: k })),
@@ -479,7 +479,7 @@ async function loadStandardSchemas() {
479
479
  */
480
480
  function collectInputs(section, pageFetch, siteFetch) {
481
481
  const byKey = new Map()
482
- // ⭐ Each level may declare SEVERAL — `data: [team, articles]` — so each is
482
+ // ⭐ Each level may declare SEVERAL — `query: [team, articles]` — so each is
483
483
  // flattened rather than read. Order is least- to most-specific and `set`
484
484
  // overwrites, which is what makes a section's declaration win the key.
485
485
  for (const source of [siteFetch, pageFetch, section.fetch]) {