@uniweb/build 0.44.5 → 0.45.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.44.5",
3
+ "version": "0.45.0",
4
4
  "description": "Build tooling for the Uniweb Component Web Platform",
5
5
  "type": "module",
6
6
  "exports": {
@@ -59,13 +59,13 @@
59
59
  "yaml": "^2.5.0",
60
60
  "@uniweb/content-reader": "^1.2.4",
61
61
  "@uniweb/content-writer": "^0.3.4",
62
- "@uniweb/projections": "^0.6.0",
63
- "@uniweb/semantic-parser": "^1.4.0",
62
+ "@uniweb/projections": "^0.6.1",
64
63
  "@uniweb/schemas": "^0.2.13",
64
+ "@uniweb/semantic-parser": "^1.4.0",
65
65
  "@uniweb/theming": "^0.1.15"
66
66
  },
67
67
  "optionalDependencies": {
68
- "@uniweb/runtime": "^0.19.5"
68
+ "@uniweb/runtime": "^0.20.0"
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.24.4"
77
+ "@uniweb/core": "^0.25.0"
78
78
  },
79
79
  "peerDependenciesMeta": {
80
80
  "vite": {
package/src/prerender.js CHANGED
@@ -11,7 +11,19 @@ import { readFile, writeFile, mkdir } from 'node:fs/promises'
11
11
  import { existsSync, readdirSync, statSync } from 'node:fs'
12
12
  import { join, dirname, resolve } from 'node:path'
13
13
  import { pathToFileURL } from 'node:url'
14
- import { resolveDefaultLocale, resolveFetchConfigs, joinPathCapture, splitPathCapture } from '@uniweb/core'
14
+ import {
15
+ resolveDefaultLocale,
16
+ resolveFetchConfigs,
17
+ joinPathCapture,
18
+ routeQuery,
19
+ sectionFetches,
20
+ routeParamValue,
21
+ routeParamName,
22
+ routeBinding,
23
+ parentRouteOf,
24
+ deriveCacheKey,
25
+ } from '@uniweb/core'
26
+ import { routePatternToRegex } from '@uniweb/core/route-match'
15
27
  import { executeFetch, mergeDataIntoContent, toFetchList, stripBuildOnlyFetchKeys } from './site/data-fetcher.js'
16
28
  import { shouldSplitContent } from './site/split-content.js'
17
29
  import { FONT_LINKS_MARKER } from './site/head-markers.js'
@@ -76,7 +88,9 @@ export function resolveExtensionPath(url, distDir, projectRoot, base) {
76
88
  * @param {string} [localeInfo.locale] - Active locale code
77
89
  * @param {string} [localeInfo.defaultLocale] - Default locale code
78
90
  * @param {string} [localeInfo.distDir] - Path to dist directory (where locale-specific data lives)
79
- * @returns {Object} { pageFetchedData, fetchedData } - Fetched data for dynamic route expansion and DataStore pre-population
91
+ * @returns {Object} { fetched, fetchedData, bake } - what each level fetched, by binding key
92
+ * (for parametric-page expansion); the entries for DataStore pre-population; and a
93
+ * baker for the views a concrete parametric page binds to its route
80
94
  */
81
95
  export async function executeAllFetches(siteContent, siteDir, onProgress, localeInfo) {
82
96
  const fetchOptions = { siteRoot: siteDir, publicDir: 'public' }
@@ -111,6 +125,8 @@ export async function executeAllFetches(siteContent, siteDir, onProgress, locale
111
125
  : fetchOptions
112
126
  const optionsFor = (cfg, oneFetch) => (cfg.path !== oneFetch.path ? localizedFetchOptions : fetchOptions)
113
127
  const entry = (cfg, data, scope) => ({ config: cfg, data, meta: { whole: cfg.whole }, _scope: scope })
128
+ // What each level fetched, by binding key — what `expandDynamicPages` iterates.
129
+ const fetched = { site: new Map(), pages: new Map(), sections: new Map() }
114
130
 
115
131
  // 1. Site-level fetch. ⛔ `toFetchList` rather than a property read: a `fetch:`
116
132
  // or `data:` LIST parses to an array, and `siteFetch.prerender` on one is
@@ -122,11 +138,22 @@ export async function executeAllFetches(siteContent, siteDir, onProgress, locale
122
138
  const result = await executeFetch(cfg, optionsFor(cfg, oneFetch))
123
139
  if (result.data && !result.error) {
124
140
  fetchedData.push(entry(cfg, result.data, '__site__'))
141
+ if (!fetched.site.has(oneFetch.as)) fetched.site.set(oneFetch.as, result.data)
125
142
  }
126
143
  }
127
144
 
128
- // 2. Process each page and track fetched data by route
129
- const pageFetchedData = new Map()
145
+ // 2. Process each page and track fetched data by route and binding key. ⭐ EVERY
146
+ // key, at every level — the static build expands a parametric page over the
147
+ // data of its ROUTE QUERY, which may be the page's own, its parent's, the site's
148
+ // or its sections' (`routeQuery`). ⛔ Until 2026-09-11 this kept only the FIRST
149
+ // prerendered fetch per page, while the collector named the first DECLARED one
150
+ // as `parentSchema`: a page whose first query was `prerender: false` expanded
151
+ // over its second and baked that key, and the SPA narrowed the first.
152
+ const keep = (byRoute, route, as, data) => {
153
+ const m = byRoute.get(route) ?? new Map()
154
+ if (!m.has(as)) m.set(as, data)
155
+ byRoute.set(route, m)
156
+ }
130
157
 
131
158
  for (const page of siteContent.pages || []) {
132
159
  // Page-level fetch — every declaration on the page.
@@ -137,34 +164,44 @@ export async function executeAllFetches(siteContent, siteDir, onProgress, locale
137
164
  const result = await executeFetch(cfg, optionsFor(cfg, oneFetch))
138
165
  if (result.data && !result.error) {
139
166
  fetchedData.push(entry(cfg, result.data, page.route))
140
- // ⚖️ Dynamic-route expansion consumes ONE query — a `[slug]` template
141
- // expands over a single record set. With several declared, the first
142
- // that prerenders is the route query, matching `parentSchema` in the
143
- // collector; `expandDynamicPages` is what reads this back.
144
- if (!pageFetchedData.has(page.route)) {
145
- pageFetchedData.set(page.route, {
146
- schema: oneFetch.as,
147
- data: result.data,
148
- })
149
- }
167
+ keep(fetched.pages, page.route, oneFetch.as, result.data)
150
168
  }
151
169
  }
152
170
 
153
171
  // Process section-level fetches (own fetch → parsedContent.data, not cascaded)
154
- await processSectionFetches(page.sections, fetchOptions, onProgress)
172
+ await processSectionFetches(page.sections, fetchOptions, onProgress, (as, data) => keep(fetched.sections, page.route, as, data))
155
173
  }
156
174
 
157
- return { pageFetchedData, fetchedData }
175
+ /**
176
+ * Read one config the runtime resolved for an expanded parametric page — a view
177
+ * its route binds — into a plain entry; `readRouteBoundViews` files it under
178
+ * each page that asks for it. Local files only: a remote `url:` is the
179
+ * browser's, as it is by default. Null when there is nothing to embed.
180
+ */
181
+ const bake = async (cfg) => {
182
+ if (cfg.prerender === false || typeof cfg.path !== 'string') return null
183
+ const options = isNonDefaultLocale && cfg.path.startsWith(`/${localeInfo.locale}/`) ? localizedFetchOptions : fetchOptions
184
+ const result = await executeFetch(cfg, options)
185
+ if (!result.data || result.error) return null
186
+ return { config: cfg, data: result.data, meta: { whole: cfg.whole } }
187
+ }
188
+
189
+ return { fetched, fetchedData, bake }
158
190
  }
159
191
 
160
192
  /**
161
- * Expand dynamic pages into concrete pages based on fetched data
162
- * A dynamic page like /blog/:slug with parent data [{ slug: 'post-1' }, { slug: 'post-2' }]
163
- * becomes /blog/post-1 and /blog/post-2
193
+ * Expand parametric pages into concrete pages over their route query's records.
194
+ * A parametric page like /blog/:slug whose route query holds
195
+ * [{ slug: 'post-1' }, { slug: 'post-2' }] becomes /blog/post-1 and /blog/post-2.
164
196
  *
165
197
  * @param {Array} pages - Original pages array
166
- * @param {Map} pageFetchedData - Map of route -> { schema, data }
198
+ * @param {{ site?: Map, pages?: Map, sections?: Map }} fetched - what each level
199
+ * fetched, by binding key (`executeAllFetches`): `site` key → data; `pages` and
200
+ * `sections` route → (key → data)
167
201
  * @param {function} onProgress - Progress callback
202
+ * @param {Object} [stats] - receives `unrouted[route]`, the records with no param value
203
+ * @param {Object} [options]
204
+ * @param {Object|Array|null} [options.siteFetch] - the site's `fetch`, the last level of a route query
168
205
  * @returns {Array} Expanded pages array with dynamic pages replaced by concrete instances
169
206
  */
170
207
  /**
@@ -202,7 +239,7 @@ export function localizeRedirectTarget(target, { website, locale, isDefault, rou
202
239
  return (website.basePath || '') + withSlash
203
240
  }
204
241
 
205
- export function expandDynamicPages(pages, pageFetchedData, onProgress = () => {}, stats = { unrouted: {} }) {
242
+ export function expandDynamicPages(pages, fetched, onProgress = () => {}, stats = { unrouted: {} }, { siteFetch = null } = {}) {
206
243
  if (!stats.unrouted) stats.unrouted = {}
207
244
  const expandedPages = []
208
245
 
@@ -216,6 +253,13 @@ export function expandDynamicPages(pages, pageFetchedData, onProgress = () => {}
216
253
  const staticRoutes = new Set(
217
254
  pages.filter((p) => !p.isDynamic).map((p) => p.route)
218
255
  )
256
+ const byRoute = new Map(pages.filter((p) => p?.route).map((p) => [p.route, p]))
257
+ const has = (route) => byRoute.has(route)
258
+ const levels = {
259
+ site: fetched?.site ?? new Map(),
260
+ pages: fetched?.pages ?? new Map(),
261
+ sections: fetched?.sections ?? new Map(),
262
+ }
219
263
 
220
264
  for (const page of pages) {
221
265
  if (!page.isDynamic) {
@@ -224,33 +268,55 @@ export function expandDynamicPages(pages, pageFetchedData, onProgress = () => {}
224
268
  continue
225
269
  }
226
270
 
227
- // Dynamic page - expand based on parent's data
228
- const { paramName, parentSchema } = page
271
+ // THE ROUTE QUERY, by the rule every lane reads it with (`routeQuery`,
272
+ // `@uniweb/core/fetch-config`), off the parent every lane finds
273
+ // (`parentRouteOf`) — the page's own query, its parent's, the site's, or its
274
+ // sections' shared key. The records it expands over are that query's, at the
275
+ // level it came from. ⛔ Until 2026-09-11 this read `parentSchema` and always
276
+ // expanded over the parent route's first prerendered fetch.
277
+ const parentRoute = parentRouteOf(page.route, { declared: page.parent ?? null, has })
278
+ const parent = parentRoute ? byRoute.get(parentRoute) : null
279
+ const route = routeQuery({
280
+ page: page.fetch,
281
+ parent: parent?.fetch,
282
+ site: siteFetch,
283
+ sections: sectionFetches(page.sections),
284
+ })
229
285
 
230
- if (!parentSchema) {
231
- onProgress(` Warning: Dynamic page ${page.route} has no parentSchema, keeping as template for runtime`)
286
+ if (!route) {
287
+ onProgress(` Keeping ${page.route} for runtime — no query for its URL to narrow`)
232
288
  expandedPages.push(page)
233
289
  continue
234
290
  }
235
291
 
236
- // Find the parent's data
237
- // The parent route is the route without the :param (or :path*) suffix
238
- const catchAll = /\/:path\*$/.test(page.route)
239
- const parentRoute = page.route.replace(/\/:[\w]+\*?$/, '') || '/'
240
- const parentData = pageFetchedData.get(parentRoute)
292
+ const data = route.level === 'page' ? levels.pages.get(page.route)?.get(route.key)
293
+ : route.level === 'parent' ? levels.pages.get(parentRoute)?.get(route.key)
294
+ : route.level === 'site' ? levels.site.get(route.key)
295
+ : levels.sections.get(page.route)?.get(route.key)
241
296
 
242
- if (!parentData || !Array.isArray(parentData.data)) {
243
- // No build-time data available (e.g., prerender: false on parent fetch).
297
+ if (!Array.isArray(data)) {
298
+ // No build-time data available (e.g., prerender: false on the route query).
244
299
  // Keep the dynamic template so the runtime can match it client-side.
245
300
  onProgress(` Keeping dynamic template ${page.route} for runtime (no build-time data)`)
246
301
  expandedPages.push(page)
247
302
  continue
248
303
  }
249
304
 
250
- const items = parentData.data
251
- const schema = parentData.schema
305
+ const paramName = routeParamName(page.route, page.paramName)
306
+ const { paramNames, catchAll } = routePatternToRegex(page.route)
252
307
 
253
- onProgress(` Expanding ${page.route} ${items.length} pages from ${schema}`)
308
+ // A route with a parameter the records cannot fill — `/orgs/:org/members/:slug`
309
+ // expanded over one query knows no `org` — is matched in the browser.
310
+ if (paramNames.length > 1) {
311
+ onProgress(` Keeping ${page.route} for runtime — it has more than one route parameter`)
312
+ expandedPages.push(page)
313
+ continue
314
+ }
315
+
316
+ const items = data
317
+ const key = route.key
318
+
319
+ onProgress(` Expanding ${page.route} → ${items.length} pages from ${key}`)
254
320
 
255
321
  // ⛔ COUNTED, not only logged per record. A record with no value for the
256
322
  // route's param gets no page — correct — but "Skipping item without slug"
@@ -261,12 +327,15 @@ export function expandDynamicPages(pages, pageFetchedData, onProgress = () => {}
261
327
 
262
328
  // Create a concrete page for each item
263
329
  for (const item of items) {
264
- // Get the param value from the item (e.g., item.slug for :slug)
265
- const paramValue = item[paramName]
266
- if (!paramValue) {
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 === '') {
267
335
  unrouted += 1
268
336
  continue
269
337
  }
338
+ const paramValue = String(raw)
270
339
 
271
340
  // Create concrete route: /blog/:slug → /blog/my-post. Under `[...path]` the
272
341
  // record's URL is its placement (the folder `records.yml` put it in, carried
@@ -274,7 +343,7 @@ export function expandDynamicPages(pages, pageFetchedData, onProgress = () => {}
274
343
  // decoded: the server decodes the request before looking the file up.
275
344
  const capture = catchAll ? joinPathCapture({ dir: item.path, slug: paramValue }) : null
276
345
  const concreteRoute = catchAll
277
- ? page.route.replace(/:path\*$/, capture)
346
+ ? page.route.replace(new RegExp(`:${catchAll}\\*$`), capture)
278
347
  : page.route.replace(`:${paramName}`, paramValue)
279
348
 
280
349
  // Static sibling wins: skip a record whose concrete route collides with
@@ -289,24 +358,21 @@ export function expandDynamicPages(pages, pageFetchedData, onProgress = () => {}
289
358
  concretePage.route = concreteRoute
290
359
  concretePage.isDynamic = false // No longer dynamic
291
360
  concretePage.paramName = undefined
292
- concretePage.parentSchema = undefined
293
-
294
- // Store the dynamic route context for runtime data resolution. Only the
295
- // keys the runtime actually uses the entity cascade re-finds the record
296
- // from the fetched collection by paramName/paramValue/schema. The record
297
- // (`currentItem`) and the full sibling list (`allItems`) are deliberately
298
- // NOT baked in: nothing reads them (the documented dynamicContext shape is
299
- // { paramName, paramValue, schema }; the record is delivered via
300
- // content.data and siblings via `fetch: { refine: true, detail: false }`),
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 }`,
301
368
  // and embedding `allItems` duplicated the whole collection onto every
302
369
  // prerendered page in split mode.
370
+ const binding = routeBinding(page.route, catchAll ? { [catchAll]: capture } : { [paramName]: paramValue }, paramName)
303
371
  concretePage.dynamicContext = {
304
- paramName,
305
- paramValue,
306
- schema, // Plural: 'articles'
307
- // A catch-all page carries its three variables, so a query binding
308
- // `:dir` or `:path` resolves the same way it does in the browser.
309
- ...(catchAll ? { params: splitPathCapture(capture) } : {}),
372
+ templateRoute: page.route,
373
+ params: binding.variables,
374
+ paramName: binding.paramName,
375
+ paramValue: binding.paramValue,
310
376
  }
311
377
 
312
378
  // Use item data for page metadata if available
@@ -319,7 +385,7 @@ export function expandDynamicPages(pages, pageFetchedData, onProgress = () => {}
319
385
  if (unrouted > 0) {
320
386
  stats.unrouted[page.route] = unrouted
321
387
  onProgress(
322
- ` ⚠️ ${unrouted} of ${items.length} ${schema} records have no "${paramName}" — no page was ` +
388
+ ` ⚠️ ${unrouted} of ${items.length} ${key} records have no "${paramName}" — no page was ` +
323
389
  `generated for them under ${page.route}`
324
390
  )
325
391
  }
@@ -336,7 +402,7 @@ export function expandDynamicPages(pages, pageFetchedData, onProgress = () => {}
336
402
  * @param {Object} fetchOptions - Options for executeFetch
337
403
  * @param {function} onProgress - Progress callback
338
404
  */
339
- async function processSectionFetches(sections, fetchOptions, onProgress) {
405
+ async function processSectionFetches(sections, fetchOptions, onProgress, record = null) {
340
406
  if (!sections || !Array.isArray(sections)) return
341
407
 
342
408
  for (const section of sections) {
@@ -348,6 +414,9 @@ async function processSectionFetches(sections, fetchOptions, onProgress) {
348
414
  onProgress(` Fetching section data: ${sectionFetch.path || sectionFetch.url}`)
349
415
  const result = await executeFetch(sectionFetch, fetchOptions)
350
416
  if (result.data && !result.error) {
417
+ // What a section fetched, by key — the route query of a parametric page
418
+ // whose sections alone declare it (`routeQuery`).
419
+ if (record) record(sectionFetch.as, result.data)
351
420
  section.parsedContent = mergeDataIntoContent(
352
421
  section.parsedContent || {},
353
422
  result.data,
@@ -359,7 +428,7 @@ async function processSectionFetches(sections, fetchOptions, onProgress) {
359
428
 
360
429
  // Process subsections recursively
361
430
  if (section.subsections && section.subsections.length > 0) {
362
- await processSectionFetches(section.subsections, fetchOptions, onProgress)
431
+ await processSectionFetches(section.subsections, fetchOptions, onProgress, record)
363
432
  }
364
433
  }
365
434
  }
@@ -425,7 +494,7 @@ async function discoverLocaleContents(distDir, defaultContent) {
425
494
  * `{ config, data }` shape the runtime's hydrateDataStore expects.
426
495
  */
427
496
  function stripFetchScope(entry) {
428
- const { _scope, ...clean } = entry
497
+ const { _scope, _routeBound, ...clean } = entry
429
498
  return clean
430
499
  }
431
500
 
@@ -448,14 +517,66 @@ function stripFetchScope(entry) {
448
517
  * @param {Set<string>|null} scopeRoutes - Routes whose entries to keep, or null.
449
518
  * @returns {Array<{config: Object, data: any}>}
450
519
  */
451
- export function scopeFetchedData(fetchedData, scopeRoutes) {
520
+ export function scopeFetchedData(fetchedData, scopeRoutes, currentRoute = null) {
452
521
  if (!Array.isArray(fetchedData)) return fetchedData
453
- if (!scopeRoutes) return fetchedData.map(stripFetchScope)
522
+ // A route-bound entry (`readRouteBoundViews`) belongs to one expanded page, in
523
+ // either mode: carried everywhere, a site of N such pages would embed N views —
524
+ // or N whole records — in every page.
525
+ const own = (e) => !e._routeBound || e._scope === currentRoute
526
+ if (!scopeRoutes) return fetchedData.filter(own).map(stripFetchScope)
454
527
  return fetchedData
455
- .filter((e) => e._scope === '__site__' || scopeRoutes.has(e._scope))
528
+ .filter((e) => own(e) && (e._scope === '__site__' || scopeRoutes.has(e._scope)))
456
529
  .map(stripFetchScope)
457
530
  }
458
531
 
532
+ /**
533
+ * The views an expanded parametric page binds to its route — `scope: :dir` bound to
534
+ * its branch, a `deferred:` query's per-record file — that no list page asked for.
535
+ * Each is resolved by the RUNTIME's own rule for the page (`resolvePageFetchConfigs`,
536
+ * the one a host's prefetch calls, matched against the parametric page it came
537
+ * from) and read by `read`, so the page renders complete and the SPA hydrates the
538
+ * very keys it asks for.
539
+ *
540
+ * ⭐ ONE ENTRY PER PAGE, ONE READ PER VIEW. Each entry is tagged `_routeBound` with
541
+ * its page's route: `scopeFetchedData` embeds it in that page's HTML and nowhere
542
+ * else, and the split-mode manifest leaves it out — so N expanded pages do not
543
+ * carry N views (or N whole records) each. Pages that bind the same view (two
544
+ * entries in one branch) each get an entry, over a single read. ⛔ Filed under
545
+ * the first page that asked for it, a shared view reached no other page's HTML
546
+ * (measured: the second entry in a branch shipped without its branch's view).
547
+ *
548
+ * A key already `present` is not read: every page carries those already — the
549
+ * site's, and, in split mode, its parent's and its template's (the render loop's
550
+ * `scopeRoutes`).
551
+ *
552
+ * @param {Object} options
553
+ * @param {Object} options.templates - the content as it was before expansion (the parametric pages)
554
+ * @param {Array<Object>} options.pages - the pages after expansion
555
+ * @param {Array<Object>} options.present - the entries already baked
556
+ * @param {Function} options.resolvePageFetchConfigs - `@uniweb/runtime/ssr`'s
557
+ * @param {(cfg: Object) => Promise<{config: Object, data: any, meta?: Object}|null>} options.read - reads one config
558
+ * @param {string|null} [options.locale]
559
+ * @returns {Promise<Array<Object>>} the new entries, each for its own page
560
+ */
561
+ export async function readRouteBoundViews({ templates, pages, present, resolvePageFetchConfigs, read, locale = null }) {
562
+ const carried = new Set((present || []).map((e) => deriveCacheKey(e.config)))
563
+ const reads = new Map()
564
+ const out = []
565
+ for (const page of pages || []) {
566
+ if (!page?.dynamicContext) continue
567
+ const filed = new Set()
568
+ for (const cfg of resolvePageFetchConfigs(templates, page.route, { locale })) {
569
+ const key = deriveCacheKey(cfg)
570
+ if (carried.has(key) || filed.has(key)) continue
571
+ filed.add(key)
572
+ if (!reads.has(key)) reads.set(key, read(cfg))
573
+ const view = await reads.get(key)
574
+ if (view) out.push({ ...view, _scope: page.route, _routeBound: true })
575
+ }
576
+ }
577
+ return out
578
+ }
579
+
459
580
  /**
460
581
  * Inject build-specific data into HTML (theme CSS, __SITE_CONTENT__, icon cache).
461
582
  * Called after the shared injectPageContent for build-specific additions.
@@ -518,7 +639,7 @@ export function injectBuildData(html, siteContent, { splitContent = false, curre
518
639
  if (Array.isArray(contentForJson.fetchedData)) {
519
640
  contentForJson = {
520
641
  ...contentForJson,
521
- fetchedData: scopeFetchedData(contentForJson.fetchedData, splitContent ? scopeRoutes : null),
642
+ fetchedData: scopeFetchedData(contentForJson.fetchedData, splitContent ? scopeRoutes : null, currentRoute),
522
643
  }
523
644
  }
524
645
 
@@ -607,6 +728,7 @@ export async function prerenderSite(siteDir, options = {}) {
607
728
  prefetchIcons,
608
729
  createPageRenderer,
609
730
  generate404Html,
731
+ resolvePageFetchConfigs,
610
732
  } = await import('@uniweb/runtime/ssr')
611
733
 
612
734
  // Load default site content
@@ -674,7 +796,7 @@ export async function prerenderSite(siteDir, options = {}) {
674
796
  // For non-default locales, collection data is read from dist/{locale}/data/
675
797
  onProgress('Executing data fetches...')
676
798
  const defaultLocale = resolveDefaultLocale(defaultSiteContent.config)
677
- const { pageFetchedData, fetchedData } = await executeAllFetches(
799
+ const { fetched, fetchedData, bake } = await executeAllFetches(
678
800
  siteContent, siteDir, onProgress,
679
801
  { locale, defaultLocale, distDir }
680
802
  )
@@ -690,7 +812,33 @@ export async function prerenderSite(siteDir, options = {}) {
690
812
  // Expand dynamic pages (e.g., /blog/:slug → /blog/post-1, /blog/post-2)
691
813
  if (siteContent.pages?.some(p => p.isDynamic)) {
692
814
  onProgress('Expanding dynamic routes...')
693
- siteContent.pages = expandDynamicPages(siteContent.pages, pageFetchedData, onProgress)
815
+ const templates = { ...siteContent, pages: siteContent.pages }
816
+ siteContent.pages = expandDynamicPages(siteContent.pages, fetched, onProgress, undefined, {
817
+ siteFetch: siteContent.config?.fetch ?? null,
818
+ })
819
+
820
+ // ⭐ AN EXPANDED PAGE ASKS FOR WHAT ITS ROUTE BINDS. Its sections read views
821
+ // no list page asked for — `scope: :dir` bound to its branch, a `deferred:`
822
+ // query's per-record file — and those are resolved here by the RUNTIME's own
823
+ // rule for a page (`resolvePageFetchConfigs`, the one a host's prefetch
824
+ // calls, matched against the parametric page it came from), then read by
825
+ // this build's executor. So the page renders complete, and the SPA hydrates
826
+ // the very keys it asks for.
827
+ // ⛔ Appended to `siteContent.fetchedData` — the copy `stripBuildOnlyFetchKeys`
828
+ // made above — never to the raw `fetchedData`, whose configs still carry the
829
+ // build-only `merge` (it leaked into shipped pages that way, measured).
830
+ const baked = await readRouteBoundViews({
831
+ templates,
832
+ pages: siteContent.pages,
833
+ present: siteContent.fetchedData,
834
+ resolvePageFetchConfigs,
835
+ read: bake,
836
+ locale,
837
+ })
838
+ if (baked.length > 0) {
839
+ siteContent.fetchedData = [...siteContent.fetchedData, ...baked]
840
+ onProgress(` Read ${baked.length} route-bound view(s) for expanded pages`)
841
+ }
694
842
  }
695
843
 
696
844
  // Determine whether to split content (after dynamic expansion, after data fetches)
@@ -752,7 +900,9 @@ export async function prerenderSite(siteDir, options = {}) {
752
900
  // hydrateDataStore handles cache-key derivation + value-shape wrapping
753
901
  // — same helper used by the browser SPA boot and by the Cloudflare
754
902
  // Worker SSR isolate, so all three render paths agree on shape.
755
- hydrateDataStore(uniweb.activeWebsite, fetchedData)
903
+ // ⛔ `siteContent.fetchedData`, not the raw list: only it holds the route-bound
904
+ // views, and an expanded page rendered without them paints "not found" (measured).
905
+ hydrateDataStore(uniweb.activeWebsite, siteContent.fetchedData)
756
906
 
757
907
  // Pre-fetch icons for SSR embedding
758
908
  await prefetchIcons(siteContent, uniweb, onProgress)
@@ -846,7 +996,9 @@ export async function prerenderSite(siteDir, options = {}) {
846
996
  // Build-specific: theme CSS, __SITE_CONTENT__, icon cache.
847
997
  // scopeRoutes mirrors the runtime data cascade (page → page.parent → site)
848
998
  // so split-mode pages embed only the collection data their first render reads.
849
- const scopeRoutes = new Set([page.route, page.parent?.route].filter(Boolean))
999
+ // An expanded page's own fetch was read under its TEMPLATE's route — the
1000
+ // route it had when the fetches ran — so that route is in its cascade too.
1001
+ const scopeRoutes = new Set([page.route, page.parent?.route, page.dynamicContext?.templateRoute].filter(Boolean))
850
1002
  html = injectBuildData(html, siteContent, {
851
1003
  splitContent,
852
1004
  currentRoute: page.route,
@@ -892,9 +1044,10 @@ export async function prerenderSite(siteDir, options = {}) {
892
1044
  })
893
1045
  }
894
1046
  // The manifest is a single (non-per-page) file, so it keeps all fetched
895
- // data — but the internal `_scope` tag must never leak into it.
1047
+ // data — but the internal `_scope` tag must never leak into it, and a
1048
+ // route-bound entry belongs to its own page's HTML, not to every page.
896
1049
  if (Array.isArray(manifest.fetchedData)) {
897
- manifest.fetchedData = manifest.fetchedData.map(stripFetchScope)
1050
+ manifest.fetchedData = manifest.fetchedData.filter((e) => !e?._routeBound).map(stripFetchScope)
898
1051
  }
899
1052
  await writeFile(localeContentPath, JSON.stringify(manifest))
900
1053
  onProgress('Rewrote site-content.json as lightweight manifest')
@@ -30,7 +30,7 @@ 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
  // ─────────────────────────────────────────────────────────────────
@@ -866,7 +911,6 @@ async function processFileAsPage(filePath, fileName, siteRoot, parentRoute) {
866
911
  lastModified: fileStat.mtime?.toISOString() || null,
867
912
  isDynamic: false,
868
913
  paramName: null,
869
- parentSchema: null,
870
914
  version: null,
871
915
  versionMeta: null,
872
916
  versionScope: null,
@@ -1467,12 +1511,19 @@ async function processPage(pagePath, pageName, siteRoot, { isIndex = false, pare
1467
1511
  // Determine route
1468
1512
  // Index pages get the parent route as their canonical route (no dual routes)
1469
1513
  // sourcePath stores the original folder-based path for ancestor checking
1470
- const isDynamic = isDynamicRoute(pageName)
1471
- const paramName = isDynamic ? extractRouteParam(pageName) : null
1514
+ // A page is PARAMETRIC when its folder is a bracket name or it sits inside
1515
+ // one (ruled 2026-09-11 [Diego]): `pages/members/[slug]/cv/` is `/members/:slug/cv`
1516
+ // and binds its ancestor's `slug`. Every lane — the SPA, the prefetch, the static
1517
+ // build — tells a parametric page by the parameter in its route; this flag says
1518
+ // the same thing to a consumer that reads the flag.
1519
+ const isBracket = isDynamicRoute(pageName)
1520
+ const inheritedParam = isBracket ? null : inheritedRouteParam(parentRoute)
1521
+ const isDynamic = isBracket || inheritedParam !== null
1522
+ const paramName = isBracket ? extractRouteParam(pageName) : inheritedParam
1472
1523
 
1473
1524
  // First, calculate the folder-based route (what the route would be without index handling)
1474
1525
  let folderRoute
1475
- if (isDynamic) {
1526
+ if (isBracket) {
1476
1527
  // Dynamic routes: /blog/[slug] → /blog/:slug (for route matching);
1477
1528
  // /blog/[...path] → /blog/:path* — the one multi-segment token the matcher knows.
1478
1529
  const token = isCatchAllRoute(pageName) ? ':path*' : `:${paramName}`
@@ -1500,23 +1551,13 @@ async function processPage(pagePath, pageName, siteRoot, { isIndex = false, pare
1500
1551
  const layoutObj = mergeLayoutConfig(inheritedLayout, normalizeLayoutConfig(layoutConfig))
1501
1552
  const resolvedLayoutName = layoutObj.name || null
1502
1553
 
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
- }
1554
+ // NO `parentSchema`. Which query a parametric page's URL names one record of
1555
+ // its ROUTE QUERY is worked out where it is read, by one function every lane
1556
+ // calls (`routeQuery`, `@uniweb/core/fetch-config`): the page's own query, its
1557
+ // parent's, the site's, or its sections' shared key. This emitted a copy chosen
1558
+ // by another rule (the closest ancestor with a query at ANY depth, never the
1559
+ // page's own or the site's), so the URL narrowed nothing, or a key no section
1560
+ // received measured 2026-09-10. Removed 2026-09-11 [Diego].
1520
1561
 
1521
1562
  return {
1522
1563
  page: {
@@ -1535,10 +1576,9 @@ async function processPage(pagePath, pageName, siteRoot, { isIndex = false, pare
1535
1576
  : {}),
1536
1577
  lastModified: lastModified?.toISOString(),
1537
1578
 
1538
- // Dynamic route metadata
1579
+ // Parametric route metadata
1539
1580
  isDynamic,
1540
- paramName, // e.g., "slug" from [slug]
1541
- parentSchema, // e.g., "articles" - the data array to iterate over
1581
+ paramName, // e.g., "slug" from [slug]; a nested page's ancestor's
1542
1582
 
1543
1583
  // Version metadata (if within a versioned section)
1544
1584
  version: versionContext?.version || null,
@@ -1872,6 +1912,7 @@ async function collectPagesRecursive(dirPath, parentRoute, siteRoot, orderConfig
1872
1912
  // Process subdirectories
1873
1913
  for (const folder of orderedFolders) {
1874
1914
  const { name: entry, path: entryPath, dirConfig, dirMode, mountedContentMode, childOrderConfig, childLayout } = folder
1915
+ assertRouteFolder(entry, parentRoute)
1875
1916
  const isIndex = entry === indexName
1876
1917
  const effectiveLayout = mergeLayoutConfig(parentLayout, childLayout)
1877
1918
 
@@ -1930,7 +1971,6 @@ async function collectPagesRecursive(dirPath, parentRoute, siteRoot, orderConfig
1930
1971
  lastModified: null,
1931
1972
  isDynamic: false,
1932
1973
  paramName: null,
1933
- parentSchema: null,
1934
1974
  version: versionContext?.version || null,
1935
1975
  versionMeta: versionContext?.versionMeta || null,
1936
1976
  versionScope: versionContext?.scope || null,
@@ -2001,6 +2041,7 @@ async function collectPagesRecursive(dirPath, parentRoute, siteRoot, orderConfig
2001
2041
  // Second pass: process each page folder
2002
2042
  for (const folder of orderedFolders) {
2003
2043
  const { name: entry, path: entryPath, dirConfig, dirMode, mountedContentMode, childOrderConfig, childLayout } = folder
2044
+ assertRouteFolder(entry, parentRoute)
2004
2045
  const isIndex = entry === indexPageName
2005
2046
  const effectiveLayout = mergeLayoutConfig(parentLayout, childLayout)
2006
2047
 
@@ -2024,7 +2065,6 @@ async function collectPagesRecursive(dirPath, parentRoute, siteRoot, orderConfig
2024
2065
  lastModified: null,
2025
2066
  isDynamic: false,
2026
2067
  paramName: null,
2027
- parentSchema: null,
2028
2068
  version: versionContext?.version || null,
2029
2069
  versionMeta: versionContext?.versionMeta || null,
2030
2070
  versionScope: versionContext?.scope || null,
@@ -2835,6 +2875,7 @@ function buildRouteTranslations(pages, { defaultLocale = 'en', languages = null
2835
2875
  }
2836
2876
 
2837
2877
  export {
2878
+ assertRouteFolder,
2838
2879
  buildRouteTranslations,
2839
2880
  extractItemName,
2840
2881
  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
@@ -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,
@@ -54,8 +54,8 @@ import { join, basename, extname, dirname, relative, resolve, sep } from 'node:p
54
54
  import { existsSync } from 'node:fs'
55
55
  import yaml from 'js-yaml'
56
56
  import { parseBibtex } from '@citestyle/bibtex'
57
- import { DATA_DIR, fillRoutePattern } from '@uniweb/core'
58
- import { applyWhere, applySort } from './data-fetcher.js'
57
+ import { DATA_DIR, fillRoutePattern, withoutRouteVariables } from '@uniweb/core'
58
+ import { applyWhere, applySort, refuseUnder } from './data-fetcher.js'
59
59
  import { resolveAssetPath, walkContentAssets, isLocalAssetPath } from './assets.js'
60
60
  import { readEntityPool, groupPoolBySchema, ENTITIES_DIR } from './entity-pool.js'
61
61
  import { readRecordsConfig, resolveFolder, FOLDER_MISSING } from './records-config.js'
@@ -113,6 +113,7 @@ function parseQueryConfig(name, config) {
113
113
  schema: config,
114
114
  url: null,
115
115
  route: null,
116
+ scope: null,
116
117
  sort: null,
117
118
  where: null,
118
119
  filter: null,
@@ -122,6 +123,7 @@ function parseQueryConfig(name, config) {
122
123
  }
123
124
  }
124
125
 
126
+ refuseUnder(config.where, `queries.${name}`)
125
127
  return {
126
128
  name,
127
129
  // The query's schema selects its records from the pool — `entities/{schema}/`
@@ -129,6 +131,9 @@ function parseQueryConfig(name, config) {
129
131
  schema: config.schema || null,
130
132
  url: config.url || null,
131
133
  route: config.route || null,
134
+ // The folder branch the query reads (`records.yml` placement). ⛔ Not read
135
+ // here until 2026-09-11: a named query's `scope` was ignored on this lane.
136
+ scope: typeof config.scope === 'string' ? config.scope : null,
132
137
  sort: config.sort || null,
133
138
  // `where:` is the CANONICAL predicate; `filter:` is the deprecated string DSL
134
139
  // it replaced. Both are carried and both are applied below, in the same order
@@ -673,6 +678,19 @@ async function collectItems(siteDir, config, entitiesDir, basePath) {
673
678
  // Filter out nulls (unpublished items)
674
679
  items = items.filter(Boolean)
675
680
 
681
+ // ⭐ `$name` IS THE RECORD HANDLE ON EVERY SITE (ruled 2026-09-11 [Diego]) — the
682
+ // field a `[slug]` or `[...path]` page matches, and the one the records service
683
+ // serves. It is the record's FINAL slug: set here, after every format has been
684
+ // read and flattened, so a frontmatter `slug:` (which wins over the filename),
685
+ // a BibTeX cite key and an array-form file's own `slug` all count — exactly what
686
+ // our sync sends as the entry's name (`uwx/entity-source.js`). `slug` stays:
687
+ // foundations and templates read it.
688
+ items = items.map((item) => (
689
+ item && typeof item === 'object' && item.slug !== undefined && item.slug !== null && item.slug !== ''
690
+ ? { ...item, $name: String(item.slug) }
691
+ : item
692
+ ))
693
+
676
694
  warnDuplicateSlugs(items, config.name)
677
695
 
678
696
  // `route:` on the query — bake each record's canonical href.
@@ -711,8 +729,19 @@ async function collectItems(siteDir, config, entitiesDir, basePath) {
711
729
  // the sync wire, stored — and never applied, while the DEPRECATED one it replaced
712
730
  // worked. An author following current guidance got silence and shipped unfiltered
713
731
  // data. Pinned by `tests/collection-query-terms.test.js`.
714
- if (config.where) {
715
- items = applyWhere(items, config.where)
732
+ // ⭐ ONLY THE `where` FIXED FOR EVERY PAGE. A clause bound to the route —
733
+ // `where: { tag: :dir }` — cannot be applied to a file written once for every
734
+ // page; the runtime binds it per page (`@uniweb/core/fetch-config`,
735
+ // `resolveQuerySource`). ⛔ Until 2026-09-11 it was applied here to the literal
736
+ // `':dir'`, and the query compiled to no records (measured).
737
+ //
738
+ // ⛔ `scope` is NEVER baked, fixed or routed. The runtime applies the one that
739
+ // wins — a page fetch's own, else this query's — which is what the records
740
+ // service does. Baked here, a page's `scope:` could only narrow inside the
741
+ // query's branch on a static site and would replace it on a hosted one.
742
+ const fixed = withoutRouteVariables({ where: config.where })
743
+ if (fixed.where) {
744
+ items = applyWhere(items, fixed.where)
716
745
  }
717
746
 
718
747
  // Apply sort
package/src/uwx/site.js CHANGED
@@ -53,7 +53,9 @@ import {
53
53
  applyWildcardOrder,
54
54
  processMarkdownFile,
55
55
  fetchFromDataShorthand,
56
+ assertRouteFolder,
56
57
  } from '../site/content-collector.js'
58
+ import { refuseUnder } from '../site/data-fetcher.js'
57
59
  import { normalizeHideIn } from '../site/nav-visibility.js'
58
60
  import { resolveDefaultLocale, validateLanguageConfig, queryDataUrl } from '@uniweb/core'
59
61
  import { emitEntitySyncPackage } from './entity-document.js'
@@ -236,6 +238,10 @@ function buildPageData(config, ctx) {
236
238
  // 2026-09-02 this kept `[0]` and dropped the rest silently, so the wire
237
239
  // carried one dataset for a page that asked for several.
238
240
  let fetch = config.fetch ?? fetchFromDataShorthand(config.data)
241
+ // `where: { path: { under } }` is refused here as the build refuses it
242
+ // (`parseFetchConfig`): a site that cannot build must not sync either. A
243
+ // section's fetch is refused where the collector parses it.
244
+ for (const one of [fetch].flat()) refuseUnder(one?.where, 'fetch')
239
245
  // Resolve the authored `query:` shorthand to the runtime-fetchable
240
246
  // `path: /data/<name>.json` (the static convention the default-fetcher uses).
241
247
  // A shell/backend-hosted site renders client-side with NO prerender, so the
@@ -548,8 +554,13 @@ async function walkPagesNested(ctx, dirPath, parentSlugPath, inheritedMode, pare
548
554
  const { siteRoot, siteIndex, sourceLocale, translations } = ctx
549
555
  const folders = await orderedSubfolders(dirPath, inheritedMode, parentConfig)
550
556
  const out = []
557
+ // A folder inside a `[...path]` folder can never be reached, and `[dir]` /
558
+ // `[path]` would name a route variable — refused here as the collector refuses
559
+ // them, so a site that cannot build cannot sync either (ruled 2026-09-11).
560
+ const insideCatchAll = (parentSlugPath || '').split('/').includes(CATCH_ALL_MARKER)
551
561
  for (let i = 0; i < folders.length; i++) {
552
562
  const f = folders[i]
563
+ assertRouteFolder(f.dirName, insideCatchAll ? '/:path*' : '/')
553
564
  const dyn = f.dirName.match(DYNAMIC_RE)
554
565
  const slug = dyn ? dyn[1] : f.name
555
566
  const mode = f.source === 'folder.yml' ? 'folder' : 'page'
@@ -803,6 +814,7 @@ const DECL_NOT_ON_WIRE = new Set([
803
814
  function queriesNested(declarations, uuids = null, org = null) {
804
815
  const out = []
805
816
  for (const [name, d] of Object.entries(declarations)) {
817
+ refuseUnder(d.where, `queries.${name}`)
806
818
  const data = {}
807
819
  const source = d.path ? { path: d.path } : d.url ? { url: d.url } : d.source
808
820
  setIf(data, 'source', source)
@@ -1071,6 +1083,10 @@ function settingsNested(siteYml, { headHtml, themeYml, sourceLocale, translation
1071
1083
  // ⭐ The site-level fetch, DESUGARED and under its real name. `data:` is the
1072
1084
  // authoring shorthand for `fetch:` and every other tier already calls the wire
1073
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.
1089
+ for (const one of [siteYml.fetch].flat()) refuseUnder(one?.where, 'site.yml fetch')
1074
1090
  setIf(settings, 'fetch', siteYml.fetch ?? fetchFromDataShorthand(siteYml.data))
1075
1091
 
1076
1092
  // ⭐ The SITE TIER of framework's own `{name, hide, params}` layout object, which