@uniweb/build 0.44.5 → 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 +5 -5
- 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/site.js +16 -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": {
|
|
@@ -59,13 +59,13 @@
|
|
|
59
59
|
"yaml": "^2.5.0",
|
|
60
60
|
"@uniweb/content-reader": "^1.2.4",
|
|
61
61
|
"@uniweb/content-writer": "^0.3.4",
|
|
62
|
-
"@uniweb/projections": "^0.6.
|
|
63
|
-
"@uniweb/semantic-parser": "^1.4.0",
|
|
62
|
+
"@uniweb/projections": "^0.6.1",
|
|
64
63
|
"@uniweb/schemas": "^0.2.13",
|
|
64
|
+
"@uniweb/semantic-parser": "^1.4.0",
|
|
65
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')
|
|
@@ -30,7 +30,7 @@ import yaml from 'js-yaml'
|
|
|
30
30
|
import { collectSectionAssets, mergeAssetCollections, collectConfigAssets } from './assets.js'
|
|
31
31
|
import { collectSectionIcons, mergeIconCollections, buildIconManifest } from './icons.js'
|
|
32
32
|
import { normalizeHideIn, dropUnpublishedPages } from './nav-visibility.js'
|
|
33
|
-
import { parseFetchConfig
|
|
33
|
+
import { parseFetchConfig } from './data-fetcher.js'
|
|
34
34
|
import { resolveExtensionUrls } from './extension-urls.js'
|
|
35
35
|
import { buildTheme, extractFoundationVars } from '../theme/index.js'
|
|
36
36
|
import { resolveDefaultLocale, resolvePublishableLocales, validateLanguageConfig } from '@uniweb/core'
|
|
@@ -122,6 +122,51 @@ function extractRouteParam(folderName) {
|
|
|
122
122
|
return match ? match[1] : null
|
|
123
123
|
}
|
|
124
124
|
|
|
125
|
+
/**
|
|
126
|
+
* Folders the build refuses on a route, ruled 2026-09-11 [Diego]:
|
|
127
|
+
*
|
|
128
|
+
* - `[dir]` and `[path]` — `:dir` and `:path` are route variables every
|
|
129
|
+
* parametric page already has, so a folder by either name would make one
|
|
130
|
+
* name mean two values (and `[path]` is most often a mistyped `[...path]`);
|
|
131
|
+
* - any folder inside a `[...path]` folder — the catch-all takes the rest of
|
|
132
|
+
* the URL, so a page below it has a route (`/docs/:path*\/edit`) that can
|
|
133
|
+
* never match. A folder that holds something other than a page is named
|
|
134
|
+
* with a leading `_`, which the walk skips.
|
|
135
|
+
*
|
|
136
|
+
* @param {string} name - the folder's name
|
|
137
|
+
* @param {string} parentRoute - the route of the folder it sits in
|
|
138
|
+
*/
|
|
139
|
+
function assertRouteFolder(name, parentRoute) {
|
|
140
|
+
if (name === '[dir]' || name === '[path]') {
|
|
141
|
+
const hint = name === '[path]' ? ' Did you mean `[...path]`, which captures a path of any depth?' : ''
|
|
142
|
+
throw new Error(
|
|
143
|
+
`[uniweb] pages: a folder cannot be named \`${name}\` — \`:${name.slice(1, -1)}\` is a route ` +
|
|
144
|
+
`variable every parametric page already has.${hint}`
|
|
145
|
+
)
|
|
146
|
+
}
|
|
147
|
+
if (typeof parentRoute === 'string' && /\/:[A-Za-z0-9_-]+\*(\/|$)/.test(parentRoute)) {
|
|
148
|
+
throw new Error(
|
|
149
|
+
`[uniweb] pages: \`${name}\` sits inside a \`[...path]\` folder (${parentRoute}). The catch-all ` +
|
|
150
|
+
`takes the rest of the URL, so a page below it could never be reached. Move it beside the ` +
|
|
151
|
+
`\`[...path]\` folder, or name it \`_${name}\` if it holds something other than a page.`
|
|
152
|
+
)
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* The route param a page nested inside a parametric page binds — its nearest
|
|
158
|
+
* parametric ancestor's, the DEEPEST `:param` of the route it sits under. Null
|
|
159
|
+
* when no ancestor is parametric.
|
|
160
|
+
*
|
|
161
|
+
* @param {string} parentRoute
|
|
162
|
+
* @returns {string|null}
|
|
163
|
+
*/
|
|
164
|
+
function inheritedRouteParam(parentRoute) {
|
|
165
|
+
if (typeof parentRoute !== 'string') return null
|
|
166
|
+
const params = [...parentRoute.matchAll(/:([A-Za-z0-9_-]+)(?=\/|$)/g)].map((m) => m[1])
|
|
167
|
+
return params.length ? params[params.length - 1] : null
|
|
168
|
+
}
|
|
169
|
+
|
|
125
170
|
// ─────────────────────────────────────────────────────────────────
|
|
126
171
|
// Version Detection
|
|
127
172
|
// ─────────────────────────────────────────────────────────────────
|
|
@@ -866,7 +911,6 @@ async function processFileAsPage(filePath, fileName, siteRoot, parentRoute) {
|
|
|
866
911
|
lastModified: fileStat.mtime?.toISOString() || null,
|
|
867
912
|
isDynamic: false,
|
|
868
913
|
paramName: null,
|
|
869
|
-
parentSchema: null,
|
|
870
914
|
version: null,
|
|
871
915
|
versionMeta: null,
|
|
872
916
|
versionScope: null,
|
|
@@ -1467,12 +1511,19 @@ async function processPage(pagePath, pageName, siteRoot, { isIndex = false, pare
|
|
|
1467
1511
|
// Determine route
|
|
1468
1512
|
// Index pages get the parent route as their canonical route (no dual routes)
|
|
1469
1513
|
// sourcePath stores the original folder-based path for ancestor checking
|
|
1470
|
-
|
|
1471
|
-
|
|
1514
|
+
// ⭐ A page is PARAMETRIC when its folder is a bracket name or it sits inside
|
|
1515
|
+
// one (ruled 2026-09-11 [Diego]): `pages/members/[slug]/cv/` is `/members/:slug/cv`
|
|
1516
|
+
// and binds its ancestor's `slug`. Every lane — the SPA, the prefetch, the static
|
|
1517
|
+
// build — tells a parametric page by the parameter in its route; this flag says
|
|
1518
|
+
// the same thing to a consumer that reads the flag.
|
|
1519
|
+
const isBracket = isDynamicRoute(pageName)
|
|
1520
|
+
const inheritedParam = isBracket ? null : inheritedRouteParam(parentRoute)
|
|
1521
|
+
const isDynamic = isBracket || inheritedParam !== null
|
|
1522
|
+
const paramName = isBracket ? extractRouteParam(pageName) : inheritedParam
|
|
1472
1523
|
|
|
1473
1524
|
// First, calculate the folder-based route (what the route would be without index handling)
|
|
1474
1525
|
let folderRoute
|
|
1475
|
-
if (
|
|
1526
|
+
if (isBracket) {
|
|
1476
1527
|
// Dynamic routes: /blog/[slug] → /blog/:slug (for route matching);
|
|
1477
1528
|
// /blog/[...path] → /blog/:path* — the one multi-segment token the matcher knows.
|
|
1478
1529
|
const token = isCatchAllRoute(pageName) ? ':path*' : `:${paramName}`
|
|
@@ -1500,23 +1551,13 @@ async function processPage(pagePath, pageName, siteRoot, { isIndex = false, pare
|
|
|
1500
1551
|
const layoutObj = mergeLayoutConfig(inheritedLayout, normalizeLayoutConfig(layoutConfig))
|
|
1501
1552
|
const resolvedLayoutName = layoutObj.name || null
|
|
1502
1553
|
|
|
1503
|
-
//
|
|
1504
|
-
//
|
|
1505
|
-
//
|
|
1506
|
-
//
|
|
1507
|
-
//
|
|
1508
|
-
//
|
|
1509
|
-
//
|
|
1510
|
-
//
|
|
1511
|
-
// ⛔ This is a genuine cardinality constraint, not a limit worth lifting: a
|
|
1512
|
-
// route pattern names one variable, and "which collection does `:slug` index"
|
|
1513
|
-
// has no second answer. A page that needs another dataset alongside its
|
|
1514
|
-
// dynamic one still declares it — plurality is what makes that sayable.
|
|
1515
|
-
let parentSchema = null
|
|
1516
|
-
if (isDynamic && parentFetch) {
|
|
1517
|
-
const [first] = toFetchList(parentFetch)
|
|
1518
|
-
parentSchema = first ? first.as : null
|
|
1519
|
-
}
|
|
1554
|
+
// ⛔ NO `parentSchema`. Which query a parametric page's URL names one record of
|
|
1555
|
+
// — its ROUTE QUERY — is worked out where it is read, by one function every lane
|
|
1556
|
+
// calls (`routeQuery`, `@uniweb/core/fetch-config`): the page's own query, its
|
|
1557
|
+
// parent's, the site's, or its sections' shared key. This emitted a copy chosen
|
|
1558
|
+
// by another rule (the closest ancestor with a query at ANY depth, never the
|
|
1559
|
+
// page's own or the site's), so the URL narrowed nothing, or a key no section
|
|
1560
|
+
// received — measured 2026-09-10. Removed 2026-09-11 [Diego].
|
|
1520
1561
|
|
|
1521
1562
|
return {
|
|
1522
1563
|
page: {
|
|
@@ -1535,10 +1576,9 @@ async function processPage(pagePath, pageName, siteRoot, { isIndex = false, pare
|
|
|
1535
1576
|
: {}),
|
|
1536
1577
|
lastModified: lastModified?.toISOString(),
|
|
1537
1578
|
|
|
1538
|
-
//
|
|
1579
|
+
// Parametric route metadata
|
|
1539
1580
|
isDynamic,
|
|
1540
|
-
paramName, // e.g., "slug" from [slug]
|
|
1541
|
-
parentSchema, // e.g., "articles" - the data array to iterate over
|
|
1581
|
+
paramName, // e.g., "slug" from [slug]; a nested page's ancestor's
|
|
1542
1582
|
|
|
1543
1583
|
// Version metadata (if within a versioned section)
|
|
1544
1584
|
version: versionContext?.version || null,
|
|
@@ -1872,6 +1912,7 @@ async function collectPagesRecursive(dirPath, parentRoute, siteRoot, orderConfig
|
|
|
1872
1912
|
// Process subdirectories
|
|
1873
1913
|
for (const folder of orderedFolders) {
|
|
1874
1914
|
const { name: entry, path: entryPath, dirConfig, dirMode, mountedContentMode, childOrderConfig, childLayout } = folder
|
|
1915
|
+
assertRouteFolder(entry, parentRoute)
|
|
1875
1916
|
const isIndex = entry === indexName
|
|
1876
1917
|
const effectiveLayout = mergeLayoutConfig(parentLayout, childLayout)
|
|
1877
1918
|
|
|
@@ -1930,7 +1971,6 @@ async function collectPagesRecursive(dirPath, parentRoute, siteRoot, orderConfig
|
|
|
1930
1971
|
lastModified: null,
|
|
1931
1972
|
isDynamic: false,
|
|
1932
1973
|
paramName: null,
|
|
1933
|
-
parentSchema: null,
|
|
1934
1974
|
version: versionContext?.version || null,
|
|
1935
1975
|
versionMeta: versionContext?.versionMeta || null,
|
|
1936
1976
|
versionScope: versionContext?.scope || null,
|
|
@@ -2001,6 +2041,7 @@ async function collectPagesRecursive(dirPath, parentRoute, siteRoot, orderConfig
|
|
|
2001
2041
|
// Second pass: process each page folder
|
|
2002
2042
|
for (const folder of orderedFolders) {
|
|
2003
2043
|
const { name: entry, path: entryPath, dirConfig, dirMode, mountedContentMode, childOrderConfig, childLayout } = folder
|
|
2044
|
+
assertRouteFolder(entry, parentRoute)
|
|
2004
2045
|
const isIndex = entry === indexPageName
|
|
2005
2046
|
const effectiveLayout = mergeLayoutConfig(parentLayout, childLayout)
|
|
2006
2047
|
|
|
@@ -2024,7 +2065,6 @@ async function collectPagesRecursive(dirPath, parentRoute, siteRoot, orderConfig
|
|
|
2024
2065
|
lastModified: null,
|
|
2025
2066
|
isDynamic: false,
|
|
2026
2067
|
paramName: null,
|
|
2027
|
-
parentSchema: null,
|
|
2028
2068
|
version: versionContext?.version || null,
|
|
2029
2069
|
versionMeta: versionContext?.versionMeta || null,
|
|
2030
2070
|
versionScope: versionContext?.scope || null,
|
|
@@ -2835,6 +2875,7 @@ function buildRouteTranslations(pages, { defaultLocale = 'en', languages = null
|
|
|
2835
2875
|
}
|
|
2836
2876
|
|
|
2837
2877
|
export {
|
|
2878
|
+
assertRouteFolder,
|
|
2838
2879
|
buildRouteTranslations,
|
|
2839
2880
|
extractItemName,
|
|
2840
2881
|
parseWildcardArray,
|
package/src/site/data-fetcher.js
CHANGED
|
@@ -20,7 +20,7 @@ import { readFile } from 'node:fs/promises'
|
|
|
20
20
|
import { join } from 'node:path'
|
|
21
21
|
import { existsSync } from 'node:fs'
|
|
22
22
|
import yaml from 'js-yaml'
|
|
23
|
-
import { matchWhere, sortRecords, queryDataUrl } from '@uniweb/core'
|
|
23
|
+
import { matchWhere, sortRecords, queryDataUrl, applyScope } from '@uniweb/core'
|
|
24
24
|
|
|
25
25
|
/**
|
|
26
26
|
* Infer schema name from path or URL
|
|
@@ -115,10 +115,16 @@ export function applyWhere(items, where) {
|
|
|
115
115
|
*/
|
|
116
116
|
export function applyPostProcessing(data, config) {
|
|
117
117
|
if (!data || !Array.isArray(data)) return data
|
|
118
|
-
if (!config.where && !config.sort && !config.limit) return data
|
|
118
|
+
if (!config.scope && !config.where && !config.sort && !config.limit) return data
|
|
119
119
|
|
|
120
120
|
let result = data
|
|
121
121
|
|
|
122
|
+
// `scope` first — the folder branch the rest of the query reads, over each
|
|
123
|
+
// record's placement (`path`), as the runtime's default fetcher applies it.
|
|
124
|
+
if (typeof config.scope === 'string' && config.scope) {
|
|
125
|
+
result = applyScope(result, config.scope)
|
|
126
|
+
}
|
|
127
|
+
|
|
122
128
|
// Apply where-object predicate first (new path)
|
|
123
129
|
if (config.where) {
|
|
124
130
|
result = applyWhere(result, config.where)
|
|
@@ -176,16 +182,50 @@ const RECOGNIZED_FETCH_KEYS = {
|
|
|
176
182
|
// dropped in the one way the author could not see: no warning, and a plausible
|
|
177
183
|
// key inferred from the path in its place. It has its own message below, since
|
|
178
184
|
// "unrecognized" understates a key that used to work.
|
|
185
|
+
// ⭐ `scope` is recognized since 2026-09-11, when a folder branch became `scope:`
|
|
186
|
+
// on both lanes and `where: { path: { under } }` was retired in its favour. It
|
|
187
|
+
// was dropped here as "unrecognized" until then, so a page could not narrow a
|
|
188
|
+
// query to a branch at all.
|
|
179
189
|
query: new Set([
|
|
180
190
|
'query', 'as', 'prerender', 'merge', 'transform',
|
|
181
|
-
'where', 'limit', 'sort', 'detailPage',
|
|
191
|
+
'scope', 'where', 'limit', 'sort', 'detailPage',
|
|
182
192
|
]),
|
|
183
193
|
source: new Set([
|
|
184
194
|
'path', 'url', 'as', 'prerender', 'merge', 'transform', 'detail',
|
|
185
|
-
'detailPage', 'where', 'limit', 'sort',
|
|
195
|
+
'detailPage', 'scope', 'where', 'limit', 'sort',
|
|
186
196
|
]),
|
|
187
197
|
}
|
|
188
198
|
|
|
199
|
+
/**
|
|
200
|
+
* ⛔ `under` IS RETIRED (2026-09-11 [Diego]) — refused, like every retired spelling
|
|
201
|
+
* here, because an ignored predicate is a silently wrong answer. It existed for
|
|
202
|
+
* `where: { path: { under: X } }`, a folder branch written before a query had
|
|
203
|
+
* `scope:`; a branch is `scope: X` now, on both lanes, and the evaluator no longer
|
|
204
|
+
* knows the operator, so a `where` still carrying it would match nothing.
|
|
205
|
+
*
|
|
206
|
+
* @param {Object|undefined} where
|
|
207
|
+
* @param {string} context - where the declaration sits, for the message
|
|
208
|
+
*/
|
|
209
|
+
export function refuseUnder(where, context) {
|
|
210
|
+
const walk = (node) => {
|
|
211
|
+
if (Array.isArray(node)) {
|
|
212
|
+
node.forEach(walk)
|
|
213
|
+
return
|
|
214
|
+
}
|
|
215
|
+
if (!node || typeof node !== 'object') return
|
|
216
|
+
for (const [key, value] of Object.entries(node)) {
|
|
217
|
+
if (value && typeof value === 'object' && !Array.isArray(value) && Object.prototype.hasOwnProperty.call(value, 'under')) {
|
|
218
|
+
const instead = key === 'path' && typeof value.under === 'string'
|
|
219
|
+
? `Write \`scope: ${JSON.stringify(value.under)}\` — the same folder branch, on every lane.`
|
|
220
|
+
: 'A folder branch is `scope:`; `under` is no longer an operator.'
|
|
221
|
+
throw new Error(`[uniweb] ${context}: \`where: { ${key}: { under: … } }\` is retired. ${instead}`)
|
|
222
|
+
}
|
|
223
|
+
walk(value)
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
walk(where)
|
|
227
|
+
}
|
|
228
|
+
|
|
189
229
|
// Keys that are neither recognized nor merely unknown: they USED to work, and a
|
|
190
230
|
// generic "unrecognized key" line understates that. Each has a dedicated message
|
|
191
231
|
// naming its replacement, so this table only has to keep the generic report from
|
|
@@ -300,6 +340,7 @@ export function parseFetchConfig(fetch) {
|
|
|
300
340
|
'per-instance refinement of the ancestor fetch, under its current name.'
|
|
301
341
|
)
|
|
302
342
|
}
|
|
343
|
+
refuseUnder(fetch.where, 'fetch')
|
|
303
344
|
|
|
304
345
|
// Refine config: { refine: true, detail: false, limit: 3 }
|
|
305
346
|
// No URL — merges with the parent fetch config at runtime; only carries
|
|
@@ -368,7 +409,8 @@ export function parseFetchConfig(fetch) {
|
|
|
368
409
|
prerender: fetch.prerender ?? true,
|
|
369
410
|
merge: fetch.merge ?? false,
|
|
370
411
|
transform: fetch.transform,
|
|
371
|
-
// Query operators
|
|
412
|
+
// Query operators — a fetch's own override the named query's, per field
|
|
413
|
+
scope: fetch.scope,
|
|
372
414
|
where: fetch.where,
|
|
373
415
|
limit: fetch.limit,
|
|
374
416
|
sort: fetch.sort,
|
|
@@ -389,6 +431,7 @@ export function parseFetchConfig(fetch) {
|
|
|
389
431
|
detail,
|
|
390
432
|
detailPage,
|
|
391
433
|
// Query operators
|
|
434
|
+
scope,
|
|
392
435
|
where,
|
|
393
436
|
limit,
|
|
394
437
|
sort,
|
|
@@ -413,6 +456,7 @@ export function parseFetchConfig(fetch) {
|
|
|
413
456
|
// Canonical detail page for a list card's href (page:<stable_id>).
|
|
414
457
|
detailPage,
|
|
415
458
|
// Query operators
|
|
459
|
+
scope,
|
|
416
460
|
where,
|
|
417
461
|
limit,
|
|
418
462
|
sort,
|
|
@@ -54,8 +54,8 @@ import { join, basename, extname, dirname, relative, resolve, sep } from 'node:p
|
|
|
54
54
|
import { existsSync } from 'node:fs'
|
|
55
55
|
import yaml from 'js-yaml'
|
|
56
56
|
import { parseBibtex } from '@citestyle/bibtex'
|
|
57
|
-
import { DATA_DIR, fillRoutePattern } from '@uniweb/core'
|
|
58
|
-
import { applyWhere, applySort } from './data-fetcher.js'
|
|
57
|
+
import { DATA_DIR, fillRoutePattern, withoutRouteVariables } from '@uniweb/core'
|
|
58
|
+
import { applyWhere, applySort, refuseUnder } from './data-fetcher.js'
|
|
59
59
|
import { resolveAssetPath, walkContentAssets, isLocalAssetPath } from './assets.js'
|
|
60
60
|
import { readEntityPool, groupPoolBySchema, ENTITIES_DIR } from './entity-pool.js'
|
|
61
61
|
import { readRecordsConfig, resolveFolder, FOLDER_MISSING } from './records-config.js'
|
|
@@ -113,6 +113,7 @@ function parseQueryConfig(name, config) {
|
|
|
113
113
|
schema: config,
|
|
114
114
|
url: null,
|
|
115
115
|
route: null,
|
|
116
|
+
scope: null,
|
|
116
117
|
sort: null,
|
|
117
118
|
where: null,
|
|
118
119
|
filter: null,
|
|
@@ -122,6 +123,7 @@ function parseQueryConfig(name, config) {
|
|
|
122
123
|
}
|
|
123
124
|
}
|
|
124
125
|
|
|
126
|
+
refuseUnder(config.where, `queries.${name}`)
|
|
125
127
|
return {
|
|
126
128
|
name,
|
|
127
129
|
// The query's schema selects its records from the pool — `entities/{schema}/`
|
|
@@ -129,6 +131,9 @@ function parseQueryConfig(name, config) {
|
|
|
129
131
|
schema: config.schema || null,
|
|
130
132
|
url: config.url || null,
|
|
131
133
|
route: config.route || null,
|
|
134
|
+
// The folder branch the query reads (`records.yml` placement). ⛔ Not read
|
|
135
|
+
// here until 2026-09-11: a named query's `scope` was ignored on this lane.
|
|
136
|
+
scope: typeof config.scope === 'string' ? config.scope : null,
|
|
132
137
|
sort: config.sort || null,
|
|
133
138
|
// `where:` is the CANONICAL predicate; `filter:` is the deprecated string DSL
|
|
134
139
|
// it replaced. Both are carried and both are applied below, in the same order
|
|
@@ -673,6 +678,19 @@ async function collectItems(siteDir, config, entitiesDir, basePath) {
|
|
|
673
678
|
// Filter out nulls (unpublished items)
|
|
674
679
|
items = items.filter(Boolean)
|
|
675
680
|
|
|
681
|
+
// ⭐ `$name` IS THE RECORD HANDLE ON EVERY SITE (ruled 2026-09-11 [Diego]) — the
|
|
682
|
+
// field a `[slug]` or `[...path]` page matches, and the one the records service
|
|
683
|
+
// serves. It is the record's FINAL slug: set here, after every format has been
|
|
684
|
+
// read and flattened, so a frontmatter `slug:` (which wins over the filename),
|
|
685
|
+
// a BibTeX cite key and an array-form file's own `slug` all count — exactly what
|
|
686
|
+
// our sync sends as the entry's name (`uwx/entity-source.js`). `slug` stays:
|
|
687
|
+
// foundations and templates read it.
|
|
688
|
+
items = items.map((item) => (
|
|
689
|
+
item && typeof item === 'object' && item.slug !== undefined && item.slug !== null && item.slug !== ''
|
|
690
|
+
? { ...item, $name: String(item.slug) }
|
|
691
|
+
: item
|
|
692
|
+
))
|
|
693
|
+
|
|
676
694
|
warnDuplicateSlugs(items, config.name)
|
|
677
695
|
|
|
678
696
|
// `route:` on the query — bake each record's canonical href.
|
|
@@ -711,8 +729,19 @@ async function collectItems(siteDir, config, entitiesDir, basePath) {
|
|
|
711
729
|
// the sync wire, stored — and never applied, while the DEPRECATED one it replaced
|
|
712
730
|
// worked. An author following current guidance got silence and shipped unfiltered
|
|
713
731
|
// data. Pinned by `tests/collection-query-terms.test.js`.
|
|
714
|
-
|
|
715
|
-
|
|
732
|
+
// ⭐ ONLY THE `where` FIXED FOR EVERY PAGE. A clause bound to the route —
|
|
733
|
+
// `where: { tag: :dir }` — cannot be applied to a file written once for every
|
|
734
|
+
// page; the runtime binds it per page (`@uniweb/core/fetch-config`,
|
|
735
|
+
// `resolveQuerySource`). ⛔ Until 2026-09-11 it was applied here to the literal
|
|
736
|
+
// `':dir'`, and the query compiled to no records (measured).
|
|
737
|
+
//
|
|
738
|
+
// ⛔ `scope` is NEVER baked, fixed or routed. The runtime applies the one that
|
|
739
|
+
// wins — a page fetch's own, else this query's — which is what the records
|
|
740
|
+
// service does. Baked here, a page's `scope:` could only narrow inside the
|
|
741
|
+
// query's branch on a static site and would replace it on a hosted one.
|
|
742
|
+
const fixed = withoutRouteVariables({ where: config.where })
|
|
743
|
+
if (fixed.where) {
|
|
744
|
+
items = applyWhere(items, fixed.where)
|
|
716
745
|
}
|
|
717
746
|
|
|
718
747
|
// Apply sort
|
package/src/uwx/site.js
CHANGED
|
@@ -53,7 +53,9 @@ import {
|
|
|
53
53
|
applyWildcardOrder,
|
|
54
54
|
processMarkdownFile,
|
|
55
55
|
fetchFromDataShorthand,
|
|
56
|
+
assertRouteFolder,
|
|
56
57
|
} from '../site/content-collector.js'
|
|
58
|
+
import { refuseUnder } from '../site/data-fetcher.js'
|
|
57
59
|
import { normalizeHideIn } from '../site/nav-visibility.js'
|
|
58
60
|
import { resolveDefaultLocale, validateLanguageConfig, queryDataUrl } from '@uniweb/core'
|
|
59
61
|
import { emitEntitySyncPackage } from './entity-document.js'
|
|
@@ -236,6 +238,10 @@ function buildPageData(config, ctx) {
|
|
|
236
238
|
// 2026-09-02 this kept `[0]` and dropped the rest silently, so the wire
|
|
237
239
|
// carried one dataset for a page that asked for several.
|
|
238
240
|
let fetch = config.fetch ?? fetchFromDataShorthand(config.data)
|
|
241
|
+
// `where: { path: { under } }` is refused here as the build refuses it
|
|
242
|
+
// (`parseFetchConfig`): a site that cannot build must not sync either. A
|
|
243
|
+
// section's fetch is refused where the collector parses it.
|
|
244
|
+
for (const one of [fetch].flat()) refuseUnder(one?.where, 'fetch')
|
|
239
245
|
// Resolve the authored `query:` shorthand to the runtime-fetchable
|
|
240
246
|
// `path: /data/<name>.json` (the static convention the default-fetcher uses).
|
|
241
247
|
// A shell/backend-hosted site renders client-side with NO prerender, so the
|
|
@@ -548,8 +554,13 @@ async function walkPagesNested(ctx, dirPath, parentSlugPath, inheritedMode, pare
|
|
|
548
554
|
const { siteRoot, siteIndex, sourceLocale, translations } = ctx
|
|
549
555
|
const folders = await orderedSubfolders(dirPath, inheritedMode, parentConfig)
|
|
550
556
|
const out = []
|
|
557
|
+
// A folder inside a `[...path]` folder can never be reached, and `[dir]` /
|
|
558
|
+
// `[path]` would name a route variable — refused here as the collector refuses
|
|
559
|
+
// them, so a site that cannot build cannot sync either (ruled 2026-09-11).
|
|
560
|
+
const insideCatchAll = (parentSlugPath || '').split('/').includes(CATCH_ALL_MARKER)
|
|
551
561
|
for (let i = 0; i < folders.length; i++) {
|
|
552
562
|
const f = folders[i]
|
|
563
|
+
assertRouteFolder(f.dirName, insideCatchAll ? '/:path*' : '/')
|
|
553
564
|
const dyn = f.dirName.match(DYNAMIC_RE)
|
|
554
565
|
const slug = dyn ? dyn[1] : f.name
|
|
555
566
|
const mode = f.source === 'folder.yml' ? 'folder' : 'page'
|
|
@@ -803,6 +814,7 @@ const DECL_NOT_ON_WIRE = new Set([
|
|
|
803
814
|
function queriesNested(declarations, uuids = null, org = null) {
|
|
804
815
|
const out = []
|
|
805
816
|
for (const [name, d] of Object.entries(declarations)) {
|
|
817
|
+
refuseUnder(d.where, `queries.${name}`)
|
|
806
818
|
const data = {}
|
|
807
819
|
const source = d.path ? { path: d.path } : d.url ? { url: d.url } : d.source
|
|
808
820
|
setIf(data, 'source', source)
|
|
@@ -1071,6 +1083,10 @@ function settingsNested(siteYml, { headHtml, themeYml, sourceLocale, translation
|
|
|
1071
1083
|
// ⭐ The site-level fetch, DESUGARED and under its real name. `data:` is the
|
|
1072
1084
|
// authoring shorthand for `fetch:` and every other tier already calls the wire
|
|
1073
1085
|
// field `fetch`; the site tier called it `data` until 2026-09-09.
|
|
1086
|
+
// `under` is refused as the build refuses it; the `data:` shorthand carries no
|
|
1087
|
+
// `where`. ⚠️ The source expression stays inside `setIf`: `gen-emit-surface.mjs`
|
|
1088
|
+
// reads the published key's sources off it.
|
|
1089
|
+
for (const one of [siteYml.fetch].flat()) refuseUnder(one?.where, 'site.yml fetch')
|
|
1074
1090
|
setIf(settings, 'fetch', siteYml.fetch ?? fetchFromDataShorthand(siteYml.data))
|
|
1075
1091
|
|
|
1076
1092
|
// ⭐ The SITE TIER of framework's own `{name, hide, params}` layout object, which
|