@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@uniweb/build",
3
- "version": "0.45.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/schemas": "^0.2.13",
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.0"
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.0"
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 `data:` LIST parses to an array, and `siteFetch.prerender` on one is
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
- * Desugar a `data:` declaration into a `fetch:` one.
224
- *
225
- * `data: team` `{ query: 'team' }`; `data: [team, articles]` one config per
226
- * entry. **A list means "fetch each"** — see `parseFetchConfig` for why the
227
- * declaration is plural by necessity and why that is not a statement about
228
- * request count.
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
- * Before 2026-09-02 a list kept `[0]` and dropped the rest **silently**: no
231
- * warning, no error, and the array was not carried forward on the section, so
232
- * nothing downstream could recover it. An author writing a list got one dataset
233
- * and a section rendering empty.
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} data
288
+ * @param {string|Array<string>|undefined|null} query - already checked (`checkDeclaration`)
236
289
  * @returns {Object|Array<Object>|undefined}
237
290
  */
238
- export function fetchFromDataShorthand(data) {
239
- if (!data) return undefined
240
- if (Array.isArray(data)) return data.map((query) => ({ query }))
241
- return { query: data }
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:`, `data:` declarations, theme. **The build
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
- const { type, preset, input, props, fetch, data, id: frontmatterId, ...params } = frontMatter
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
- // `data:` shorthand — `data: team` → `fetch: { query: team }`.
977
- //
978
- // **A LIST KEEPS ONLY `[0]`. THE REST ARE INERT.** This comment used to say
979
- // *"others via inheritData"*, and that is wrong in a way worth spelling out,
980
- // because it read as a mechanism and was copied into a second doc as one.
981
- //
982
- // `inheritData` is a BOOLEAN OPT-OUT on a section's `meta.js`
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
- // Support 'data:' shorthand at page level
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
- fetch: parseFetchConfig(dirConfig.fetch) || null,
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
- fetch: null,
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 subResult = await collectPagesRecursive(childDirPath, containerRoute, siteRoot, childOrderConfig, parentFetch, versionContext, 'pages', null, effectiveLayout)
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
- const { publishLanguages: _publishLanguages, ...runtimeSiteConfig } = siteConfig
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:, data: and theme settings in these files were dropped.\n` +
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
- // ⛔ `data:` IS THE SHORTHAND FOR `fetch:` AND BOTH LANES MUST READ IT.
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
- // `data: articles` reached a backend on the sync lane and was silently
2726
- // ignored on a static build — the works-on-one-lane shape. The page level
2727
- // has always used this helper (`pageConfig.fetch || fetchFromDataShorthand(…)`);
2728
- // the site level simply never did.
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
@@ -274,9 +274,9 @@ export function toFetchList(fetch) {
274
274
  }
275
275
 
276
276
  /**
277
- * Parse a `fetch:` (or desugared `data:`) declaration.
277
+ * Parse a `fetch:` (or desugared `query:`) declaration.
278
278
  *
279
- * ⭐ **A LIST MEANS "FETCH EACH".** `data: [team, articles]` declares two needs
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.
@@ -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 `data: <name>` declarations, and per-record full files
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 `data: <name>` declarations deliver everywhere.
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)
@@ -62,7 +62,7 @@
62
62
  ],
63
63
  "fetch": [
64
64
  "site.yml::fetch",
65
- "site.yml::data"
65
+ "site.yml::query"
66
66
  ],
67
67
  "fetcher": [
68
68
  "site.yml::fetcher"
@@ -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); any
190
- // other value replaces. NOTE: this re-dumps the file, so author comments/order are
191
- // not preserved — acceptable for machine-owned config, but comment-preserving
192
- // merges for hand-authored config files are a quality bar to revisit.
193
- function mergeYamlConfig(filePath, changes) {
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
  /**
@@ -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 { authorableFetch } from '../site/fetch-shapes.js'
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
- // ⛔ `data` IS NOT VERBATIM — see the explicit branch below. It projects to
142
- // `site.yml::fetch`, not `site.yml::data`.
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
- // `data:` is the authoring SHORTHAND for `fetch:` and the wire carries the
244
- // desugared form, so `fetch:` is the key that describes what came back. The page
245
- // lane has always projected this way (`y.fetch = authorableFetch(record.fetch)`);
246
- // the site lane wrote `data:` verbatim, so an author who typed `fetch:` pushed,
247
- // pulled, and got a `data:` block back the value survived and the authored key
248
- // did not, which the round-trip law forbids (uwx-format.md).
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
- siteChanges.fetch = Array.isArray(wireFetch)
255
- ? wireFetch.map((f) => authorableFetch(f))
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
- if (fetch !== undefined) frontmatter.fetch = authorableFetch(fetch)
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
- if (record.fetch !== undefined) y.fetch = authorableFetch(record.fetch)
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
- fetchFromDataShorthand,
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 `data:` LIST means "fetch each" — one declaration per entry. Before
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
- let fetch = config.fetch ?? fetchFromDataShorthand(config.data)
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 `data:`/`fetch:` LIST reaches here as an array, and
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. `data:` is the
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 called it `data` until 2026-09-09.
1086
- // `under` is refused as the build refuses it; the `data:` shorthand carries no
1087
- // `where`. ⚠️ The source expression stays inside `setIf`: `gen-emit-surface.mjs`
1088
- // reads the published key's sources off it.
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 ?? fetchFromDataShorthand(siteYml.data))
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
@@ -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 `data:` / `fetch:`) and
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
- `\`fetch: { query: <name> }\`.`,
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 — `data: [team, articles]` — so each is
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]) {