@uniweb/build 0.44.5 → 0.46.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.
@@ -24,13 +24,13 @@
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'
31
31
  import { collectSectionIcons, mergeIconCollections, buildIconManifest } from './icons.js'
32
32
  import { normalizeHideIn, dropUnpublishedPages } from './nav-visibility.js'
33
- import { parseFetchConfig, toFetchList } from './data-fetcher.js'
33
+ import { parseFetchConfig } from './data-fetcher.js'
34
34
  import { resolveExtensionUrls } from './extension-urls.js'
35
35
  import { buildTheme, extractFoundationVars } from '../theme/index.js'
36
36
  import { resolveDefaultLocale, resolvePublishableLocales, validateLanguageConfig } from '@uniweb/core'
@@ -122,6 +122,51 @@ function extractRouteParam(folderName) {
122
122
  return match ? match[1] : null
123
123
  }
124
124
 
125
+ /**
126
+ * Folders the build refuses on a route, ruled 2026-09-11 [Diego]:
127
+ *
128
+ * - `[dir]` and `[path]` — `:dir` and `:path` are route variables every
129
+ * parametric page already has, so a folder by either name would make one
130
+ * name mean two values (and `[path]` is most often a mistyped `[...path]`);
131
+ * - any folder inside a `[...path]` folder — the catch-all takes the rest of
132
+ * the URL, so a page below it has a route (`/docs/:path*\/edit`) that can
133
+ * never match. A folder that holds something other than a page is named
134
+ * with a leading `_`, which the walk skips.
135
+ *
136
+ * @param {string} name - the folder's name
137
+ * @param {string} parentRoute - the route of the folder it sits in
138
+ */
139
+ function assertRouteFolder(name, parentRoute) {
140
+ if (name === '[dir]' || name === '[path]') {
141
+ const hint = name === '[path]' ? ' Did you mean `[...path]`, which captures a path of any depth?' : ''
142
+ throw new Error(
143
+ `[uniweb] pages: a folder cannot be named \`${name}\` — \`:${name.slice(1, -1)}\` is a route ` +
144
+ `variable every parametric page already has.${hint}`
145
+ )
146
+ }
147
+ if (typeof parentRoute === 'string' && /\/:[A-Za-z0-9_-]+\*(\/|$)/.test(parentRoute)) {
148
+ throw new Error(
149
+ `[uniweb] pages: \`${name}\` sits inside a \`[...path]\` folder (${parentRoute}). The catch-all ` +
150
+ `takes the rest of the URL, so a page below it could never be reached. Move it beside the ` +
151
+ `\`[...path]\` folder, or name it \`_${name}\` if it holds something other than a page.`
152
+ )
153
+ }
154
+ }
155
+
156
+ /**
157
+ * The route param a page nested inside a parametric page binds — its nearest
158
+ * parametric ancestor's, the DEEPEST `:param` of the route it sits under. Null
159
+ * when no ancestor is parametric.
160
+ *
161
+ * @param {string} parentRoute
162
+ * @returns {string|null}
163
+ */
164
+ function inheritedRouteParam(parentRoute) {
165
+ if (typeof parentRoute !== 'string') return null
166
+ const params = [...parentRoute.matchAll(/:([A-Za-z0-9_-]+)(?=\/|$)/g)].map((m) => m[1])
167
+ return params.length ? params[params.length - 1] : null
168
+ }
169
+
125
170
  // ─────────────────────────────────────────────────────────────────
126
171
  // Version Detection
127
172
  // ─────────────────────────────────────────────────────────────────
@@ -175,27 +220,86 @@ function detectVersions(folderNames) {
175
220
  }
176
221
 
177
222
  /**
178
- * Desugar a `data:` declaration into a `fetch:` one.
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:`.
179
229
  *
180
- * `data: team` `{ query: 'team' }`; `data: [team, articles]` one config per
181
- * entry. **A list means "fetch each"** see `parseFetchConfig` for why the
182
- * declaration is plural by necessity and why that is not a statement about
183
- * request count.
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`.
184
235
  *
185
- * ⛔ Before 2026-09-02 a list kept `[0]` and dropped the rest **silently**: no
186
- * warning, no error, and the array was not carried forward on the section, so
187
- * nothing downstream could recover it. An author writing a list got one dataset
188
- * and a section rendering empty.
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).
189
252
  *
190
- * @param {string|Array<string>|undefined} data
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.
287
+ *
288
+ * @param {string|Array<string>|undefined|null} query - already checked (`checkDeclaration`)
191
289
  * @returns {Object|Array<Object>|undefined}
192
290
  */
193
- export function fetchFromDataShorthand(data) {
194
- if (!data) return undefined
195
- if (Array.isArray(data)) return data.map((query) => ({ query }))
196
- 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 }
197
295
  }
198
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
+
199
303
  /**
200
304
  * Build version metadata from detected versions and page.yml config
201
305
  * @param {Array<Object>} detectedVersions - Detected version infos
@@ -235,7 +339,7 @@ function buildVersionMetadata(detectedVersions, pageConfig = {}) {
235
339
  * this function reads `site.yml`, `page.yml`, `folder.yml`, `theme.yml` and
236
340
  * every section's frontmatter — i.e. every configuration surface an author
237
341
  * writes. So a single typo silently discarded that file's entire contribution:
238
- * page order, nesting, `sections:`, `data:` declarations, theme. **The build
342
+ * page order, nesting, `sections:`, `query:` declarations, theme. **The build
239
343
  * succeeded and shipped a site missing what the author asked for**, with one
240
344
  * line on stderr that named no file.
241
345
  *
@@ -866,7 +970,6 @@ async function processFileAsPage(filePath, fileName, siteRoot, parentRoute) {
866
970
  lastModified: fileStat.mtime?.toISOString() || null,
867
971
  isDynamic: false,
868
972
  paramName: null,
869
- parentSchema: null,
870
973
  version: null,
871
974
  versionMeta: null,
872
975
  versionScope: null,
@@ -921,7 +1024,9 @@ async function processMarkdownFile(filePath, id, siteRoot, defaultStableId = nul
921
1024
  console.warn(`[content-collector] ${err.message}`)
922
1025
  }
923
1026
 
924
- 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
925
1030
 
926
1031
  // Convert markdown to ProseMirror
927
1032
  const proseMirrorContent = markdownToProseMirror(markdown)
@@ -929,28 +1034,13 @@ async function processMarkdownFile(filePath, id, siteRoot, defaultStableId = nul
929
1034
  // Extract @ component references → insets (mutates doc)
930
1035
  const insets = extractInsets(proseMirrorContent)
931
1036
 
932
- // `data:` shorthand — `data: team` → `fetch: { query: team }`.
933
- //
934
- // **A LIST KEEPS ONLY `[0]`. THE REST ARE INERT.** This comment used to say
935
- // *"others via inheritData"*, and that is wrong in a way worth spelling out,
936
- // because it read as a mechanism and was copied into a second doc as one.
937
- //
938
- // `inheritData` is a BOOLEAN OPT-OUT on a section's `meta.js`
939
- // (`meta.inheritData === false` → deliver nothing). It never consumed list
940
- // elements. What actually happens is that delivery is default-on and
941
- // collect-all: `EntityStore._getRequestedSchemas` returns `[]`, so a section
942
- // receives EVERY fetch config in the section → page → site cascade whether or
943
- // not it named any of them.
944
- //
945
- // ⇒ So `articles` arrives only if something ELSE already declared a fetch for
946
- // it — in which case this section would have received it anyway. **Naming it
947
- // here contributes nothing.** Measured 2026-09-02: `data: [team, articles]`
948
- // with no other declaration yields exactly one config, `team`, and the array
949
- // is not carried forward on the section, so nothing downstream can recover it.
950
- //
951
- // ⚠️ The list form appears in no `docs/` page, so nothing promises it works.
952
- // Whether it should mean "fetch each" or be refused outright is undecided.
953
- 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))
954
1044
 
955
1045
  // Stable ID for scroll targeting: frontmatter id > filename-derived > null
956
1046
  // This ID is stable across reordering (unlike the positional id)
@@ -1467,12 +1557,19 @@ async function processPage(pagePath, pageName, siteRoot, { isIndex = false, pare
1467
1557
  // Determine route
1468
1558
  // Index pages get the parent route as their canonical route (no dual routes)
1469
1559
  // sourcePath stores the original folder-based path for ancestor checking
1470
- const isDynamic = isDynamicRoute(pageName)
1471
- const paramName = isDynamic ? extractRouteParam(pageName) : null
1560
+ // A page is PARAMETRIC when its folder is a bracket name or it sits inside
1561
+ // one (ruled 2026-09-11 [Diego]): `pages/members/[slug]/cv/` is `/members/:slug/cv`
1562
+ // and binds its ancestor's `slug`. Every lane — the SPA, the prefetch, the static
1563
+ // build — tells a parametric page by the parameter in its route; this flag says
1564
+ // the same thing to a consumer that reads the flag.
1565
+ const isBracket = isDynamicRoute(pageName)
1566
+ const inheritedParam = isBracket ? null : inheritedRouteParam(parentRoute)
1567
+ const isDynamic = isBracket || inheritedParam !== null
1568
+ const paramName = isBracket ? extractRouteParam(pageName) : inheritedParam
1472
1569
 
1473
1570
  // First, calculate the folder-based route (what the route would be without index handling)
1474
1571
  let folderRoute
1475
- if (isDynamic) {
1572
+ if (isBracket) {
1476
1573
  // Dynamic routes: /blog/[slug] → /blog/:slug (for route matching);
1477
1574
  // /blog/[...path] → /blog/:path* — the one multi-segment token the matcher knows.
1478
1575
  const token = isCatchAllRoute(pageName) ? ':path*' : `:${paramName}`
@@ -1500,23 +1597,13 @@ async function processPage(pagePath, pageName, siteRoot, { isIndex = false, pare
1500
1597
  const layoutObj = mergeLayoutConfig(inheritedLayout, normalizeLayoutConfig(layoutConfig))
1501
1598
  const resolvedLayoutName = layoutObj.name || null
1502
1599
 
1503
- // For dynamic routes, determine the parent's data schema this tells
1504
- // prerender which data array to iterate over.
1505
- //
1506
- // ⚖️ **A `[slug]` template expands over exactly ONE record set**, so a plural
1507
- // parent declaration has to resolve to one query here. The first is taken,
1508
- // matching what prerender records in `pageFetchedData`; the two must agree or
1509
- // expansion iterates a set the route was not built from.
1510
- //
1511
- // ⛔ This is a genuine cardinality constraint, not a limit worth lifting: a
1512
- // route pattern names one variable, and "which collection does `:slug` index"
1513
- // has no second answer. A page that needs another dataset alongside its
1514
- // dynamic one still declares it — plurality is what makes that sayable.
1515
- let parentSchema = null
1516
- if (isDynamic && parentFetch) {
1517
- const [first] = toFetchList(parentFetch)
1518
- parentSchema = first ? first.as : null
1519
- }
1600
+ // NO `parentSchema`. Which query a parametric page's URL names one record of
1601
+ // its ROUTE QUERY is worked out where it is read, by one function every lane
1602
+ // calls (`routeQuery`, `@uniweb/core/fetch-config`): the page's own query, its
1603
+ // parent's, the site's, or its sections' shared key. This emitted a copy chosen
1604
+ // by another rule (the closest ancestor with a query at ANY depth, never the
1605
+ // page's own or the site's), so the URL narrowed nothing, or a key no section
1606
+ // received measured 2026-09-10. Removed 2026-09-11 [Diego].
1520
1607
 
1521
1608
  return {
1522
1609
  page: {
@@ -1535,10 +1622,9 @@ async function processPage(pagePath, pageName, siteRoot, { isIndex = false, pare
1535
1622
  : {}),
1536
1623
  lastModified: lastModified?.toISOString(),
1537
1624
 
1538
- // Dynamic route metadata
1625
+ // Parametric route metadata
1539
1626
  isDynamic,
1540
- paramName, // e.g., "slug" from [slug]
1541
- parentSchema, // e.g., "articles" - the data array to iterate over
1627
+ paramName, // e.g., "slug" from [slug]; a nested page's ancestor's
1542
1628
 
1543
1629
  // Version metadata (if within a versioned section)
1544
1630
  version: versionContext?.version || null,
@@ -1579,12 +1665,8 @@ async function processPage(pagePath, pageName, siteRoot, { isIndex = false, pare
1579
1665
  priority: seo.priority || null
1580
1666
  },
1581
1667
 
1582
- // Data fetching
1583
- // Support 'data:' shorthand at page level
1584
- // data: team → fetch: { query: team }
1585
- fetch: parseFetchConfig(
1586
- pageConfig.fetch || fetchFromDataShorthand(pageConfig.data)
1587
- ),
1668
+ // Data fetching — `fetch:`, or the `query:` shorthand (`declaredFetch`)
1669
+ fetch: parseFetchConfig(declaredFetch(pageConfig, relative(siteRoot, join(pagePath, 'page.yml')))),
1588
1670
 
1589
1671
  hasContent: hierarchicalSections.length > 0,
1590
1672
  sections: hierarchicalSections
@@ -1872,6 +1954,7 @@ async function collectPagesRecursive(dirPath, parentRoute, siteRoot, orderConfig
1872
1954
  // Process subdirectories
1873
1955
  for (const folder of orderedFolders) {
1874
1956
  const { name: entry, path: entryPath, dirConfig, dirMode, mountedContentMode, childOrderConfig, childLayout } = folder
1957
+ assertRouteFolder(entry, parentRoute)
1875
1958
  const isIndex = entry === indexName
1876
1959
  const effectiveLayout = mergeLayoutConfig(parentLayout, childLayout)
1877
1960
 
@@ -1930,7 +2013,6 @@ async function collectPagesRecursive(dirPath, parentRoute, siteRoot, orderConfig
1930
2013
  lastModified: null,
1931
2014
  isDynamic: false,
1932
2015
  paramName: null,
1933
- parentSchema: null,
1934
2016
  version: versionContext?.version || null,
1935
2017
  versionMeta: versionContext?.versionMeta || null,
1936
2018
  versionScope: versionContext?.scope || null,
@@ -1947,7 +2029,10 @@ async function collectPagesRecursive(dirPath, parentRoute, siteRoot, orderConfig
1947
2029
  changefreq: dirConfig.seo?.changefreq || null,
1948
2030
  priority: dirConfig.seo?.priority || null
1949
2031
  },
1950
- fetch: parseFetchConfig(dirConfig.fetch) || null,
2032
+ // ⭐ `declaredFetch`, as every other level: this read `dirConfig.fetch`
2033
+ // alone until 2026-09-11, so a container's `folder.yml` shorthand reached
2034
+ // a backend on `push` and was dropped from a static build.
2035
+ fetch: parseFetchConfig(declaredFetch(dirConfig, relative(siteRoot, join(entryPath, 'folder.yml')))) || null,
1951
2036
  hasContent: false,
1952
2037
  sections: [],
1953
2038
  order: typeof dirConfig.order === 'number' ? dirConfig.order : undefined
@@ -2001,6 +2086,7 @@ async function collectPagesRecursive(dirPath, parentRoute, siteRoot, orderConfig
2001
2086
  // Second pass: process each page folder
2002
2087
  for (const folder of orderedFolders) {
2003
2088
  const { name: entry, path: entryPath, dirConfig, dirMode, mountedContentMode, childOrderConfig, childLayout } = folder
2089
+ assertRouteFolder(entry, parentRoute)
2004
2090
  const isIndex = entry === indexPageName
2005
2091
  const effectiveLayout = mergeLayoutConfig(parentLayout, childLayout)
2006
2092
 
@@ -2024,7 +2110,6 @@ async function collectPagesRecursive(dirPath, parentRoute, siteRoot, orderConfig
2024
2110
  lastModified: null,
2025
2111
  isDynamic: false,
2026
2112
  paramName: null,
2027
- parentSchema: null,
2028
2113
  version: versionContext?.version || null,
2029
2114
  versionMeta: versionContext?.versionMeta || null,
2030
2115
  versionScope: versionContext?.scope || null,
@@ -2039,7 +2124,12 @@ async function collectPagesRecursive(dirPath, parentRoute, siteRoot, orderConfig
2039
2124
  changefreq: dirConfig.seo?.changefreq || null,
2040
2125
  priority: dirConfig.seo?.priority || null
2041
2126
  },
2042
- fetch: null,
2127
+ // ⭐ The folder's own declaration, as the folder-mode container above and
2128
+ // every other level read it. ⛔ This was `fetch: null` until 2026-09-11:
2129
+ // a `folder.yml` here lost its `fetch:` (and its shorthand) on a static
2130
+ // build while `push` carried it, so its pages had the folder's data on a
2131
+ // hosted site and none on an exported one.
2132
+ fetch: parseFetchConfig(declaredFetch(dirConfig, relative(siteRoot, join(entryPath, 'folder.yml')))) || null,
2043
2133
  hasContent: false,
2044
2134
  sections: [],
2045
2135
  order: typeof dirConfig.order === 'number' ? dirConfig.order : undefined
@@ -2051,8 +2141,11 @@ async function collectPagesRecursive(dirPath, parentRoute, siteRoot, orderConfig
2051
2141
  pages.push(containerPage)
2052
2142
  }
2053
2143
 
2144
+ // The container's own fetch config, or the parent's — as the folder-mode
2145
+ // container above passes it.
2054
2146
  const childDirPath = mounts?.get(entry) || entryPath
2055
- const subResult = await collectPagesRecursive(childDirPath, containerRoute, siteRoot, childOrderConfig, parentFetch, versionContext, 'pages', null, effectiveLayout)
2147
+ const containerFetch = containerPage.fetch || parentFetch
2148
+ const subResult = await collectPagesRecursive(childDirPath, containerRoute, siteRoot, childOrderConfig, containerFetch, versionContext, 'pages', null, effectiveLayout)
2056
2149
  pages.push(...subResult.pages)
2057
2150
  assetCollection = mergeAssetCollections(assetCollection, subResult.assetCollection)
2058
2151
  iconCollection = mergeIconCollections(iconCollection, subResult.iconCollection)
@@ -2616,7 +2709,9 @@ export async function collectSiteContent(sitePath, options = {}) {
2616
2709
  // `publishLanguages` is authoring/publish intent — it has no runtime
2617
2710
  // consumer and never ships in a payload (the visitor runtime is
2618
2711
  // list-unaware; the sync lane reads site.yml directly, not this output).
2619
- const { publishLanguages: _publishLanguages, ...runtimeSiteConfig } = siteConfig
2712
+ // The `query:` shorthand ships as `config.fetch`, desugared below; carried raw
2713
+ // it would sit beside `config.queries`, the declarations, and read as one.
2714
+ const { publishLanguages: _publishLanguages, query: _query, ...runtimeSiteConfig } = siteConfig
2620
2715
  // ⛔ `$`-prefixed keys are the project's BACKEND-SCOPED state — `$uuid`, `$org`,
2621
2716
  // `$backend`, `$services`, `$secrets` — and this payload is a PUBLISHED artifact
2622
2717
  // that a visitor can fetch. They have no runtime reader (nothing in core, runtime
@@ -2660,7 +2755,7 @@ export async function collectSiteContent(sitePath, options = {}) {
2660
2755
  `${distinctFailures.length} file${distinctFailures.length === 1 ? '' : 's'} could not be parsed as YAML:\n` +
2661
2756
  `${lines.join('\n')}\n\n` +
2662
2757
  ` Each one contributed NOTHING to this build — page order, nesting,\n` +
2663
- ` sections:, data: and theme settings in these files were dropped.\n` +
2758
+ ` sections:, query: and theme settings in these files were dropped.\n` +
2664
2759
  ` Fix them and rebuild; the dev server reports the same files without failing.`
2665
2760
  )
2666
2761
  }
@@ -2680,13 +2775,12 @@ export async function collectSiteContent(sitePath, options = {}) {
2680
2775
  ...(publishFilterActive && Array.isArray(siteConfig.languages)
2681
2776
  ? { languages: publishable }
2682
2777
  : {}),
2683
- // ⛔ `data:` IS THE SHORTHAND FOR `fetch:` AND BOTH LANES MUST READ IT.
2778
+ // ⛔ `query:` IS THE SHORTHAND FOR `fetch:` AND BOTH LANES MUST READ IT.
2684
2779
  // This read `siteConfig.fetch` alone until 2026-09-09, so a site-level
2685
- // `data: articles` reached a backend on the sync lane and was silently
2686
- // ignored on a static build — the works-on-one-lane shape. The page level
2687
- // has always used this helper (`pageConfig.fetch || fetchFromDataShorthand(…)`);
2688
- // the site level simply never did.
2689
- fetch: parseFetchConfig(siteConfig.fetch || fetchFromDataShorthand(siteConfig.data)),
2780
+ // shorthand reached a backend on the sync lane and was silently ignored on
2781
+ // a static build — the works-on-one-lane shape. Every level reads it
2782
+ // through `declaredFetch` now.
2783
+ fetch: parseFetchConfig(declaredFetch(siteConfig, 'site.yml')),
2690
2784
  fetcher: warnRetiredFetcherKeys(siteConfig.fetcher),
2691
2785
  // NOTE: `intelligence.yml` was read here and emitted as `config.intelligence`.
2692
2786
  // Removed 2026-08-12 — the assistant surface is `site.yml::assistant`, which
@@ -2835,6 +2929,7 @@ function buildRouteTranslations(pages, { defaultLocale = 'en', languages = null
2835
2929
  }
2836
2930
 
2837
2931
  export {
2932
+ assertRouteFolder,
2838
2933
  buildRouteTranslations,
2839
2934
  extractItemName,
2840
2935
  parseWildcardArray,
@@ -20,7 +20,7 @@ import { readFile } from 'node:fs/promises'
20
20
  import { join } from 'node:path'
21
21
  import { existsSync } from 'node:fs'
22
22
  import yaml from 'js-yaml'
23
- import { matchWhere, sortRecords, queryDataUrl } from '@uniweb/core'
23
+ import { matchWhere, sortRecords, queryDataUrl, applyScope } from '@uniweb/core'
24
24
 
25
25
  /**
26
26
  * Infer schema name from path or URL
@@ -115,10 +115,16 @@ export function applyWhere(items, where) {
115
115
  */
116
116
  export function applyPostProcessing(data, config) {
117
117
  if (!data || !Array.isArray(data)) return data
118
- if (!config.where && !config.sort && !config.limit) return data
118
+ if (!config.scope && !config.where && !config.sort && !config.limit) return data
119
119
 
120
120
  let result = data
121
121
 
122
+ // `scope` first — the folder branch the rest of the query reads, over each
123
+ // record's placement (`path`), as the runtime's default fetcher applies it.
124
+ if (typeof config.scope === 'string' && config.scope) {
125
+ result = applyScope(result, config.scope)
126
+ }
127
+
122
128
  // Apply where-object predicate first (new path)
123
129
  if (config.where) {
124
130
  result = applyWhere(result, config.where)
@@ -176,16 +182,50 @@ const RECOGNIZED_FETCH_KEYS = {
176
182
  // dropped in the one way the author could not see: no warning, and a plausible
177
183
  // key inferred from the path in its place. It has its own message below, since
178
184
  // "unrecognized" understates a key that used to work.
185
+ // ⭐ `scope` is recognized since 2026-09-11, when a folder branch became `scope:`
186
+ // on both lanes and `where: { path: { under } }` was retired in its favour. It
187
+ // was dropped here as "unrecognized" until then, so a page could not narrow a
188
+ // query to a branch at all.
179
189
  query: new Set([
180
190
  'query', 'as', 'prerender', 'merge', 'transform',
181
- 'where', 'limit', 'sort', 'detailPage',
191
+ 'scope', 'where', 'limit', 'sort', 'detailPage',
182
192
  ]),
183
193
  source: new Set([
184
194
  'path', 'url', 'as', 'prerender', 'merge', 'transform', 'detail',
185
- 'detailPage', 'where', 'limit', 'sort',
195
+ 'detailPage', 'scope', 'where', 'limit', 'sort',
186
196
  ]),
187
197
  }
188
198
 
199
+ /**
200
+ * ⛔ `under` IS RETIRED (2026-09-11 [Diego]) — refused, like every retired spelling
201
+ * here, because an ignored predicate is a silently wrong answer. It existed for
202
+ * `where: { path: { under: X } }`, a folder branch written before a query had
203
+ * `scope:`; a branch is `scope: X` now, on both lanes, and the evaluator no longer
204
+ * knows the operator, so a `where` still carrying it would match nothing.
205
+ *
206
+ * @param {Object|undefined} where
207
+ * @param {string} context - where the declaration sits, for the message
208
+ */
209
+ export function refuseUnder(where, context) {
210
+ const walk = (node) => {
211
+ if (Array.isArray(node)) {
212
+ node.forEach(walk)
213
+ return
214
+ }
215
+ if (!node || typeof node !== 'object') return
216
+ for (const [key, value] of Object.entries(node)) {
217
+ if (value && typeof value === 'object' && !Array.isArray(value) && Object.prototype.hasOwnProperty.call(value, 'under')) {
218
+ const instead = key === 'path' && typeof value.under === 'string'
219
+ ? `Write \`scope: ${JSON.stringify(value.under)}\` — the same folder branch, on every lane.`
220
+ : 'A folder branch is `scope:`; `under` is no longer an operator.'
221
+ throw new Error(`[uniweb] ${context}: \`where: { ${key}: { under: … } }\` is retired. ${instead}`)
222
+ }
223
+ walk(value)
224
+ }
225
+ }
226
+ walk(where)
227
+ }
228
+
189
229
  // Keys that are neither recognized nor merely unknown: they USED to work, and a
190
230
  // generic "unrecognized key" line understates that. Each has a dedicated message
191
231
  // naming its replacement, so this table only has to keep the generic report from
@@ -234,9 +274,9 @@ export function toFetchList(fetch) {
234
274
  }
235
275
 
236
276
  /**
237
- * Parse a `fetch:` (or desugared `data:`) declaration.
277
+ * Parse a `fetch:` (or desugared `query:`) declaration.
238
278
  *
239
- * ⭐ **A LIST MEANS "FETCH EACH".** `data: [team, articles]` declares two needs
279
+ * ⭐ **A LIST MEANS "FETCH EACH".** `query: [team, articles]` declares two needs
240
280
  * and they land under two keys in `content.data` — a component reads
241
281
  * `content.data.team` and `content.data.articles` independently, so the
242
282
  * declaration is plural by necessity.
@@ -300,6 +340,7 @@ export function parseFetchConfig(fetch) {
300
340
  'per-instance refinement of the ancestor fetch, under its current name.'
301
341
  )
302
342
  }
343
+ refuseUnder(fetch.where, 'fetch')
303
344
 
304
345
  // Refine config: { refine: true, detail: false, limit: 3 }
305
346
  // No URL — merges with the parent fetch config at runtime; only carries
@@ -368,7 +409,8 @@ export function parseFetchConfig(fetch) {
368
409
  prerender: fetch.prerender ?? true,
369
410
  merge: fetch.merge ?? false,
370
411
  transform: fetch.transform,
371
- // Query operators
412
+ // Query operators — a fetch's own override the named query's, per field
413
+ scope: fetch.scope,
372
414
  where: fetch.where,
373
415
  limit: fetch.limit,
374
416
  sort: fetch.sort,
@@ -389,6 +431,7 @@ export function parseFetchConfig(fetch) {
389
431
  detail,
390
432
  detailPage,
391
433
  // Query operators
434
+ scope,
392
435
  where,
393
436
  limit,
394
437
  sort,
@@ -413,6 +456,7 @@ export function parseFetchConfig(fetch) {
413
456
  // Canonical detail page for a list card's href (page:<stable_id>).
414
457
  detailPage,
415
458
  // Query operators
459
+ scope,
416
460
  where,
417
461
  limit,
418
462
  sort,
@@ -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
+ }