@uniweb/build 0.45.0 → 0.46.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 +1 -1
- package/src/site/content-collector.js +111 -57
- 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.46.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",
|
|
61
|
+
"@uniweb/theming": "^0.1.15",
|
|
62
|
+
"@uniweb/schemas": "^0.2.13",
|
|
60
63
|
"@uniweb/content-reader": "^1.2.4",
|
|
61
|
-
"@uniweb/content-writer": "^0.3.4",
|
|
62
64
|
"@uniweb/projections": "^0.6.1",
|
|
63
|
-
"@uniweb/
|
|
64
|
-
"@uniweb/semantic-parser": "^1.4.0",
|
|
65
|
-
"@uniweb/theming": "^0.1.15"
|
|
65
|
+
"@uniweb/content-writer": "^0.3.4"
|
|
66
66
|
},
|
|
67
67
|
"optionalDependencies": {
|
|
68
|
-
"@uniweb/runtime": "^0.20.
|
|
68
|
+
"@uniweb/runtime": "^0.20.1"
|
|
69
69
|
},
|
|
70
70
|
"peerDependencies": {
|
|
71
71
|
"vite": "^5.0.0 || ^6.0.0 || ^7.0.0",
|
|
@@ -74,7 +74,7 @@
|
|
|
74
74
|
"@tailwindcss/vite": "^4.0.0",
|
|
75
75
|
"@vitejs/plugin-react": "^4.0.0 || ^5.0.0",
|
|
76
76
|
"vite-plugin-svgr": "^4.0.0",
|
|
77
|
-
"@uniweb/core": "^0.25.
|
|
77
|
+
"@uniweb/core": "^0.25.1"
|
|
78
78
|
},
|
|
79
79
|
"peerDependenciesMeta": {
|
|
80
80
|
"vite": {
|
package/src/prerender.js
CHANGED
|
@@ -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
|
|
@@ -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
|
|
@@ -1987,7 +2029,10 @@ async function collectPagesRecursive(dirPath, parentRoute, siteRoot, orderConfig
|
|
|
1987
2029
|
changefreq: dirConfig.seo?.changefreq || null,
|
|
1988
2030
|
priority: dirConfig.seo?.priority || null
|
|
1989
2031
|
},
|
|
1990
|
-
|
|
2032
|
+
// ⭐ `declaredFetch`, as every other level: this read `dirConfig.fetch`
|
|
2033
|
+
// alone until 2026-09-11, so a container's `folder.yml` shorthand reached
|
|
2034
|
+
// a backend on `push` and was dropped from a static build.
|
|
2035
|
+
fetch: parseFetchConfig(declaredFetch(dirConfig, relative(siteRoot, join(entryPath, 'folder.yml')))) || null,
|
|
1991
2036
|
hasContent: false,
|
|
1992
2037
|
sections: [],
|
|
1993
2038
|
order: typeof dirConfig.order === 'number' ? dirConfig.order : undefined
|
|
@@ -2079,7 +2124,12 @@ async function collectPagesRecursive(dirPath, parentRoute, siteRoot, orderConfig
|
|
|
2079
2124
|
changefreq: dirConfig.seo?.changefreq || null,
|
|
2080
2125
|
priority: dirConfig.seo?.priority || null
|
|
2081
2126
|
},
|
|
2082
|
-
|
|
2127
|
+
// ⭐ The folder's own declaration, as the folder-mode container above and
|
|
2128
|
+
// every other level read it. ⛔ This was `fetch: null` until 2026-09-11:
|
|
2129
|
+
// a `folder.yml` here lost its `fetch:` (and its shorthand) on a static
|
|
2130
|
+
// build while `push` carried it, so its pages had the folder's data on a
|
|
2131
|
+
// hosted site and none on an exported one.
|
|
2132
|
+
fetch: parseFetchConfig(declaredFetch(dirConfig, relative(siteRoot, join(entryPath, 'folder.yml')))) || null,
|
|
2083
2133
|
hasContent: false,
|
|
2084
2134
|
sections: [],
|
|
2085
2135
|
order: typeof dirConfig.order === 'number' ? dirConfig.order : undefined
|
|
@@ -2091,8 +2141,11 @@ async function collectPagesRecursive(dirPath, parentRoute, siteRoot, orderConfig
|
|
|
2091
2141
|
pages.push(containerPage)
|
|
2092
2142
|
}
|
|
2093
2143
|
|
|
2144
|
+
// The container's own fetch config, or the parent's — as the folder-mode
|
|
2145
|
+
// container above passes it.
|
|
2094
2146
|
const childDirPath = mounts?.get(entry) || entryPath
|
|
2095
|
-
const
|
|
2147
|
+
const containerFetch = containerPage.fetch || parentFetch
|
|
2148
|
+
const subResult = await collectPagesRecursive(childDirPath, containerRoute, siteRoot, childOrderConfig, containerFetch, versionContext, 'pages', null, effectiveLayout)
|
|
2096
2149
|
pages.push(...subResult.pages)
|
|
2097
2150
|
assetCollection = mergeAssetCollections(assetCollection, subResult.assetCollection)
|
|
2098
2151
|
iconCollection = mergeIconCollections(iconCollection, subResult.iconCollection)
|
|
@@ -2656,7 +2709,9 @@ export async function collectSiteContent(sitePath, options = {}) {
|
|
|
2656
2709
|
// `publishLanguages` is authoring/publish intent — it has no runtime
|
|
2657
2710
|
// consumer and never ships in a payload (the visitor runtime is
|
|
2658
2711
|
// list-unaware; the sync lane reads site.yml directly, not this output).
|
|
2659
|
-
|
|
2712
|
+
// The `query:` shorthand ships as `config.fetch`, desugared below; carried raw
|
|
2713
|
+
// it would sit beside `config.queries`, the declarations, and read as one.
|
|
2714
|
+
const { publishLanguages: _publishLanguages, query: _query, ...runtimeSiteConfig } = siteConfig
|
|
2660
2715
|
// ⛔ `$`-prefixed keys are the project's BACKEND-SCOPED state — `$uuid`, `$org`,
|
|
2661
2716
|
// `$backend`, `$services`, `$secrets` — and this payload is a PUBLISHED artifact
|
|
2662
2717
|
// that a visitor can fetch. They have no runtime reader (nothing in core, runtime
|
|
@@ -2700,7 +2755,7 @@ export async function collectSiteContent(sitePath, options = {}) {
|
|
|
2700
2755
|
`${distinctFailures.length} file${distinctFailures.length === 1 ? '' : 's'} could not be parsed as YAML:\n` +
|
|
2701
2756
|
`${lines.join('\n')}\n\n` +
|
|
2702
2757
|
` Each one contributed NOTHING to this build — page order, nesting,\n` +
|
|
2703
|
-
` sections:,
|
|
2758
|
+
` sections:, query: and theme settings in these files were dropped.\n` +
|
|
2704
2759
|
` Fix them and rebuild; the dev server reports the same files without failing.`
|
|
2705
2760
|
)
|
|
2706
2761
|
}
|
|
@@ -2720,13 +2775,12 @@ export async function collectSiteContent(sitePath, options = {}) {
|
|
|
2720
2775
|
...(publishFilterActive && Array.isArray(siteConfig.languages)
|
|
2721
2776
|
? { languages: publishable }
|
|
2722
2777
|
: {}),
|
|
2723
|
-
// ⛔ `
|
|
2778
|
+
// ⛔ `query:` IS THE SHORTHAND FOR `fetch:` AND BOTH LANES MUST READ IT.
|
|
2724
2779
|
// 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)),
|
|
2780
|
+
// shorthand reached a backend on the sync lane and was silently ignored on
|
|
2781
|
+
// a static build — the works-on-one-lane shape. Every level reads it
|
|
2782
|
+
// through `declaredFetch` now.
|
|
2783
|
+
fetch: parseFetchConfig(declaredFetch(siteConfig, 'site.yml')),
|
|
2730
2784
|
fetcher: warnRetiredFetcherKeys(siteConfig.fetcher),
|
|
2731
2785
|
// NOTE: `intelligence.yml` was read here and emitted as `config.intelligence`.
|
|
2732
2786
|
// Removed 2026-08-12 — the assistant surface is `site.yml::assistant`, which
|
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]) {
|