@uniweb/build 0.46.0 → 0.47.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.46.0",
3
+ "version": "0.47.0",
4
4
  "description": "Build tooling for the Uniweb Component Web Platform",
5
5
  "type": "module",
6
6
  "exports": {
@@ -58,14 +58,14 @@
58
58
  "sharp": "^0.35.3",
59
59
  "yaml": "^2.5.0",
60
60
  "@uniweb/semantic-parser": "^1.4.0",
61
- "@uniweb/theming": "^0.1.15",
62
- "@uniweb/schemas": "^0.2.13",
63
61
  "@uniweb/content-reader": "^1.2.4",
64
- "@uniweb/projections": "^0.6.1",
65
- "@uniweb/content-writer": "^0.3.4"
62
+ "@uniweb/theming": "^0.1.15",
63
+ "@uniweb/projections": "^0.6.2",
64
+ "@uniweb/content-writer": "^0.3.4",
65
+ "@uniweb/schemas": "^0.2.13"
66
66
  },
67
67
  "optionalDependencies": {
68
- "@uniweb/runtime": "^0.20.1"
68
+ "@uniweb/runtime": "^0.20.3"
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.25.1"
77
+ "@uniweb/core": "^0.26.1"
78
78
  },
79
79
  "peerDependenciesMeta": {
80
80
  "vite": {
package/src/prerender.js CHANGED
@@ -17,7 +17,7 @@ import {
17
17
  joinPathCapture,
18
18
  routeQuery,
19
19
  sectionFetches,
20
- routeParamValue,
20
+ routeParamValues,
21
21
  routeParamName,
22
22
  routeBinding,
23
23
  parentRouteOf,
@@ -324,62 +324,81 @@ export function expandDynamicPages(pages, fetched, onProgress = () => {}, stats
324
324
  // records and names three gets three pages and no idea why. The total is
325
325
  // said once at the end, and handed back on `stats` for a caller to assert.
326
326
  let unrouted = 0
327
+ // route → the value that claimed it, so a second claim is reported, not silent
328
+ const claimed = new Map()
327
329
 
328
330
  // Create a concrete page for each item
329
331
  for (const item of items) {
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 === '') {
332
+ // EVERY value the record answers to — one for a scalar, one per member for a
333
+ // `multi` field (`routeParamValues`, the map every lane matches through:
334
+ // `[slug]` its handle, `[uuid]` its identity, any other name its field). A
335
+ // record holding `['a','b']` gets /tags/a AND /tags/b; a `multi` holding one
336
+ // value the case the rule is for gets exactly one page. Ruled 2026-09-12
337
+ // [Diego]. ⛔ This read `item[paramName]` until 2026-09-11 and the whole array
338
+ // until 2026-09-12, which baked `/tags/a%2Cb`, a URL no lane matches.
339
+ const values = routeParamValues(item, paramName)
340
+ if (values.length === 0) {
335
341
  unrouted += 1
336
342
  continue
337
343
  }
338
- const paramValue = String(raw)
339
-
340
- // Create concrete route: /blog/:slug → /blog/my-post. Under `[...path]` the
341
- // record's URL is its placement (the folder `records.yml` put it in, carried
342
- // as `path`) plus its handle — the split rule in reverse. ⛔ A FILE PATH, so
343
- // decoded: the server decodes the request before looking the file up.
344
- const capture = catchAll ? joinPathCapture({ dir: item.path, slug: paramValue }) : null
345
- const concreteRoute = catchAll
346
- ? page.route.replace(new RegExp(`:${catchAll}\\*$`), capture)
347
- : page.route.replace(`:${paramName}`, paramValue)
348
-
349
- // Static sibling wins: skip a record whose concrete route collides with
350
- // an existing static page rather than overwriting its HTML at write time.
351
- if (staticRoutes.has(concreteRoute)) {
352
- onProgress(` Skipping ${concreteRoute}a static page already claims this route (${paramName}:'${paramValue}')`)
353
- continue
354
- }
344
+ for (const paramValue of values) {
345
+
346
+ // Create concrete route: /blog/:slug → /blog/my-post. Under `[...path]` the
347
+ // record's URL is its placement (the folder `records.yml` put it in, carried
348
+ // as `path`) plus its handle — the split rule in reverse. ⛔ A FILE PATH, so
349
+ // decoded: the server decodes the request before looking the file up.
350
+ const capture = catchAll ? joinPathCapture({ dir: item.path, slug: paramValue }) : null
351
+ const concreteRoute = catchAll
352
+ ? page.route.replace(new RegExp(`:${catchAll}\\*$`), capture)
353
+ : page.route.replace(`:${paramName}`, paramValue)
354
+
355
+ // TWO RECORDS, ONE ROUTE normal the moment the route field is not
356
+ // unique, which a `multi` member shared by two records makes easy. The first
357
+ // wins; WHICH is first is this lane's record order, and a hosted site orders
358
+ // by its own store so it is said out loud here rather than discovered as a
359
+ // different record on the same URL.
360
+ const claimant = claimed.get(concreteRoute)
361
+ if (claimant !== undefined) {
362
+ onProgress(` ⚠️ ${concreteRoute} is claimed by more than one ${key} record (${paramName}: '${claimant}', '${paramValue}') — the first keeps it`)
363
+ continue
364
+ }
365
+ claimed.set(concreteRoute, paramValue)
355
366
 
356
- // Deep clone the page with modifications
357
- const concretePage = JSON.parse(JSON.stringify(page))
358
- concretePage.route = concreteRoute
359
- concretePage.isDynamic = false // No longer dynamic
360
- concretePage.paramName = undefined
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 }`,
368
- // and embedding `allItems` duplicated the whole collection onto every
369
- // prerendered page in split mode.
370
- const binding = routeBinding(page.route, catchAll ? { [catchAll]: capture } : { [paramName]: paramValue }, paramName)
371
- concretePage.dynamicContext = {
372
- templateRoute: page.route,
373
- params: binding.variables,
374
- paramName: binding.paramName,
375
- paramValue: binding.paramValue,
376
- }
367
+ // Static sibling wins: skip a record whose concrete route collides with
368
+ // an existing static page rather than overwriting its HTML at write time.
369
+ if (staticRoutes.has(concreteRoute)) {
370
+ onProgress(` Skipping ${concreteRoute} a static page already claims this route (${paramName}:'${paramValue}')`)
371
+ continue
372
+ }
373
+
374
+ // Deep clone the page with modifications
375
+ const concretePage = JSON.parse(JSON.stringify(page))
376
+ concretePage.route = concreteRoute
377
+ concretePage.isDynamic = false // No longer dynamic
378
+ concretePage.paramName = undefined
379
+
380
+ // The route's binding, as the SPA makes it (`routeBinding`): the three
381
+ // variables a query binds, the param and its value, and the template's
382
+ // route. No `schema`: the key the URL narrows is worked out where it is
383
+ // read (deleted 2026-09-11). The record (`currentItem`) and the full sibling
384
+ // list (`allItems`) are deliberately NOT baked in: the record is delivered
385
+ // via content.data and siblings via `fetch: { refine: true, detail: false }`,
386
+ // and embedding `allItems` duplicated the whole collection onto every
387
+ // prerendered page in split mode.
388
+ const binding = routeBinding(page.route, catchAll ? { [catchAll]: capture } : { [paramName]: paramValue }, paramName)
389
+ concretePage.dynamicContext = {
390
+ templateRoute: page.route,
391
+ params: binding.variables,
392
+ paramName: binding.paramName,
393
+ paramValue: binding.paramValue,
394
+ }
377
395
 
378
- // Use item data for page metadata if available
379
- if (item.title) concretePage.title = item.title
380
- if (item.description || item.excerpt) concretePage.description = item.description || item.excerpt
396
+ // Use item data for page metadata if available
397
+ if (item.title) concretePage.title = item.title
398
+ if (item.description || item.excerpt) concretePage.description = item.description || item.excerpt
381
399
 
382
- expandedPages.push(concretePage)
400
+ expandedPages.push(concretePage)
401
+ }
383
402
  }
384
403
 
385
404
  if (unrouted > 0) {
@@ -523,10 +542,41 @@ export function scopeFetchedData(fetchedData, scopeRoutes, currentRoute = null)
523
542
  // either mode: carried everywhere, a site of N such pages would embed N views —
524
543
  // or N whole records — in every page.
525
544
  const own = (e) => !e._routeBound || e._scope === currentRoute
526
- if (!scopeRoutes) return fetchedData.filter(own).map(stripFetchScope)
527
- return fetchedData
528
- .filter((e) => own(e) && (e._scope === '__site__' || scopeRoutes.has(e._scope)))
529
- .map(stripFetchScope)
545
+ const kept = scopeRoutes
546
+ ? fetchedData.filter((e) => own(e) && (e._scope === '__site__' || scopeRoutes.has(e._scope)))
547
+ : fetchedData.filter(own)
548
+ return dedupeByAddress(kept).map(stripFetchScope)
549
+ }
550
+
551
+ /**
552
+ * One entry per ADDRESS in a page's embedded data — first occurrence wins.
553
+ *
554
+ * ⛔ Entries are collected per PAGE (`executeAllFetches` tags each with the route
555
+ * that asked for it), so a query several pages declare produced one entry per page
556
+ * and unsplit mode embedded all of them in every page. Measured 2026-09-12 on a
557
+ * four-page site whose pages share two queries: 8 entries of which 2 were distinct,
558
+ * and **46% of the HTML was the duplicates**.
559
+ *
560
+ * ⭐ Keyed by `deriveCacheKey`, which is what the SPA looks each entry up under
561
+ * (`hydrateDataStore`) — so two entries with one key are the same answer to the
562
+ * same question by construction, and dropping the later ones cannot change what any
563
+ * page reads. A page's own route-bound view has its own address and survives.
564
+ *
565
+ * @param {Array<{config: Object}>} entries
566
+ * @returns {Array<Object>} the same entries, minus repeats of an address
567
+ */
568
+ function dedupeByAddress(entries) {
569
+ const seen = new Set()
570
+ const out = []
571
+ for (const entry of entries) {
572
+ const key = entry?.config ? deriveCacheKey(entry.config) : null
573
+ if (key !== null) {
574
+ if (seen.has(key)) continue
575
+ seen.add(key)
576
+ }
577
+ out.push(entry)
578
+ }
579
+ return out
530
580
  }
531
581
 
532
582
  /**
@@ -1047,7 +1097,7 @@ export async function prerenderSite(siteDir, options = {}) {
1047
1097
  // data — but the internal `_scope` tag must never leak into it, and a
1048
1098
  // route-bound entry belongs to its own page's HTML, not to every page.
1049
1099
  if (Array.isArray(manifest.fetchedData)) {
1050
- manifest.fetchedData = manifest.fetchedData.filter((e) => !e?._routeBound).map(stripFetchScope)
1100
+ manifest.fetchedData = dedupeByAddress(manifest.fetchedData.filter((e) => !e?._routeBound)).map(stripFetchScope)
1051
1101
  }
1052
1102
  await writeFile(localeContentPath, JSON.stringify(manifest))
1053
1103
  onProgress('Rewrote site-content.json as lightweight manifest')
@@ -1970,9 +1970,15 @@ async function collectPagesRecursive(dirPath, parentRoute, siteRoot, orderConfig
1970
1970
  assetCollection = mergeAssetCollections(assetCollection, pageAssets)
1971
1971
  iconCollection = mergeIconCollections(iconCollection, pageIcons)
1972
1972
 
1973
- // Modern pattern: blog/index/ (isIndex) inherits the container's fetch config
1974
- // when it has no fetch of its own. Without this, EntityStore can't find the
1975
- // fetch config for sections on the index page (page.parent is null for /blog).
1973
+ // A ROOT-PROMOTED folder inherits the container's fetch config when it has
1974
+ // no fetch of its own. Without this, EntityStore cannot find the fetch config
1975
+ // for that page's sections, because promotion leaves it with no parent.
1976
+ //
1977
+ // ⛔ **Root only, and the example this comment used to give was impossible.**
1978
+ // It read "blog/index/ (isIndex)" — a NESTED index folder — but `indexName` is
1979
+ // assigned only under `parentRoute === '/'` (see above), so `entry === indexName`
1980
+ // is false at every deeper level and a nested `index/` folder is an ordinary page.
1981
+ // Measured 2026-09-12: `pages/docs/index/` collects as `/docs/index`, isIndex=false.
1976
1982
  if (isIndex && !page.fetch && parentFetch) {
1977
1983
  page.fetch = parentFetch
1978
1984
  }
@@ -2805,7 +2811,19 @@ export async function collectSiteContent(sitePath, options = {}) {
2805
2811
  pages: dropUnpublished ? dropUnpublishedPages(pages) : pages,
2806
2812
  // Layout area sets: { default: { header: page, footer: page, ... }, marketing: { ... } }
2807
2813
  layouts,
2808
- notFound,
2814
+ // ⭐ THE SAME REACHABILITY AXIS, for the 404 slot. `hidden: true` means DRAFT —
2815
+ // "excluded from the published site" (`docs/reference/page-configuration.md`);
2816
+ // `hideIn: ['*']` is the control for "routed but in no nav".
2817
+ // ⛔ The 404 was exempt BY ACCIDENT until 2026-09-12: it is lifted out of
2818
+ // `pages` before the prune runs, and the prune only filters `pages`, so the
2819
+ // flag never reached it — we published a page its author had marked as not
2820
+ // for publishing, while a backend-published site dropped it. Ruled
2821
+ // [Diego, 2026-09-12]: the flag is literal and framework moves to the lane
2822
+ // that honoured it. The site still gets a `404.html` (the SPA fallback
2823
+ // shell), and `uniweb dev` keeps the page previewable like any other draft.
2824
+ // No cascade to resolve: this slot is root-level only, so its own flag is
2825
+ // the only one that can apply.
2826
+ notFound: dropUnpublished && notFound?.hidden ? null : notFound,
2809
2827
  // Versioned scopes: route → { versions, latestId }
2810
2828
  versionedScopes: versionedScopesObj,
2811
2829
  assets: assetCollection.assets,