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