@uniweb/build 0.44.4 → 0.45.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 +6 -6
- package/src/prerender.js +221 -68
- package/src/site/content-collector.js +68 -27
- package/src/site/data-fetcher.js +49 -5
- package/src/site/query-processor.js +33 -4
- package/src/uwx/asset-map.js +74 -2
- package/src/uwx/emit-surface.json +6 -0
- package/src/uwx/index.js +2 -1
- package/src/uwx/records-project.js +21 -26
- package/src/uwx/records.js +6 -7
- package/src/uwx/self-scope.js +78 -0
- package/src/uwx/site-project.js +13 -5
- package/src/uwx/site.js +65 -9
- package/src/uwx/sync-package.js +24 -9
- package/src/uwx/yaml-upsert.js +26 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@uniweb/build",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.45.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",
|
|
60
61
|
"@uniweb/content-writer": "^0.3.4",
|
|
61
|
-
"@uniweb/projections": "^0.6.
|
|
62
|
+
"@uniweb/projections": "^0.6.1",
|
|
62
63
|
"@uniweb/schemas": "^0.2.13",
|
|
63
64
|
"@uniweb/semantic-parser": "^1.4.0",
|
|
64
|
-
"@uniweb/theming": "^0.1.15"
|
|
65
|
-
"@uniweb/content-reader": "^1.2.4"
|
|
65
|
+
"@uniweb/theming": "^0.1.15"
|
|
66
66
|
},
|
|
67
67
|
"optionalDependencies": {
|
|
68
|
-
"@uniweb/runtime": "^0.
|
|
68
|
+
"@uniweb/runtime": "^0.20.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.
|
|
77
|
+
"@uniweb/core": "^0.25.0"
|
|
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 {
|
|
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} {
|
|
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,6 +125,8 @@ 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
132
|
// or `data:` LIST parses to an array, and `siteFetch.prerender` on one is
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
162
|
-
* A
|
|
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
|
|
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,
|
|
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
|
-
//
|
|
228
|
-
|
|
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 (!
|
|
231
|
-
onProgress(`
|
|
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
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
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 (!
|
|
243
|
-
// No build-time data available (e.g., prerender: false on
|
|
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
|
|
251
|
-
const
|
|
305
|
+
const paramName = routeParamName(page.route, page.paramName)
|
|
306
|
+
const { paramNames, catchAll } = routePatternToRegex(page.route)
|
|
252
307
|
|
|
253
|
-
|
|
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
|
-
//
|
|
265
|
-
|
|
266
|
-
|
|
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(
|
|
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
|
-
|
|
293
|
-
|
|
294
|
-
//
|
|
295
|
-
//
|
|
296
|
-
//
|
|
297
|
-
// (`
|
|
298
|
-
//
|
|
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
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
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} ${
|
|
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
|
-
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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')
|