@uniweb/build 0.47.1 → 0.48.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.47.1",
3
+ "version": "0.48.0",
4
4
  "description": "Build tooling for the Uniweb Component Web Platform",
5
5
  "type": "module",
6
6
  "exports": {
@@ -57,15 +57,15 @@
57
57
  "js-yaml": "^4.1.0",
58
58
  "sharp": "^0.35.3",
59
59
  "yaml": "^2.5.0",
60
- "@uniweb/semantic-parser": "^1.4.0",
61
60
  "@uniweb/content-reader": "^1.2.4",
61
+ "@uniweb/schemas": "^0.2.13",
62
+ "@uniweb/projections": "^0.7.0",
62
63
  "@uniweb/theming": "^0.1.15",
63
- "@uniweb/projections": "^0.6.2",
64
- "@uniweb/content-writer": "^0.3.4",
65
- "@uniweb/schemas": "^0.2.13"
64
+ "@uniweb/semantic-parser": "^1.4.0",
65
+ "@uniweb/content-writer": "^0.3.4"
66
66
  },
67
67
  "optionalDependencies": {
68
- "@uniweb/runtime": "^0.21.0"
68
+ "@uniweb/runtime": "^0.23.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.26.2"
77
+ "@uniweb/core": "^0.27.0"
78
78
  },
79
79
  "peerDependenciesMeta": {
80
80
  "vite": {
package/src/prerender.js CHANGED
@@ -15,8 +15,8 @@ import {
15
15
  resolveDefaultLocale,
16
16
  resolveFetchConfigs,
17
17
  joinPathCapture,
18
- routeQuery,
19
- sectionFetches,
18
+ pageRouteQuery,
19
+ routeSelection,
20
20
  routeParamValues,
21
21
  routeParamName,
22
22
  routeBinding,
@@ -128,17 +128,55 @@ export async function executeAllFetches(siteContent, siteDir, onProgress, locale
128
128
  // What each level fetched, by binding key — what `expandDynamicPages` iterates.
129
129
  const fetched = { site: new Map(), pages: new Map(), sections: new Map() }
130
130
 
131
+ /**
132
+ * Read one resolved config ONCE, as two things: the records it selects, uncut
133
+ * (`routeSelection`), and the view it delivers — the same records cut by its
134
+ * `limit`. ⭐ The view is what a page renders and hydrates; the selection is what
135
+ * a parametric page expands over, because a `limit` is how many a list shows,
136
+ * never which records have a page (ruled 2026-09-13 [Diego]). ⛔ Until then the
137
+ * expansion read the cut list, so a query's `limit: 2` made two detail pages of
138
+ * five. Null when the read failed.
139
+ */
140
+ const read = async (cfg, oneFetch) => {
141
+ const result = await executeFetch(routeSelection(cfg), optionsFor(cfg, oneFetch))
142
+ if (!result.data || result.error) return null
143
+ const selection = result.data
144
+ const view = Array.isArray(selection) && typeof cfg.limit === 'number' && cfg.limit > 0
145
+ ? selection.slice(0, cfg.limit)
146
+ : selection
147
+ return { selection, view }
148
+ }
149
+
150
+ /**
151
+ * The first binding of a key at one level is the one every lane uses
152
+ * (`resolveFetchConfigs`); a later one under the same key is ignored — and the
153
+ * parser has said so (`warnDuplicateBindings`). Counted BEFORE `prerender: false`
154
+ * is honoured, so a deferred first binding is not replaced by a baked second.
155
+ */
156
+ const firstOfKey = () => {
157
+ const seen = new Set()
158
+ return (as) => {
159
+ if (seen.has(as)) return false
160
+ seen.add(as)
161
+ return true
162
+ }
163
+ }
164
+
131
165
  // 1. Site-level fetch. ⛔ `toFetchList` rather than a property read: a `fetch:`
132
166
  // or `query:` LIST parses to an array, and `siteFetch.prerender` on one is
133
167
  // `undefined` — which passes the `!== false` test and then fetches nothing.
168
+ const firstSite = firstOfKey()
134
169
  for (const oneFetch of toFetchList(siteContent.config?.fetch)) {
135
- if (oneFetch.prerender === false) continue
170
+ if (!firstSite(oneFetch.as)) continue
171
+ // ⭐ `prerender` is read off the RESOLVED config: an external query's binding is the
172
+ // browser's unless it says otherwise, which only the resolver knows.
136
173
  const cfg = resolveForBuild(oneFetch)
174
+ if (cfg.prerender === false) continue
137
175
  onProgress(` Fetching site data: ${cfg.path || cfg.url}`)
138
- const result = await executeFetch(cfg, optionsFor(cfg, oneFetch))
139
- if (result.data && !result.error) {
140
- fetchedData.push(entry(cfg, result.data, '__site__'))
141
- if (!fetched.site.has(oneFetch.as)) fetched.site.set(oneFetch.as, result.data)
176
+ const got = await read(cfg, oneFetch)
177
+ if (got) {
178
+ fetchedData.push(entry(cfg, got.view, '__site__'))
179
+ fetched.site.set(oneFetch.as, got.selection)
142
180
  }
143
181
  }
144
182
 
@@ -157,19 +195,33 @@ export async function executeAllFetches(siteContent, siteDir, onProgress, locale
157
195
 
158
196
  for (const page of siteContent.pages || []) {
159
197
  // Page-level fetch — every declaration on the page.
198
+ const firstPage = firstOfKey()
160
199
  for (const oneFetch of toFetchList(page.fetch)) {
161
- if (oneFetch.prerender === false) continue
200
+ if (!firstPage(oneFetch.as)) continue
162
201
  const cfg = resolveForBuild(oneFetch)
202
+ if (cfg.prerender === false) continue
163
203
  onProgress(` Fetching page data for ${page.route}: ${cfg.path || cfg.url}`)
164
- const result = await executeFetch(cfg, optionsFor(cfg, oneFetch))
165
- if (result.data && !result.error) {
166
- fetchedData.push(entry(cfg, result.data, page.route))
167
- keep(fetched.pages, page.route, oneFetch.as, result.data)
204
+ const got = await read(cfg, oneFetch)
205
+ if (got) {
206
+ fetchedData.push(entry(cfg, got.view, page.route))
207
+ keep(fetched.pages, page.route, oneFetch.as, got.selection)
168
208
  }
169
209
  }
170
210
 
171
- // Process section-level fetches (own fetch → parsedContent.data, not cascaded)
172
- await processSectionFetches(page.sections, fetchOptions, onProgress, (as, data) => keep(fetched.sections, page.route, as, data))
211
+ // Section-level fetches — a section's own, into its `parsedContent.data`.
212
+ // ⛔ Not on a parametric page: there the sections are the runtime's to fill,
213
+ // because what one receives depends on the URL — the record under the route
214
+ // key, a view its route binds under any other. A list baked into the template
215
+ // was cloned into every expanded page and outranked both (measured 2026-09-13:
216
+ // `/blog/b` rendered all three records). Its views are read per expanded page
217
+ // instead (`readRouteBoundViews`).
218
+ await processSectionFetches(page.sections, {
219
+ resolve: resolveForBuild,
220
+ read,
221
+ onProgress,
222
+ record: (as, data) => keep(fetched.sections, page.route, as, data),
223
+ bake: !page.isDynamic,
224
+ })
173
225
  }
174
226
 
175
227
  /**
@@ -179,8 +231,8 @@ export async function executeAllFetches(siteContent, siteDir, onProgress, locale
179
231
  * browser's, as it is by default. Null when there is nothing to embed.
180
232
  */
181
233
  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
234
+ if (cfg.prerender === false || (typeof cfg.path !== 'string' && typeof cfg.url !== 'string')) return null
235
+ const options = isNonDefaultLocale && typeof cfg.path === 'string' && cfg.path.startsWith(`/${localeInfo.locale}/`) ? localizedFetchOptions : fetchOptions
184
236
  const result = await executeFetch(cfg, options)
185
237
  if (!result.data || result.error) return null
186
238
  return { config: cfg, data: result.data, meta: { whole: cfg.whole } }
@@ -196,8 +248,9 @@ export async function executeAllFetches(siteContent, siteDir, onProgress, locale
196
248
  *
197
249
  * @param {Array} pages - Original pages array
198
250
  * @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)
251
+ * selected, by binding key (`executeAllFetches`): `site` key → data; `pages` and
252
+ * `sections` route → (key → data). Every record a binding selects, uncut by its
253
+ * `limit` — a record past a list's count still gets its page
201
254
  * @param {function} onProgress - Progress callback
202
255
  * @param {Object} [stats] - receives `unrouted[route]`, the records with no param value
203
256
  * @param {Object} [options]
@@ -255,6 +308,10 @@ export function expandDynamicPages(pages, fetched, onProgress = () => {}, stats
255
308
  )
256
309
  const byRoute = new Map(pages.filter((p) => p?.route).map((p) => [p.route, p]))
257
310
  const has = (route) => byRoute.has(route)
311
+ const parentOf = (p) => {
312
+ const route = parentRouteOf(p.route, { declared: p.parent ?? null, has })
313
+ return route ? byRoute.get(route) : null
314
+ }
258
315
  const levels = {
259
316
  site: fetched?.site ?? new Map(),
260
317
  pages: fetched?.pages ?? new Map(),
@@ -268,19 +325,21 @@ export function expandDynamicPages(pages, fetched, onProgress = () => {}, stats
268
325
  continue
269
326
  }
270
327
 
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,
328
+ // ⭐ THE ROUTE QUERY, by the rule every lane reads it with (`pageRouteQuery`,
329
+ // `@uniweb/core/fetch-config`), off the parents every lane finds
330
+ // (`parentRouteOf`) — chosen at the page that captured the URL's variable: its
331
+ // own query, its parent's, the site's, or its sections' shared key. A page
332
+ // nested inside a parametric page expands over its capturing page's records.
333
+ // The records it expands over are that query's, at the level it came from.
334
+ // ⛔ Until 2026-09-11 this read `parentSchema` and always expanded over the
335
+ // parent route's first prerendered fetch; until 2026-09-13 a nested page looked
336
+ // one parent up, and expanded only when the `[slug]` page declared the query.
337
+ const route = pageRouteQuery(page, {
338
+ routeOf: (p) => p.route,
339
+ parentOf,
340
+ fetchOf: (p) => p.fetch,
341
+ sectionsOf: (p) => p.sections,
282
342
  site: siteFetch,
283
- sections: sectionFetches(page.sections),
284
343
  })
285
344
 
286
345
  if (!route) {
@@ -289,10 +348,11 @@ export function expandDynamicPages(pages, fetched, onProgress = () => {}, stats
289
348
  continue
290
349
  }
291
350
 
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)
351
+ const capturing = route.capturing
352
+ const data = route.level === 'page' ? levels.pages.get(capturing.route)?.get(route.key)
353
+ : route.level === 'parent' ? levels.pages.get(parentOf(capturing)?.route)?.get(route.key)
294
354
  : route.level === 'site' ? levels.site.get(route.key)
295
- : levels.sections.get(page.route)?.get(route.key)
355
+ : levels.sections.get(capturing.route)?.get(route.key)
296
356
 
297
357
  if (!Array.isArray(data)) {
298
358
  // No build-time data available (e.g., prerender: false on the route query).
@@ -382,7 +442,7 @@ export function expandDynamicPages(pages, fetched, onProgress = () => {}, stats
382
442
  // route. ⛔ No `schema`: the key the URL narrows is worked out where it is
383
443
  // read (deleted 2026-09-11). The record (`currentItem`) and the full sibling
384
444
  // list (`allItems`) are deliberately NOT baked in: the record is delivered
385
- // via content.data and siblings via `fetch: { refine: true, detail: false }`,
445
+ // via content.data and the others via `fetch: { query, current: exclude }`,
386
446
  // and embedding `allItems` duplicated the whole collection onto every
387
447
  // prerendered page in split mode.
388
448
  const binding = routeBinding(page.route, catchAll ? { [catchAll]: capture } : { [paramName]: paramValue }, paramName)
@@ -417,28 +477,45 @@ export function expandDynamicPages(pages, fetched, onProgress = () => {}, stats
417
477
  * Process fetch configs for sections (and subsections recursively)
418
478
  * Section-level fetches merge data into parsedContent.data (not cascaded).
419
479
  *
480
+ * ⭐ RESOLVED BY THE RUNTIME'S RULE, as page fetches are (`resolve`). ⛔ Until
481
+ * 2026-09-13 a section's fetch was executed as authored: a non-default locale baked
482
+ * the default locale's records (measured: a French page's section held the English
483
+ * file), and a named query's routed clauses and, once its `limit` stopped being
484
+ * compiled into the file, its `limit` never applied.
485
+ *
420
486
  * @param {Array} sections - Array of section objects
421
- * @param {Object} fetchOptions - Options for executeFetch
422
- * @param {function} onProgress - Progress callback
487
+ * @param {Object} options
488
+ * @param {(cfg: Object) => Object} options.resolve - resolves one binding
489
+ * @param {(cfg: Object, authored: Object) => Promise<{selection: any, view: any}|null>} options.read
490
+ * @param {function} options.onProgress - Progress callback
491
+ * @param {(as: string, data: any) => void} [options.record] - receives each key's selection
492
+ * @param {boolean} [options.bake=true] - write the view into `parsedContent.data`
423
493
  */
424
- async function processSectionFetches(sections, fetchOptions, onProgress, record = null) {
494
+ async function processSectionFetches(sections, { resolve, read, onProgress, record = null, bake = true }) {
425
495
  if (!sections || !Array.isArray(sections)) return
426
496
 
427
497
  for (const section of sections) {
428
498
  // Execute every section-level fetch. Each merges under its own key, so
429
499
  // several accumulate into one `parsedContent.data` — the same keyed map the
430
- // runtime's EntityStore builds.
500
+ // runtime's EntityStore builds. ⛔ The FIRST binding of a key, as there: each
501
+ // replaced the last until 2026-09-13, so the build delivered the last binding
502
+ // and the runtime the first.
503
+ const seen = new Set()
431
504
  for (const sectionFetch of toFetchList(section.fetch)) {
432
- if (sectionFetch.prerender === false) continue
433
- onProgress(` Fetching section data: ${sectionFetch.path || sectionFetch.url}`)
434
- const result = await executeFetch(sectionFetch, fetchOptions)
435
- if (result.data && !result.error) {
436
- // What a section fetched, by key — the route query of a parametric page
437
- // whose sections alone declare it (`routeQuery`).
438
- if (record) record(sectionFetch.as, result.data)
505
+ if (seen.has(sectionFetch.as)) continue
506
+ seen.add(sectionFetch.as)
507
+ const cfg = resolve(sectionFetch)
508
+ if (cfg.prerender === false) continue
509
+ onProgress(` Fetching section data: ${cfg.path || cfg.url}`)
510
+ const got = await read(cfg, sectionFetch)
511
+ if (!got) continue
512
+ // What a section fetched, by key — the route query of a parametric page
513
+ // whose sections alone declare it (`routeQuery`).
514
+ if (record) record(sectionFetch.as, got.selection)
515
+ if (bake) {
439
516
  section.parsedContent = mergeDataIntoContent(
440
517
  section.parsedContent || {},
441
- result.data,
518
+ got.view,
442
519
  sectionFetch.as,
443
520
  sectionFetch.merge
444
521
  )
@@ -447,7 +524,7 @@ async function processSectionFetches(sections, fetchOptions, onProgress, record
447
524
 
448
525
  // Process subsections recursively
449
526
  if (section.subsections && section.subsections.length > 0) {
450
- await processSectionFetches(section.subsections, fetchOptions, onProgress, record)
527
+ await processSectionFetches(section.subsections, { resolve, read, onProgress, record, bake })
451
528
  }
452
529
  }
453
530
  }