@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 +7 -7
- package/src/prerender.js +103 -53
- package/src/site/content-collector.js +22 -4
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@uniweb/build",
|
|
3
|
-
"version": "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/
|
|
65
|
-
"@uniweb/
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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
|
-
//
|
|
331
|
-
//
|
|
332
|
-
//
|
|
333
|
-
|
|
334
|
-
|
|
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
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
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
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
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
|
-
|
|
379
|
-
|
|
380
|
-
|
|
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
|
-
|
|
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
|
-
|
|
527
|
-
|
|
528
|
-
.filter(
|
|
529
|
-
|
|
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
|
-
//
|
|
1974
|
-
//
|
|
1975
|
-
//
|
|
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
|
-
|
|
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,
|