@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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@uniweb/build",
3
- "version": "0.44.5",
3
+ "version": "0.46.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/content-reader": "^1.2.4",
61
- "@uniweb/content-writer": "^0.3.4",
62
- "@uniweb/projections": "^0.6.0",
63
60
  "@uniweb/semantic-parser": "^1.4.0",
61
+ "@uniweb/theming": "^0.1.15",
64
62
  "@uniweb/schemas": "^0.2.13",
65
- "@uniweb/theming": "^0.1.15"
63
+ "@uniweb/content-reader": "^1.2.4",
64
+ "@uniweb/projections": "^0.6.1",
65
+ "@uniweb/content-writer": "^0.3.4"
66
66
  },
67
67
  "optionalDependencies": {
68
- "@uniweb/runtime": "^0.19.5"
68
+ "@uniweb/runtime": "^0.20.1"
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.1"
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,9 +125,11 @@ 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
- // 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
117
133
  // `undefined` — which passes the `!== false` test and then fetches nothing.
118
134
  for (const oneFetch of toFetchList(siteContent.config?.fetch)) {
119
135
  if (oneFetch.prerender === false) continue
@@ -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')