@uniweb/build 0.45.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 +104 -54
- package/src/site/content-collector.js +133 -61
- package/src/site/data-fetcher.js +2 -2
- package/src/site/fetch-shapes.js +54 -0
- package/src/site/query-processor.js +2 -2
- package/src/uwx/emit-surface.json +1 -1
- package/src/uwx/project-writer.js +22 -7
- package/src/uwx/site-project.js +42 -21
- package/src/uwx/site.js +18 -21
- package/src/validate-data.js +3 -3
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": {
|
|
@@ -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/semantic-parser": "^1.4.0",
|
|
60
61
|
"@uniweb/content-reader": "^1.2.4",
|
|
62
|
+
"@uniweb/theming": "^0.1.15",
|
|
63
|
+
"@uniweb/projections": "^0.6.2",
|
|
61
64
|
"@uniweb/content-writer": "^0.3.4",
|
|
62
|
-
"@uniweb/
|
|
63
|
-
"@uniweb/schemas": "^0.2.13",
|
|
64
|
-
"@uniweb/semantic-parser": "^1.4.0",
|
|
65
|
-
"@uniweb/theming": "^0.1.15"
|
|
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,
|
|
@@ -129,7 +129,7 @@ export async function executeAllFetches(siteContent, siteDir, onProgress, locale
|
|
|
129
129
|
const fetched = { site: new Map(), pages: new Map(), sections: new Map() }
|
|
130
130
|
|
|
131
131
|
// 1. Site-level fetch. ⛔ `toFetchList` rather than a property read: a `fetch:`
|
|
132
|
-
// or `
|
|
132
|
+
// or `query:` LIST parses to an array, and `siteFetch.prerender` on one is
|
|
133
133
|
// `undefined` — which passes the `!== false` test and then fetches nothing.
|
|
134
134
|
for (const oneFetch of toFetchList(siteContent.config?.fetch)) {
|
|
135
135
|
if (oneFetch.prerender === false) continue
|
|
@@ -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')
|
|
@@ -24,7 +24,7 @@
|
|
|
24
24
|
import { readFile, readdir, stat } from 'node:fs/promises'
|
|
25
25
|
import { resolveQueriesConfig, toConfigQueries } from './queries-config.js'
|
|
26
26
|
import { parseNumericPrefix, compareByNumericPrefix } from '../utils/numeric-prefix.js'
|
|
27
|
-
import { join, parse, resolve, sep } from 'node:path'
|
|
27
|
+
import { join, parse, relative, resolve, sep } from 'node:path'
|
|
28
28
|
import { existsSync, statSync, realpathSync, readdirSync } from 'node:fs'
|
|
29
29
|
import yaml from 'js-yaml'
|
|
30
30
|
import { collectSectionAssets, mergeAssetCollections, collectConfigAssets } from './assets.js'
|
|
@@ -220,27 +220,86 @@ function detectVersions(folderNames) {
|
|
|
220
220
|
}
|
|
221
221
|
|
|
222
222
|
/**
|
|
223
|
-
*
|
|
224
|
-
*
|
|
225
|
-
*
|
|
226
|
-
*
|
|
227
|
-
* declaration is plural
|
|
228
|
-
*
|
|
223
|
+
* `query:` — the shorthand for `fetch: { query }`, at every level that declares
|
|
224
|
+
* data: a section's frontmatter, `page.yml`, `folder.yml` and `site.yml`, on the
|
|
225
|
+
* build and on the sync push alike. It names a query, or a list of them — "fetch
|
|
226
|
+
* each", one `content.data` key per name (see `parseFetchConfig` for why a
|
|
227
|
+
* declaration is plural and why that says nothing about request count). Anything
|
|
228
|
+
* richer — a `limit`, a `where`, a source — is `fetch:`.
|
|
229
|
+
*
|
|
230
|
+
* ⛔ `data:` WAS THIS KEY until 2026-09-11 [Diego], and it is REFUSED, not
|
|
231
|
+
* ignored: in frontmatter an unreserved key becomes a section param, so a stale
|
|
232
|
+
* `data:` would render an empty section and say nothing. The word stays where it
|
|
233
|
+
* names the data itself — a section type's `meta.js` `data:` (the shape of each
|
|
234
|
+
* `content.data` key, which fetches nothing) and `content.data`.
|
|
235
|
+
*
|
|
236
|
+
* ⛔ `query:` beside `fetch:` is refused too. It was silent — `fetch:` won, and
|
|
237
|
+
* the other declaration was dropped with nothing saying so.
|
|
238
|
+
*
|
|
239
|
+
* @param {Object|null|undefined} config - the level's authored config
|
|
240
|
+
* @param {string} where - the file it was authored in, for the message
|
|
241
|
+
* @returns {Object|Array<Object>|undefined} the level's `fetch:` declaration, as authored
|
|
242
|
+
*/
|
|
243
|
+
export function declaredFetch(config, where) {
|
|
244
|
+
checkDeclaration(config, where)
|
|
245
|
+
return config?.fetch ?? fetchFromQueryShorthand(config?.query)
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* The refusals `declaredFetch` makes, alone — for a reader that must keep the
|
|
250
|
+
* desugaring inline (the sync push's `settings.fetch`, whose sources
|
|
251
|
+
* `scripts/gen-emit-surface.mjs` reads off the expression).
|
|
229
252
|
*
|
|
230
|
-
*
|
|
231
|
-
*
|
|
232
|
-
|
|
233
|
-
|
|
253
|
+
* @param {Object|null|undefined} config
|
|
254
|
+
* @param {string} where
|
|
255
|
+
*/
|
|
256
|
+
export function checkDeclaration(config, where) {
|
|
257
|
+
if (!config || typeof config !== 'object') return
|
|
258
|
+
if (config.data !== undefined) {
|
|
259
|
+
const other = config.query !== undefined ? 'query' : config.fetch !== undefined ? 'fetch' : null
|
|
260
|
+
throw new Error(
|
|
261
|
+
other
|
|
262
|
+
? `[uniweb] ${where}: \`data:\` is retired, and this file already declares \`${other}:\` — delete the \`data:\` line.`
|
|
263
|
+
: `[uniweb] ${where}: \`data:\` is retired — the shorthand for \`fetch: { query }\` is now \`query:\`. ` +
|
|
264
|
+
`Write \`query: ${formatQueryNames(config.data)}\`.`
|
|
265
|
+
)
|
|
266
|
+
}
|
|
267
|
+
if (config.query !== undefined && config.fetch !== undefined) {
|
|
268
|
+
throw new Error(
|
|
269
|
+
`[uniweb] ${where}: declare \`query:\` or \`fetch:\`, not both — \`query: x\` is the shorthand for \`fetch: { query: x }\`.`
|
|
270
|
+
)
|
|
271
|
+
}
|
|
272
|
+
if (config.query !== undefined && !isQueryNames(config.query)) {
|
|
273
|
+
const declaring = config.query && typeof config.query === 'object' && !Array.isArray(config.query)
|
|
274
|
+
throw new Error(
|
|
275
|
+
`[uniweb] ${where}: \`query:\` takes a query name or a list of names; anything richer is \`fetch:\` — ` +
|
|
276
|
+
`e.g. \`fetch: { query: articles, limit: 3 }\`.` +
|
|
277
|
+
(declaring ? ' Declaring queries? That is `queries:` (in site.yml) or queries.yml.' : '')
|
|
278
|
+
)
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/**
|
|
283
|
+
* Desugar a `query:` value: `team` → `{ query: 'team' }`, `[team, articles]` →
|
|
284
|
+
* one config per name. ⛔ Before 2026-09-02 a list kept `[0]` and dropped the rest
|
|
285
|
+
* silently — an author writing a list got one dataset and a section rendering
|
|
286
|
+
* empty.
|
|
234
287
|
*
|
|
235
|
-
* @param {string|Array<string>|undefined}
|
|
288
|
+
* @param {string|Array<string>|undefined|null} query - already checked (`checkDeclaration`)
|
|
236
289
|
* @returns {Object|Array<Object>|undefined}
|
|
237
290
|
*/
|
|
238
|
-
export function
|
|
239
|
-
if (
|
|
240
|
-
if (Array.isArray(
|
|
241
|
-
return { query
|
|
291
|
+
export function fetchFromQueryShorthand(query) {
|
|
292
|
+
if (query === undefined || query === null) return undefined
|
|
293
|
+
if (Array.isArray(query)) return query.map((name) => ({ query: name }))
|
|
294
|
+
return { query }
|
|
242
295
|
}
|
|
243
296
|
|
|
297
|
+
const isQueryName = (name) => typeof name === 'string' && name.trim() !== ''
|
|
298
|
+
const isQueryNames = (value) =>
|
|
299
|
+
isQueryName(value) || (Array.isArray(value) && value.length > 0 && value.every(isQueryName))
|
|
300
|
+
const formatQueryNames = (value) =>
|
|
301
|
+
isQueryNames(value) ? (Array.isArray(value) ? `[${value.join(', ')}]` : value) : '<name>'
|
|
302
|
+
|
|
244
303
|
/**
|
|
245
304
|
* Build version metadata from detected versions and page.yml config
|
|
246
305
|
* @param {Array<Object>} detectedVersions - Detected version infos
|
|
@@ -280,7 +339,7 @@ function buildVersionMetadata(detectedVersions, pageConfig = {}) {
|
|
|
280
339
|
* this function reads `site.yml`, `page.yml`, `folder.yml`, `theme.yml` and
|
|
281
340
|
* every section's frontmatter — i.e. every configuration surface an author
|
|
282
341
|
* writes. So a single typo silently discarded that file's entire contribution:
|
|
283
|
-
* page order, nesting, `sections:`, `
|
|
342
|
+
* page order, nesting, `sections:`, `query:` declarations, theme. **The build
|
|
284
343
|
* succeeded and shipped a site missing what the author asked for**, with one
|
|
285
344
|
* line on stderr that named no file.
|
|
286
345
|
*
|
|
@@ -965,7 +1024,9 @@ async function processMarkdownFile(filePath, id, siteRoot, defaultStableId = nul
|
|
|
965
1024
|
console.warn(`[content-collector] ${err.message}`)
|
|
966
1025
|
}
|
|
967
1026
|
|
|
968
|
-
|
|
1027
|
+
// `query`, `fetch` and `data` are never params: `query:` / `fetch:` declare the
|
|
1028
|
+
// section's own data, and a leftover `data:` is refused (`declaredFetch`).
|
|
1029
|
+
const { type, preset, input, props, fetch, query, data, id: frontmatterId, ...params } = frontMatter
|
|
969
1030
|
|
|
970
1031
|
// Convert markdown to ProseMirror
|
|
971
1032
|
const proseMirrorContent = markdownToProseMirror(markdown)
|
|
@@ -973,28 +1034,13 @@ async function processMarkdownFile(filePath, id, siteRoot, defaultStableId = nul
|
|
|
973
1034
|
// Extract @ component references → insets (mutates doc)
|
|
974
1035
|
const insets = extractInsets(proseMirrorContent)
|
|
975
1036
|
|
|
976
|
-
// `
|
|
977
|
-
//
|
|
978
|
-
//
|
|
979
|
-
//
|
|
980
|
-
//
|
|
981
|
-
//
|
|
982
|
-
|
|
983
|
-
// (`meta.inheritData === false` → deliver nothing). It never consumed list
|
|
984
|
-
// elements. What actually happens is that delivery is default-on and
|
|
985
|
-
// collect-all: `EntityStore._getRequestedSchemas` returns `[]`, so a section
|
|
986
|
-
// receives EVERY fetch config in the section → page → site cascade whether or
|
|
987
|
-
// not it named any of them.
|
|
988
|
-
//
|
|
989
|
-
// ⇒ So `articles` arrives only if something ELSE already declared a fetch for
|
|
990
|
-
// it — in which case this section would have received it anyway. **Naming it
|
|
991
|
-
// here contributes nothing.** Measured 2026-09-02: `data: [team, articles]`
|
|
992
|
-
// with no other declaration yields exactly one config, `team`, and the array
|
|
993
|
-
// is not carried forward on the section, so nothing downstream can recover it.
|
|
994
|
-
//
|
|
995
|
-
// ⚠️ The list form appears in no `docs/` page, so nothing promises it works.
|
|
996
|
-
// Whether it should mean "fetch each" or be refused outright is undecided.
|
|
997
|
-
const resolvedFetch = fetch || fetchFromDataShorthand(data)
|
|
1037
|
+
// `query: team` → `fetch: { query: team }`; a list, one config per name, each
|
|
1038
|
+
// delivered under its own `content.data` key — the one helper every level uses.
|
|
1039
|
+
// Unrelated to a section type's `meta.js` `data:`, which declares the SHAPE of
|
|
1040
|
+
// each `content.data` key and fetches nothing: delivery is default-on, so a
|
|
1041
|
+
// section receives every fetch in the section → page → site cascade whether or
|
|
1042
|
+
// not it names one here.
|
|
1043
|
+
const resolvedFetch = declaredFetch({ fetch, query, data }, relative(siteRoot, filePath))
|
|
998
1044
|
|
|
999
1045
|
// Stable ID for scroll targeting: frontmatter id > filename-derived > null
|
|
1000
1046
|
// This ID is stable across reordering (unlike the positional id)
|
|
@@ -1619,12 +1665,8 @@ async function processPage(pagePath, pageName, siteRoot, { isIndex = false, pare
|
|
|
1619
1665
|
priority: seo.priority || null
|
|
1620
1666
|
},
|
|
1621
1667
|
|
|
1622
|
-
// Data fetching
|
|
1623
|
-
|
|
1624
|
-
// data: team → fetch: { query: team }
|
|
1625
|
-
fetch: parseFetchConfig(
|
|
1626
|
-
pageConfig.fetch || fetchFromDataShorthand(pageConfig.data)
|
|
1627
|
-
),
|
|
1668
|
+
// Data fetching — `fetch:`, or the `query:` shorthand (`declaredFetch`)
|
|
1669
|
+
fetch: parseFetchConfig(declaredFetch(pageConfig, relative(siteRoot, join(pagePath, 'page.yml')))),
|
|
1628
1670
|
|
|
1629
1671
|
hasContent: hierarchicalSections.length > 0,
|
|
1630
1672
|
sections: hierarchicalSections
|
|
@@ -1928,9 +1970,15 @@ async function collectPagesRecursive(dirPath, parentRoute, siteRoot, orderConfig
|
|
|
1928
1970
|
assetCollection = mergeAssetCollections(assetCollection, pageAssets)
|
|
1929
1971
|
iconCollection = mergeIconCollections(iconCollection, pageIcons)
|
|
1930
1972
|
|
|
1931
|
-
//
|
|
1932
|
-
//
|
|
1933
|
-
//
|
|
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.
|
|
1934
1982
|
if (isIndex && !page.fetch && parentFetch) {
|
|
1935
1983
|
page.fetch = parentFetch
|
|
1936
1984
|
}
|
|
@@ -1987,7 +2035,10 @@ async function collectPagesRecursive(dirPath, parentRoute, siteRoot, orderConfig
|
|
|
1987
2035
|
changefreq: dirConfig.seo?.changefreq || null,
|
|
1988
2036
|
priority: dirConfig.seo?.priority || null
|
|
1989
2037
|
},
|
|
1990
|
-
|
|
2038
|
+
// ⭐ `declaredFetch`, as every other level: this read `dirConfig.fetch`
|
|
2039
|
+
// alone until 2026-09-11, so a container's `folder.yml` shorthand reached
|
|
2040
|
+
// a backend on `push` and was dropped from a static build.
|
|
2041
|
+
fetch: parseFetchConfig(declaredFetch(dirConfig, relative(siteRoot, join(entryPath, 'folder.yml')))) || null,
|
|
1991
2042
|
hasContent: false,
|
|
1992
2043
|
sections: [],
|
|
1993
2044
|
order: typeof dirConfig.order === 'number' ? dirConfig.order : undefined
|
|
@@ -2079,7 +2130,12 @@ async function collectPagesRecursive(dirPath, parentRoute, siteRoot, orderConfig
|
|
|
2079
2130
|
changefreq: dirConfig.seo?.changefreq || null,
|
|
2080
2131
|
priority: dirConfig.seo?.priority || null
|
|
2081
2132
|
},
|
|
2082
|
-
|
|
2133
|
+
// ⭐ The folder's own declaration, as the folder-mode container above and
|
|
2134
|
+
// every other level read it. ⛔ This was `fetch: null` until 2026-09-11:
|
|
2135
|
+
// a `folder.yml` here lost its `fetch:` (and its shorthand) on a static
|
|
2136
|
+
// build while `push` carried it, so its pages had the folder's data on a
|
|
2137
|
+
// hosted site and none on an exported one.
|
|
2138
|
+
fetch: parseFetchConfig(declaredFetch(dirConfig, relative(siteRoot, join(entryPath, 'folder.yml')))) || null,
|
|
2083
2139
|
hasContent: false,
|
|
2084
2140
|
sections: [],
|
|
2085
2141
|
order: typeof dirConfig.order === 'number' ? dirConfig.order : undefined
|
|
@@ -2091,8 +2147,11 @@ async function collectPagesRecursive(dirPath, parentRoute, siteRoot, orderConfig
|
|
|
2091
2147
|
pages.push(containerPage)
|
|
2092
2148
|
}
|
|
2093
2149
|
|
|
2150
|
+
// The container's own fetch config, or the parent's — as the folder-mode
|
|
2151
|
+
// container above passes it.
|
|
2094
2152
|
const childDirPath = mounts?.get(entry) || entryPath
|
|
2095
|
-
const
|
|
2153
|
+
const containerFetch = containerPage.fetch || parentFetch
|
|
2154
|
+
const subResult = await collectPagesRecursive(childDirPath, containerRoute, siteRoot, childOrderConfig, containerFetch, versionContext, 'pages', null, effectiveLayout)
|
|
2096
2155
|
pages.push(...subResult.pages)
|
|
2097
2156
|
assetCollection = mergeAssetCollections(assetCollection, subResult.assetCollection)
|
|
2098
2157
|
iconCollection = mergeIconCollections(iconCollection, subResult.iconCollection)
|
|
@@ -2656,7 +2715,9 @@ export async function collectSiteContent(sitePath, options = {}) {
|
|
|
2656
2715
|
// `publishLanguages` is authoring/publish intent — it has no runtime
|
|
2657
2716
|
// consumer and never ships in a payload (the visitor runtime is
|
|
2658
2717
|
// list-unaware; the sync lane reads site.yml directly, not this output).
|
|
2659
|
-
|
|
2718
|
+
// The `query:` shorthand ships as `config.fetch`, desugared below; carried raw
|
|
2719
|
+
// it would sit beside `config.queries`, the declarations, and read as one.
|
|
2720
|
+
const { publishLanguages: _publishLanguages, query: _query, ...runtimeSiteConfig } = siteConfig
|
|
2660
2721
|
// ⛔ `$`-prefixed keys are the project's BACKEND-SCOPED state — `$uuid`, `$org`,
|
|
2661
2722
|
// `$backend`, `$services`, `$secrets` — and this payload is a PUBLISHED artifact
|
|
2662
2723
|
// that a visitor can fetch. They have no runtime reader (nothing in core, runtime
|
|
@@ -2700,7 +2761,7 @@ export async function collectSiteContent(sitePath, options = {}) {
|
|
|
2700
2761
|
`${distinctFailures.length} file${distinctFailures.length === 1 ? '' : 's'} could not be parsed as YAML:\n` +
|
|
2701
2762
|
`${lines.join('\n')}\n\n` +
|
|
2702
2763
|
` Each one contributed NOTHING to this build — page order, nesting,\n` +
|
|
2703
|
-
` sections:,
|
|
2764
|
+
` sections:, query: and theme settings in these files were dropped.\n` +
|
|
2704
2765
|
` Fix them and rebuild; the dev server reports the same files without failing.`
|
|
2705
2766
|
)
|
|
2706
2767
|
}
|
|
@@ -2720,13 +2781,12 @@ export async function collectSiteContent(sitePath, options = {}) {
|
|
|
2720
2781
|
...(publishFilterActive && Array.isArray(siteConfig.languages)
|
|
2721
2782
|
? { languages: publishable }
|
|
2722
2783
|
: {}),
|
|
2723
|
-
// ⛔ `
|
|
2784
|
+
// ⛔ `query:` IS THE SHORTHAND FOR `fetch:` AND BOTH LANES MUST READ IT.
|
|
2724
2785
|
// This read `siteConfig.fetch` alone until 2026-09-09, so a site-level
|
|
2725
|
-
//
|
|
2726
|
-
//
|
|
2727
|
-
//
|
|
2728
|
-
|
|
2729
|
-
fetch: parseFetchConfig(siteConfig.fetch || fetchFromDataShorthand(siteConfig.data)),
|
|
2786
|
+
// shorthand reached a backend on the sync lane and was silently ignored on
|
|
2787
|
+
// a static build — the works-on-one-lane shape. Every level reads it
|
|
2788
|
+
// through `declaredFetch` now.
|
|
2789
|
+
fetch: parseFetchConfig(declaredFetch(siteConfig, 'site.yml')),
|
|
2730
2790
|
fetcher: warnRetiredFetcherKeys(siteConfig.fetcher),
|
|
2731
2791
|
// NOTE: `intelligence.yml` was read here and emitted as `config.intelligence`.
|
|
2732
2792
|
// Removed 2026-08-12 — the assistant surface is `site.yml::assistant`, which
|
|
@@ -2751,7 +2811,19 @@ export async function collectSiteContent(sitePath, options = {}) {
|
|
|
2751
2811
|
pages: dropUnpublished ? dropUnpublishedPages(pages) : pages,
|
|
2752
2812
|
// Layout area sets: { default: { header: page, footer: page, ... }, marketing: { ... } }
|
|
2753
2813
|
layouts,
|
|
2754
|
-
|
|
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,
|
|
2755
2827
|
// Versioned scopes: route → { versions, latestId }
|
|
2756
2828
|
versionedScopes: versionedScopesObj,
|
|
2757
2829
|
assets: assetCollection.assets,
|
package/src/site/data-fetcher.js
CHANGED
|
@@ -274,9 +274,9 @@ export function toFetchList(fetch) {
|
|
|
274
274
|
}
|
|
275
275
|
|
|
276
276
|
/**
|
|
277
|
-
* Parse a `fetch:` (or desugared `
|
|
277
|
+
* Parse a `fetch:` (or desugared `query:`) declaration.
|
|
278
278
|
*
|
|
279
|
-
* ⭐ **A LIST MEANS "FETCH EACH".** `
|
|
279
|
+
* ⭐ **A LIST MEANS "FETCH EACH".** `query: [team, articles]` declares two needs
|
|
280
280
|
* and they land under two keys in `content.data` — a component reads
|
|
281
281
|
* `content.data.team` and `content.data.articles` independently, so the
|
|
282
282
|
* declaration is plural by necessity.
|
package/src/site/fetch-shapes.js
CHANGED
|
@@ -69,3 +69,57 @@ export function authorableFetch(fetch) {
|
|
|
69
69
|
}
|
|
70
70
|
return out
|
|
71
71
|
}
|
|
72
|
+
|
|
73
|
+
// ── `query:` or `fetch:` — the key a projection writes back ─────────────────────
|
|
74
|
+
//
|
|
75
|
+
// An author declares a level's data with `fetch:` or with its shorthand, `query:`
|
|
76
|
+
// (`query: team` ≡ `fetch: { query: team }`). The wire carries only the desugared
|
|
77
|
+
// form, so it cannot say which key was typed — and a pull must give back the
|
|
78
|
+
// authored KEY as well as the value (the round-trip law of the sync format). So a
|
|
79
|
+
// projection writes back the key the file already uses, and a file it creates gets
|
|
80
|
+
// `query:` whenever the declaration is nothing but query names — the form the
|
|
81
|
+
// docs teach. The declaration keys are one group: exactly one is written, and a
|
|
82
|
+
// file holding both is refused by the build.
|
|
83
|
+
|
|
84
|
+
/** The keys that declare a level's data: the long form, its shorthand, and the retired shorthand. */
|
|
85
|
+
export const DECLARATION_KEYS = Object.freeze(['query', 'fetch', 'data'])
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* The query names a declaration consists of, when that is ALL it says — what
|
|
89
|
+
* `query:` can express. Keys the build derives or defaults beside a query (`path`,
|
|
90
|
+
* `url`, `as` equal to the name, `prerender: true`, `merge: false`) say nothing an
|
|
91
|
+
* author wrote; any other key makes it a `fetch:`.
|
|
92
|
+
*
|
|
93
|
+
* @param {object|object[]} fetch - a declaration (or a list) off the wire
|
|
94
|
+
* @returns {string|string[]|null} the name(s), or null when `query:` cannot say it
|
|
95
|
+
*/
|
|
96
|
+
export function queryNamesOf(fetch) {
|
|
97
|
+
const nameOf = (one) => {
|
|
98
|
+
if (!one || typeof one !== 'object' || typeof one.query !== 'string' || one.query === '') return null
|
|
99
|
+
for (const [key, value] of Object.entries(one)) {
|
|
100
|
+
if (key === 'query' || key === 'path' || key === 'url') continue
|
|
101
|
+
if (key === 'as' && value === one.query) continue
|
|
102
|
+
if (key === 'prerender' && value === true) continue
|
|
103
|
+
if (key === 'merge' && value === false) continue
|
|
104
|
+
return null
|
|
105
|
+
}
|
|
106
|
+
return one.query
|
|
107
|
+
}
|
|
108
|
+
if (!Array.isArray(fetch)) return nameOf(fetch)
|
|
109
|
+
const names = fetch.map(nameOf)
|
|
110
|
+
return names.length > 0 && names.every((n) => n !== null) ? names : null
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* The declaration to write back, and under which key.
|
|
115
|
+
*
|
|
116
|
+
* @param {object|object[]} wireFetch - the level's `fetch` off the wire
|
|
117
|
+
* @param {object|null} [existing] - the authored file's current keys; null for a new file
|
|
118
|
+
* @returns {{ key: 'query'|'fetch', value: string|string[]|object|object[] }}
|
|
119
|
+
*/
|
|
120
|
+
export function authorableDeclaration(wireFetch, existing = null) {
|
|
121
|
+
const fetch = Array.isArray(wireFetch) ? wireFetch.map((one) => authorableFetch(one)) : authorableFetch(wireFetch)
|
|
122
|
+
const names = queryNamesOf(fetch)
|
|
123
|
+
const typedFetch = !!existing && typeof existing === 'object' && existing.fetch !== undefined
|
|
124
|
+
return names !== null && !typedFetch ? { key: 'query', value: names } : { key: 'fetch', value: fetch }
|
|
125
|
+
}
|
|
@@ -147,7 +147,7 @@ function parseQueryConfig(name, config) {
|
|
|
147
147
|
},
|
|
148
148
|
// `deferred:` lists fields that are heavy (article body, full nested
|
|
149
149
|
// arrays). Those fields are stripped from the cascade payload that
|
|
150
|
-
// ships with `
|
|
150
|
+
// ships with `query: <name>` declarations, and per-record full files
|
|
151
151
|
// are emitted at public/data/<name>/<slug>.json. Components that
|
|
152
152
|
// need the full record fetch the per-record file on demand, either
|
|
153
153
|
// automatically on dynamic-route pages (entity-store routes the
|
|
@@ -913,7 +913,7 @@ export async function writeQueryFiles(siteDir, byQuery, queriesConfig = null) {
|
|
|
913
913
|
if (deferred && deferred.length > 0) {
|
|
914
914
|
// `deferred:` is set — emit two payloads:
|
|
915
915
|
// 1. The cascade JSON at /data/<name>.json with deferred fields stripped.
|
|
916
|
-
// This is what `
|
|
916
|
+
// This is what `query: <name>` declarations deliver everywhere.
|
|
917
917
|
// 2. Per-record full files at /data/<name>/<slug>.json with every field.
|
|
918
918
|
// Dynamic-route singular fetches and useEntityDetail hooks read these.
|
|
919
919
|
const recordsDir = join(dataDir, name)
|
|
@@ -19,15 +19,22 @@ import { parseFrontmatter } from './entity-source.js'
|
|
|
19
19
|
import { renderEntityDocument } from './backfill.js'
|
|
20
20
|
import { queriesYmlPath } from './queries-config.js'
|
|
21
21
|
import { recordsYmlPath } from '../site/records-config.js'
|
|
22
|
+
import { DECLARATION_KEYS } from '../site/fetch-shapes.js'
|
|
22
23
|
|
|
23
24
|
// Frontmatter keys that belong to the CCA framework / the developer's local
|
|
24
25
|
// authoring, not to externally-editable params. On a section write an existing
|
|
25
26
|
// reserved key is preserved and never overwritten by incoming params, so a
|
|
26
27
|
// projection doesn't churn fields it didn't author (the surgical-update bar).
|
|
28
|
+
//
|
|
29
|
+
// ⭐ The declaration keys — `query`, `fetch`, and the retired `data` — are one
|
|
30
|
+
// group: a section that declares its data locally, under any of them, keeps that
|
|
31
|
+
// declaration, and an incoming one under a different key does not land beside it
|
|
32
|
+
// (a file holding two is refused by the build).
|
|
27
33
|
export const DEFAULT_RESERVED_FRONTMATTER = new Set([
|
|
28
34
|
'type',
|
|
29
35
|
'preset',
|
|
30
36
|
'input',
|
|
37
|
+
'query',
|
|
31
38
|
'fetch',
|
|
32
39
|
'data',
|
|
33
40
|
'nest',
|
|
@@ -168,6 +175,7 @@ export function writeSectionFile({ filePath, content, params, reserved = DEFAULT
|
|
|
168
175
|
const { frontmatter, body: existingBody } = parseFrontmatter(existing, filePath)
|
|
169
176
|
|
|
170
177
|
const nextFrontmatter = { ...frontmatter }
|
|
178
|
+
const declaresLocally = DECLARATION_KEYS.some((k) => k in frontmatter)
|
|
171
179
|
if (params) {
|
|
172
180
|
for (const [key, value] of Object.entries(params)) {
|
|
173
181
|
// A reserved key is preserved only when it already exists locally (the
|
|
@@ -175,6 +183,7 @@ export function writeSectionFile({ filePath, content, params, reserved = DEFAULT
|
|
|
175
183
|
// there is nothing to protect, so the incoming value fills it — that's
|
|
176
184
|
// how a newly-projected section gets its `type`/`nest`/etc.
|
|
177
185
|
if (reserved.has(key) && key in frontmatter) continue
|
|
186
|
+
if (reserved.has(key) && DECLARATION_KEYS.includes(key) && declaresLocally) continue
|
|
178
187
|
if (value === null || value === undefined) delete nextFrontmatter[key]
|
|
179
188
|
else nextFrontmatter[key] = value
|
|
180
189
|
}
|
|
@@ -186,11 +195,12 @@ export function writeSectionFile({ filePath, content, params, reserved = DEFAULT
|
|
|
186
195
|
|
|
187
196
|
// Shallow-merge `changes` into a YAML config file and write idempotently. A key
|
|
188
197
|
// whose value is null/undefined is deleted; an object value is shallow-merged one
|
|
189
|
-
// level deep (so partial `theme` / `build` updates don't drop sibling keys)
|
|
190
|
-
// other value replaces. NOTE: this re-dumps the file,
|
|
191
|
-
// not preserved — acceptable for machine-owned
|
|
192
|
-
// merges for hand-authored config files are a
|
|
193
|
-
|
|
198
|
+
// level deep (so partial `theme` / `build` updates don't drop sibling keys) unless
|
|
199
|
+
// the key is in `replace`; any other value replaces. NOTE: this re-dumps the file,
|
|
200
|
+
// so author comments/order are not preserved — acceptable for machine-owned
|
|
201
|
+
// config, but comment-preserving merges for hand-authored config files are a
|
|
202
|
+
// quality bar to revisit.
|
|
203
|
+
function mergeYamlConfig(filePath, changes, { replace = [] } = {}) {
|
|
194
204
|
let existing = {}
|
|
195
205
|
try {
|
|
196
206
|
existing = yaml.load(readFileSync(filePath, 'utf8')) || {}
|
|
@@ -200,7 +210,7 @@ function mergeYamlConfig(filePath, changes) {
|
|
|
200
210
|
for (const [key, value] of Object.entries(changes)) {
|
|
201
211
|
if (value === null || value === undefined) {
|
|
202
212
|
delete existing[key]
|
|
203
|
-
} else if (typeof value === 'object' && !Array.isArray(value)) {
|
|
213
|
+
} else if (typeof value === 'object' && !Array.isArray(value) && !replace.includes(key)) {
|
|
204
214
|
existing[key] = { ...(existing[key] || {}), ...value }
|
|
205
215
|
} else {
|
|
206
216
|
existing[key] = value
|
|
@@ -212,10 +222,15 @@ function mergeYamlConfig(filePath, changes) {
|
|
|
212
222
|
/**
|
|
213
223
|
* Merge `config` into `site.yml` (shallow). Preserves keys not present in the
|
|
214
224
|
* update (foundation, base, paths, …).
|
|
225
|
+
*
|
|
226
|
+
* ⛔ The declaration keys (`query` / `fetch` / `data`) are written WHOLE, never
|
|
227
|
+
* merged: a declaration is one value, and merging an incoming `fetch:` into the
|
|
228
|
+
* local one kept whatever key the remote no longer has — a stale `limit` survived
|
|
229
|
+
* every pull.
|
|
215
230
|
* @returns {'updated'|'unchanged'}
|
|
216
231
|
*/
|
|
217
232
|
export function writeSiteConfig(siteRoot, config) {
|
|
218
|
-
return mergeYamlConfig(join(siteRoot, 'site.yml'), config)
|
|
233
|
+
return mergeYamlConfig(join(siteRoot, 'site.yml'), config, { replace: DECLARATION_KEYS })
|
|
219
234
|
}
|
|
220
235
|
|
|
221
236
|
/**
|
package/src/uwx/site-project.js
CHANGED
|
@@ -39,7 +39,7 @@ import { createHash } from 'node:crypto'
|
|
|
39
39
|
import yaml from 'js-yaml'
|
|
40
40
|
import { writeSiteConfig, writeThemeFile, writeIfChanged, writeSectionFile, writeMergedYaml } from './project-writer.js'
|
|
41
41
|
import { declarationsToQueriesYml } from './records-project.js'
|
|
42
|
-
import {
|
|
42
|
+
import { authorableDeclaration, DECLARATION_KEYS } from '../site/fetch-shapes.js'
|
|
43
43
|
import { createTranslationCollector, writeLocaleTranslations, writeFreeformTranslations, unwrapLocalizedContent } from './locale-sync.js'
|
|
44
44
|
import { buildFreeformPath } from '../i18n/freeform.js'
|
|
45
45
|
import { unwrapLocalized, unwrapLocalizedList } from './backfill.js'
|
|
@@ -138,8 +138,8 @@ const INFO_TO_SITE_YML = {
|
|
|
138
138
|
// Authored-only, like `submit` and `assistant`: a host's tracking endpoint is
|
|
139
139
|
// offered through `config.services.tracking` and resolved at render, so it
|
|
140
140
|
// never enters `info` and a pull cannot launder it into authored config.
|
|
141
|
-
// ⛔
|
|
142
|
-
// `site.yml::
|
|
141
|
+
// ⛔ The site's declaration is not verbatim — see the explicit `settings.fetch`
|
|
142
|
+
// branch below: it projects to `site.yml::query` or `site.yml::fetch`.
|
|
143
143
|
template: 'template',
|
|
144
144
|
// ⭐ `tags` — authored, non-localized tokens; the filter facet for a list of site
|
|
145
145
|
// cards. Round-trips verbatim like any authored list.
|
|
@@ -199,6 +199,16 @@ const SETTINGS_TO_SITE_YML = {
|
|
|
199
199
|
}
|
|
200
200
|
|
|
201
201
|
|
|
202
|
+
/** An authored YAML config as it stands, or null when there is none to read. */
|
|
203
|
+
function readAuthoredYaml(filePath) {
|
|
204
|
+
try {
|
|
205
|
+
const value = yaml.load(readFileSync(filePath, 'utf8'))
|
|
206
|
+
return value && typeof value === 'object' && !Array.isArray(value) ? value : null
|
|
207
|
+
} catch {
|
|
208
|
+
return null
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
202
212
|
/**
|
|
203
213
|
* Project a site-content document's `info` (+ `extensions`) onto the site's
|
|
204
214
|
* config files: `site.yml`, `theme.yml`, and `head.html`. Idempotent; only the
|
|
@@ -238,22 +248,21 @@ export function siteInfoToConfig({ document, siteRoot, sourceLocale = LOCALIZED_
|
|
|
238
248
|
if (info[infoKey] !== undefined) siteChanges[ymlKey] = info[infoKey]
|
|
239
249
|
}
|
|
240
250
|
|
|
241
|
-
// ⭐ `settings.fetch` → `site.yml::fetch
|
|
242
|
-
//
|
|
243
|
-
// `
|
|
244
|
-
//
|
|
245
|
-
//
|
|
246
|
-
//
|
|
247
|
-
//
|
|
248
|
-
//
|
|
251
|
+
// ⭐ `settings.fetch` → `site.yml::query` or `site.yml::fetch` — the key the file
|
|
252
|
+
// already uses, else `query:` when the declaration is nothing but query names
|
|
253
|
+
// (`authorableDeclaration`). The wire carries the desugared form and cannot say
|
|
254
|
+
// which was typed, and the round-trip law keeps the authored KEY as well as the
|
|
255
|
+
// value (the sync format's round-trip law): the site lane once wrote the shorthand back verbatim,
|
|
256
|
+
// so an author who typed `fetch:` pushed, pulled, and got the shorthand back.
|
|
257
|
+
// The other declaration keys are removed, so the file never holds two (the
|
|
258
|
+
// build refuses that) — a retired `data:` included.
|
|
249
259
|
//
|
|
250
260
|
// ⛔ The producer always desugars, so the wire carries a config or a list of them —
|
|
251
261
|
// never a bare string. Nothing here accommodates an older shape.
|
|
252
262
|
const wireFetch = settingsSection.fetch
|
|
253
263
|
if (wireFetch !== undefined) {
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
: authorableFetch(wireFetch)
|
|
264
|
+
const { key, value } = authorableDeclaration(wireFetch, readAuthoredYaml(join(siteRoot, 'site.yml')))
|
|
265
|
+
for (const k of DECLARATION_KEYS) siteChanges[k] = k === key ? value : null
|
|
257
266
|
}
|
|
258
267
|
|
|
259
268
|
// The `settings` Section — authored configuration that is not identity, so it is
|
|
@@ -409,8 +418,13 @@ export function sectionRecordToFile({ filePath, record, sourceLocale = LOCALIZED
|
|
|
409
418
|
if (theme_override !== undefined) frontmatter.theme = theme_override
|
|
410
419
|
if (preset !== undefined) frontmatter.preset = preset
|
|
411
420
|
if (input !== undefined) frontmatter.input = input
|
|
412
|
-
// Invert the build's resolution rather than copy it — see fetch-shapes.js.
|
|
413
|
-
|
|
421
|
+
// Invert the build's resolution rather than copy it — see fetch-shapes.js. A new
|
|
422
|
+
// file gets `query:` for a declaration of names alone; a section that declares
|
|
423
|
+
// its data locally keeps it (`writeSectionFile`, the declaration keys).
|
|
424
|
+
if (fetch !== undefined) {
|
|
425
|
+
const { key, value } = authorableDeclaration(fetch)
|
|
426
|
+
frontmatter[key] = value
|
|
427
|
+
}
|
|
414
428
|
if (stable_id !== undefined) frontmatter.id = stable_id
|
|
415
429
|
|
|
416
430
|
const body = insets ? reinlineInsets(sourceContent, insets) : sourceContent
|
|
@@ -496,10 +510,13 @@ export function pageSectionsToFiles({ pageDir, pageSections, ctx, pageContext })
|
|
|
496
510
|
// page.yml/folder.yml. On a merge write these are replaced wholesale (a managed
|
|
497
511
|
// key the record no longer carries is dropped); any other key is author-authored
|
|
498
512
|
// and preserved. Keep in sync with pageRecordToYml below.
|
|
513
|
+
// ⚠️ `query` and `data` are managed with `fetch`: the declaration is written under
|
|
514
|
+
// ONE key (`authorableDeclaration`), so the other two — a retired `data:`
|
|
515
|
+
// included — are dropped rather than left beside it.
|
|
499
516
|
const PAGE_YML_MANAGED_KEYS = new Set([
|
|
500
517
|
'id', 'title', 'description', 'label', 'keywords', 'index', 'hidden',
|
|
501
518
|
'hideIn', 'knowledge', 'trackSections', 'redirect', 'rewrite', 'layout', 'seo',
|
|
502
|
-
'fetch', 'sections',
|
|
519
|
+
'query', 'fetch', 'data', 'sections',
|
|
503
520
|
])
|
|
504
521
|
|
|
505
522
|
// Inverse of site.js buildPageData → the `page.yml` / `folder.yml` object.
|
|
@@ -507,7 +524,7 @@ const PAGE_YML_MANAGED_KEYS = new Set([
|
|
|
507
524
|
// directory (name, page.yml vs folder.yml, `[param]/`), not the config body.
|
|
508
525
|
// Identity (the backend uuid) is NOT written here — it lives in the gitignored
|
|
509
526
|
// `.uniweb/` index so authored files stay clean.
|
|
510
|
-
function pageRecordToYml(record, sectionsArray, sourceLocale) {
|
|
527
|
+
function pageRecordToYml(record, sectionsArray, sourceLocale, existing = null) {
|
|
511
528
|
const y = {}
|
|
512
529
|
if (record.stable_id !== undefined) y.id = record.stable_id
|
|
513
530
|
const title = unwrapLocalized(record.title, sourceLocale)
|
|
@@ -531,8 +548,12 @@ function pageRecordToYml(record, sectionsArray, sourceLocale) {
|
|
|
531
548
|
if (record.rewrite !== undefined) y.rewrite = record.rewrite
|
|
532
549
|
if (record.layout !== undefined) y.layout = record.layout
|
|
533
550
|
if (record.seo !== undefined) y.seo = record.seo
|
|
534
|
-
// Invert the build's resolution rather than copy it — see fetch-shapes.js.
|
|
535
|
-
|
|
551
|
+
// Invert the build's resolution rather than copy it — see fetch-shapes.js. Under
|
|
552
|
+
// the key the file already uses (`existing`), else `query:` for names alone.
|
|
553
|
+
if (record.fetch !== undefined) {
|
|
554
|
+
const { key, value } = authorableDeclaration(record.fetch, existing)
|
|
555
|
+
y[key] = value
|
|
556
|
+
}
|
|
536
557
|
// `sections:` exists to preserve ORDER and NESTING, which the projected filenames
|
|
537
558
|
// can't carry (they're `<stableId>.md`, with no numeric prefix). It must not also
|
|
538
559
|
// decide MEMBERSHIP — and a bare list does: the collector reads a list without
|
|
@@ -635,7 +656,7 @@ function writePagesTree(pages, pagesDir, sourceLocale, report, ctx, routePrefix
|
|
|
635
656
|
const ymlPath = join(pageDir, ymlName)
|
|
636
657
|
// Merge (not full-dump) so author-added keys survive a pull; the projector
|
|
637
658
|
// owns only PAGE_YML_MANAGED_KEYS.
|
|
638
|
-
writeMergedYaml(ymlPath, pageRecordToYml(record, sectionsArray, sourceLocale), PAGE_YML_MANAGED_KEYS)
|
|
659
|
+
writeMergedYaml(ymlPath, pageRecordToYml(record, sectionsArray, sourceLocale, readAuthoredYaml(ymlPath)), PAGE_YML_MANAGED_KEYS)
|
|
639
660
|
report.pages.push(ymlPath)
|
|
640
661
|
|
|
641
662
|
writePagesTree(record.$children || [], pageDir, sourceLocale, report, ctx, route)
|
package/src/uwx/site.js
CHANGED
|
@@ -39,7 +39,7 @@
|
|
|
39
39
|
|
|
40
40
|
import { readdir, readFile } from 'node:fs/promises'
|
|
41
41
|
import { existsSync } from 'node:fs'
|
|
42
|
-
import { join, parse } from 'node:path'
|
|
42
|
+
import { join, parse, relative } from 'node:path'
|
|
43
43
|
import {
|
|
44
44
|
readYamlFile,
|
|
45
45
|
readFolderConfig,
|
|
@@ -52,7 +52,9 @@ import {
|
|
|
52
52
|
parseWildcardArray,
|
|
53
53
|
applyWildcardOrder,
|
|
54
54
|
processMarkdownFile,
|
|
55
|
-
|
|
55
|
+
declaredFetch,
|
|
56
|
+
checkDeclaration,
|
|
57
|
+
fetchFromQueryShorthand,
|
|
56
58
|
assertRouteFolder,
|
|
57
59
|
} from '../site/content-collector.js'
|
|
58
60
|
import { refuseUnder } from '../site/data-fetcher.js'
|
|
@@ -196,7 +198,7 @@ function mapSectionData(section) {
|
|
|
196
198
|
}
|
|
197
199
|
|
|
198
200
|
function buildPageData(config, ctx) {
|
|
199
|
-
const { slug, mode, isDynamic, paramName, isRoot, siteIndex, sourceLocale, translations } =
|
|
201
|
+
const { slug, mode, isDynamic, paramName, isRoot, siteIndex, sourceLocale, translations, where } =
|
|
200
202
|
ctx
|
|
201
203
|
// The page `slug` is the localized route source — a `{lang: slug}` map (the
|
|
202
204
|
// site-content Model declares it localized; greenlit 2026-06-13). A single-locale
|
|
@@ -234,10 +236,12 @@ function buildPageData(config, ctx) {
|
|
|
234
236
|
setIf(data, 'rewrite', config.rewrite)
|
|
235
237
|
setIf(data, 'layout', config.layout)
|
|
236
238
|
setIf(data, 'seo', config.seo)
|
|
237
|
-
// ⭐ A `
|
|
239
|
+
// ⭐ A `query:` or `fetch:` LIST means "fetch each" — one declaration per entry. Before
|
|
238
240
|
// 2026-09-02 this kept `[0]` and dropped the rest silently, so the wire
|
|
239
241
|
// carried one dataset for a page that asked for several.
|
|
240
|
-
|
|
242
|
+
// `fetch:`, or the `query:` shorthand, read and refused exactly as the build
|
|
243
|
+
// reads them (`declaredFetch`) — a `folder.yml` as much as a `page.yml`.
|
|
244
|
+
let fetch = declaredFetch(config, where ?? 'page.yml')
|
|
241
245
|
// `where: { path: { under } }` is refused here as the build refuses it
|
|
242
246
|
// (`parseFetchConfig`): a site that cannot build must not sync either. A
|
|
243
247
|
// section's fetch is refused where the collector parses it.
|
|
@@ -249,7 +253,7 @@ function buildPageData(config, ctx) {
|
|
|
249
253
|
// it would never resolve at render (the static build resolves it the same way
|
|
250
254
|
// in site/data-fetcher.js parseFetchConfig). The gateway serves the collection
|
|
251
255
|
// at `<base>/data/<name>.json`.
|
|
252
|
-
// ⛔ **Mapped, not read.** A `
|
|
256
|
+
// ⛔ **Mapped, not read.** A `query:`/`fetch:` LIST reaches here as an array, and
|
|
253
257
|
// `fetch.query` on one is `undefined` — so a property test would skip the
|
|
254
258
|
// resolution below and put bare `{ query }` entries on the wire with no
|
|
255
259
|
// `path`, no `as` and no `schema`. That is the silent-empty class: a payload
|
|
@@ -567,6 +571,7 @@ async function walkPagesNested(ctx, dirPath, parentSlugPath, inheritedMode, pare
|
|
|
567
571
|
const slugPath = parentSlugPath ? `${parentSlugPath}/${slug}` : slug
|
|
568
572
|
|
|
569
573
|
const data = buildPageData(f.config, {
|
|
574
|
+
where: relative(siteRoot, join(f.path, f.source)),
|
|
570
575
|
slug,
|
|
571
576
|
mode,
|
|
572
577
|
isDynamic: !!dyn,
|
|
@@ -1080,14 +1085,15 @@ function settingsNested(siteYml, { headHtml, themeYml, sourceLocale, translation
|
|
|
1080
1085
|
// summarized.
|
|
1081
1086
|
setIf(settings, 'agents', siteYml.agents)
|
|
1082
1087
|
|
|
1083
|
-
// ⭐ The site-level fetch, DESUGARED and under its real name. `
|
|
1088
|
+
// ⭐ The site-level fetch, DESUGARED and under its real name. `query:` is the
|
|
1084
1089
|
// authoring shorthand for `fetch:` and every other tier already calls the wire
|
|
1085
|
-
// field `fetch`; the site tier
|
|
1086
|
-
// `
|
|
1087
|
-
// `where`. ⚠️ The
|
|
1088
|
-
// reads the published key's sources off it.
|
|
1090
|
+
// field `fetch`; the site tier's wire field was `data` until 2026-09-09.
|
|
1091
|
+
// `query:` / `fetch:` checked as the build checks them, and `under` refused;
|
|
1092
|
+
// the `query:` shorthand carries no `where`. ⚠️ The desugaring stays inline in
|
|
1093
|
+
// `setIf`: `gen-emit-surface.mjs` reads the published key's sources off it.
|
|
1094
|
+
checkDeclaration(siteYml, 'site.yml')
|
|
1089
1095
|
for (const one of [siteYml.fetch].flat()) refuseUnder(one?.where, 'site.yml fetch')
|
|
1090
|
-
setIf(settings, 'fetch', siteYml.fetch ??
|
|
1096
|
+
setIf(settings, 'fetch', siteYml.fetch ?? fetchFromQueryShorthand(siteYml.query))
|
|
1091
1097
|
|
|
1092
1098
|
// ⭐ The SITE TIER of framework's own `{name, hide, params}` layout object, which
|
|
1093
1099
|
// the page and folder tiers have always had. `hide` is a non-destructive per-area
|
|
@@ -1265,15 +1271,6 @@ export async function siteProjectToDocument(siteRoot, opts = {}) {
|
|
|
1265
1271
|
// endpoint; here it leaves the site with the RIGHT answer.
|
|
1266
1272
|
//
|
|
1267
1273
|
// The provisioned record rides the `$services` section instead (see servicesNested).
|
|
1268
|
-
// ⭐ DESUGARED, like every other tier. `data:` is the shorthand for `fetch:`
|
|
1269
|
-
// (`data: articles` → `{ query: 'articles' }`), and the page level has always
|
|
1270
|
-
// desugared before emitting. The site level shipped the bare string until
|
|
1271
|
-
// 2026-09-09, so `info.data` carried two different shapes depending on which
|
|
1272
|
-
// key the author happened to type.
|
|
1273
|
-
//
|
|
1274
|
-
// 📌 The wire NAME is still `data` and becomes `fetch` when the Section moves —
|
|
1275
|
-
// renaming it now would be a second destructive wire change for a cosmetic gain;
|
|
1276
|
-
// renaming it during the move is free.
|
|
1277
1274
|
// ⛔ THE CONFIGURATION KEYS ARE NOT HERE — they ride the `settings` Section
|
|
1278
1275
|
// (`settingsNested` above). `info` is the BRIEF: what a card or a select dropdown
|
|
1279
1276
|
// renders, plus what a listing can filter on. Eighteen keys moved off it on
|
package/src/validate-data.js
CHANGED
|
@@ -54,7 +54,7 @@ import { processQueries } from './site/query-processor.js'
|
|
|
54
54
|
* agree on which schema governs what by construction.
|
|
55
55
|
*
|
|
56
56
|
* Inputs are consumed from what the canonical build parsers already compute —
|
|
57
|
-
* `section.fetch` (the binding resolved from `
|
|
57
|
+
* `section.fetch` (the binding resolved from `query:` / `fetch:`) and
|
|
58
58
|
* `schema.json[type].data` (the key→ref bindings). Re-deriving either would let
|
|
59
59
|
* this command and the build disagree about what feeds what.
|
|
60
60
|
*
|
|
@@ -139,7 +139,7 @@ export async function validateDataInputs({ siteRoot, foundationPath }) {
|
|
|
139
139
|
`but this page delivers ${delivered.map((k) => `\`${k}\``).join(', ')}. ` +
|
|
140
140
|
`The section will render with no data and nothing else will say so. ` +
|
|
141
141
|
`Name the query for the key the section reads, or give the section its own ` +
|
|
142
|
-
`\`
|
|
142
|
+
`\`query: <name>\`.`,
|
|
143
143
|
// One user per declared key, so `uniweb validate` can print
|
|
144
144
|
// `used by /team › Team › data.team` — the key is the thing to rename.
|
|
145
145
|
users: declaredKeys.map((k) => ({ route: page.route, section: type, key: k })),
|
|
@@ -479,7 +479,7 @@ async function loadStandardSchemas() {
|
|
|
479
479
|
*/
|
|
480
480
|
function collectInputs(section, pageFetch, siteFetch) {
|
|
481
481
|
const byKey = new Map()
|
|
482
|
-
// ⭐ Each level may declare SEVERAL — `
|
|
482
|
+
// ⭐ Each level may declare SEVERAL — `query: [team, articles]` — so each is
|
|
483
483
|
// flattened rather than read. Order is least- to most-specific and `set`
|
|
484
484
|
// overwrites, which is what makes a section's declaration win the key.
|
|
485
485
|
for (const source of [siteFetch, pageFetch, section.fetch]) {
|