@uniweb/build 0.44.5 → 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.
@@ -54,8 +54,8 @@ import { join, basename, extname, dirname, relative, resolve, sep } from 'node:p
54
54
  import { existsSync } from 'node:fs'
55
55
  import yaml from 'js-yaml'
56
56
  import { parseBibtex } from '@citestyle/bibtex'
57
- import { DATA_DIR, fillRoutePattern } from '@uniweb/core'
58
- import { applyWhere, applySort } from './data-fetcher.js'
57
+ import { DATA_DIR, fillRoutePattern, withoutRouteVariables } from '@uniweb/core'
58
+ import { applyWhere, applySort, refuseUnder } from './data-fetcher.js'
59
59
  import { resolveAssetPath, walkContentAssets, isLocalAssetPath } from './assets.js'
60
60
  import { readEntityPool, groupPoolBySchema, ENTITIES_DIR } from './entity-pool.js'
61
61
  import { readRecordsConfig, resolveFolder, FOLDER_MISSING } from './records-config.js'
@@ -113,6 +113,7 @@ function parseQueryConfig(name, config) {
113
113
  schema: config,
114
114
  url: null,
115
115
  route: null,
116
+ scope: null,
116
117
  sort: null,
117
118
  where: null,
118
119
  filter: null,
@@ -122,6 +123,7 @@ function parseQueryConfig(name, config) {
122
123
  }
123
124
  }
124
125
 
126
+ refuseUnder(config.where, `queries.${name}`)
125
127
  return {
126
128
  name,
127
129
  // The query's schema selects its records from the pool — `entities/{schema}/`
@@ -129,6 +131,9 @@ function parseQueryConfig(name, config) {
129
131
  schema: config.schema || null,
130
132
  url: config.url || null,
131
133
  route: config.route || null,
134
+ // The folder branch the query reads (`records.yml` placement). ⛔ Not read
135
+ // here until 2026-09-11: a named query's `scope` was ignored on this lane.
136
+ scope: typeof config.scope === 'string' ? config.scope : null,
132
137
  sort: config.sort || null,
133
138
  // `where:` is the CANONICAL predicate; `filter:` is the deprecated string DSL
134
139
  // it replaced. Both are carried and both are applied below, in the same order
@@ -142,7 +147,7 @@ function parseQueryConfig(name, config) {
142
147
  },
143
148
  // `deferred:` lists fields that are heavy (article body, full nested
144
149
  // arrays). Those fields are stripped from the cascade payload that
145
- // ships with `data: <name>` declarations, and per-record full files
150
+ // ships with `query: <name>` declarations, and per-record full files
146
151
  // are emitted at public/data/<name>/<slug>.json. Components that
147
152
  // need the full record fetch the per-record file on demand, either
148
153
  // automatically on dynamic-route pages (entity-store routes the
@@ -673,6 +678,19 @@ async function collectItems(siteDir, config, entitiesDir, basePath) {
673
678
  // Filter out nulls (unpublished items)
674
679
  items = items.filter(Boolean)
675
680
 
681
+ // ⭐ `$name` IS THE RECORD HANDLE ON EVERY SITE (ruled 2026-09-11 [Diego]) — the
682
+ // field a `[slug]` or `[...path]` page matches, and the one the records service
683
+ // serves. It is the record's FINAL slug: set here, after every format has been
684
+ // read and flattened, so a frontmatter `slug:` (which wins over the filename),
685
+ // a BibTeX cite key and an array-form file's own `slug` all count — exactly what
686
+ // our sync sends as the entry's name (`uwx/entity-source.js`). `slug` stays:
687
+ // foundations and templates read it.
688
+ items = items.map((item) => (
689
+ item && typeof item === 'object' && item.slug !== undefined && item.slug !== null && item.slug !== ''
690
+ ? { ...item, $name: String(item.slug) }
691
+ : item
692
+ ))
693
+
676
694
  warnDuplicateSlugs(items, config.name)
677
695
 
678
696
  // `route:` on the query — bake each record's canonical href.
@@ -711,8 +729,19 @@ async function collectItems(siteDir, config, entitiesDir, basePath) {
711
729
  // the sync wire, stored — and never applied, while the DEPRECATED one it replaced
712
730
  // worked. An author following current guidance got silence and shipped unfiltered
713
731
  // data. Pinned by `tests/collection-query-terms.test.js`.
714
- if (config.where) {
715
- items = applyWhere(items, config.where)
732
+ // ⭐ ONLY THE `where` FIXED FOR EVERY PAGE. A clause bound to the route —
733
+ // `where: { tag: :dir }` — cannot be applied to a file written once for every
734
+ // page; the runtime binds it per page (`@uniweb/core/fetch-config`,
735
+ // `resolveQuerySource`). ⛔ Until 2026-09-11 it was applied here to the literal
736
+ // `':dir'`, and the query compiled to no records (measured).
737
+ //
738
+ // ⛔ `scope` is NEVER baked, fixed or routed. The runtime applies the one that
739
+ // wins — a page fetch's own, else this query's — which is what the records
740
+ // service does. Baked here, a page's `scope:` could only narrow inside the
741
+ // query's branch on a static site and would replace it on a hosted one.
742
+ const fixed = withoutRouteVariables({ where: config.where })
743
+ if (fixed.where) {
744
+ items = applyWhere(items, fixed.where)
716
745
  }
717
746
 
718
747
  // Apply sort
@@ -884,7 +913,7 @@ export async function writeQueryFiles(siteDir, byQuery, queriesConfig = null) {
884
913
  if (deferred && deferred.length > 0) {
885
914
  // `deferred:` is set — emit two payloads:
886
915
  // 1. The cascade JSON at /data/<name>.json with deferred fields stripped.
887
- // This is what `data: <name>` declarations deliver everywhere.
916
+ // This is what `query: <name>` declarations deliver everywhere.
888
917
  // 2. Per-record full files at /data/<name>/<slug>.json with every field.
889
918
  // Dynamic-route singular fetches and useEntityDetail hooks read these.
890
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,8 +52,12 @@ import {
52
52
  parseWildcardArray,
53
53
  applyWildcardOrder,
54
54
  processMarkdownFile,
55
- fetchFromDataShorthand,
55
+ declaredFetch,
56
+ checkDeclaration,
57
+ fetchFromQueryShorthand,
58
+ assertRouteFolder,
56
59
  } from '../site/content-collector.js'
60
+ import { refuseUnder } from '../site/data-fetcher.js'
57
61
  import { normalizeHideIn } from '../site/nav-visibility.js'
58
62
  import { resolveDefaultLocale, validateLanguageConfig, queryDataUrl } from '@uniweb/core'
59
63
  import { emitEntitySyncPackage } from './entity-document.js'
@@ -194,7 +198,7 @@ function mapSectionData(section) {
194
198
  }
195
199
 
196
200
  function buildPageData(config, ctx) {
197
- const { slug, mode, isDynamic, paramName, isRoot, siteIndex, sourceLocale, translations } =
201
+ const { slug, mode, isDynamic, paramName, isRoot, siteIndex, sourceLocale, translations, where } =
198
202
  ctx
199
203
  // The page `slug` is the localized route source — a `{lang: slug}` map (the
200
204
  // site-content Model declares it localized; greenlit 2026-06-13). A single-locale
@@ -232,10 +236,16 @@ function buildPageData(config, ctx) {
232
236
  setIf(data, 'rewrite', config.rewrite)
233
237
  setIf(data, 'layout', config.layout)
234
238
  setIf(data, 'seo', config.seo)
235
- // ⭐ 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
236
240
  // 2026-09-02 this kept `[0]` and dropped the rest silently, so the wire
237
241
  // carried one dataset for a page that asked for several.
238
- 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')
245
+ // `where: { path: { under } }` is refused here as the build refuses it
246
+ // (`parseFetchConfig`): a site that cannot build must not sync either. A
247
+ // section's fetch is refused where the collector parses it.
248
+ for (const one of [fetch].flat()) refuseUnder(one?.where, 'fetch')
239
249
  // Resolve the authored `query:` shorthand to the runtime-fetchable
240
250
  // `path: /data/<name>.json` (the static convention the default-fetcher uses).
241
251
  // A shell/backend-hosted site renders client-side with NO prerender, so the
@@ -243,7 +253,7 @@ function buildPageData(config, ctx) {
243
253
  // it would never resolve at render (the static build resolves it the same way
244
254
  // in site/data-fetcher.js parseFetchConfig). The gateway serves the collection
245
255
  // at `<base>/data/<name>.json`.
246
- // ⛔ **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
247
257
  // `fetch.query` on one is `undefined` — so a property test would skip the
248
258
  // resolution below and put bare `{ query }` entries on the wire with no
249
259
  // `path`, no `as` and no `schema`. That is the silent-empty class: a payload
@@ -548,14 +558,20 @@ async function walkPagesNested(ctx, dirPath, parentSlugPath, inheritedMode, pare
548
558
  const { siteRoot, siteIndex, sourceLocale, translations } = ctx
549
559
  const folders = await orderedSubfolders(dirPath, inheritedMode, parentConfig)
550
560
  const out = []
561
+ // A folder inside a `[...path]` folder can never be reached, and `[dir]` /
562
+ // `[path]` would name a route variable — refused here as the collector refuses
563
+ // them, so a site that cannot build cannot sync either (ruled 2026-09-11).
564
+ const insideCatchAll = (parentSlugPath || '').split('/').includes(CATCH_ALL_MARKER)
551
565
  for (let i = 0; i < folders.length; i++) {
552
566
  const f = folders[i]
567
+ assertRouteFolder(f.dirName, insideCatchAll ? '/:path*' : '/')
553
568
  const dyn = f.dirName.match(DYNAMIC_RE)
554
569
  const slug = dyn ? dyn[1] : f.name
555
570
  const mode = f.source === 'folder.yml' ? 'folder' : 'page'
556
571
  const slugPath = parentSlugPath ? `${parentSlugPath}/${slug}` : slug
557
572
 
558
573
  const data = buildPageData(f.config, {
574
+ where: relative(siteRoot, join(f.path, f.source)),
559
575
  slug,
560
576
  mode,
561
577
  isDynamic: !!dyn,
@@ -803,6 +819,7 @@ const DECL_NOT_ON_WIRE = new Set([
803
819
  function queriesNested(declarations, uuids = null, org = null) {
804
820
  const out = []
805
821
  for (const [name, d] of Object.entries(declarations)) {
822
+ refuseUnder(d.where, `queries.${name}`)
806
823
  const data = {}
807
824
  const source = d.path ? { path: d.path } : d.url ? { url: d.url } : d.source
808
825
  setIf(data, 'source', source)
@@ -1068,10 +1085,15 @@ function settingsNested(siteYml, { headHtml, themeYml, sourceLocale, translation
1068
1085
  // summarized.
1069
1086
  setIf(settings, 'agents', siteYml.agents)
1070
1087
 
1071
- // ⭐ 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
1072
1089
  // authoring shorthand for `fetch:` and every other tier already calls the wire
1073
- // field `fetch`; the site tier called it `data` until 2026-09-09.
1074
- setIf(settings, 'fetch', siteYml.fetch ?? fetchFromDataShorthand(siteYml.data))
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')
1095
+ for (const one of [siteYml.fetch].flat()) refuseUnder(one?.where, 'site.yml fetch')
1096
+ setIf(settings, 'fetch', siteYml.fetch ?? fetchFromQueryShorthand(siteYml.query))
1075
1097
 
1076
1098
  // ⭐ The SITE TIER of framework's own `{name, hide, params}` layout object, which
1077
1099
  // the page and folder tiers have always had. `hide` is a non-destructive per-area
@@ -1249,15 +1271,6 @@ export async function siteProjectToDocument(siteRoot, opts = {}) {
1249
1271
  // endpoint; here it leaves the site with the RIGHT answer.
1250
1272
  //
1251
1273
  // The provisioned record rides the `$services` section instead (see servicesNested).
1252
- // ⭐ DESUGARED, like every other tier. `data:` is the shorthand for `fetch:`
1253
- // (`data: articles` → `{ query: 'articles' }`), and the page level has always
1254
- // desugared before emitting. The site level shipped the bare string until
1255
- // 2026-09-09, so `info.data` carried two different shapes depending on which
1256
- // key the author happened to type.
1257
- //
1258
- // 📌 The wire NAME is still `data` and becomes `fetch` when the Section moves —
1259
- // renaming it now would be a second destructive wire change for a cosmetic gain;
1260
- // renaming it during the move is free.
1261
1274
  // ⛔ THE CONFIGURATION KEYS ARE NOT HERE — they ride the `settings` Section
1262
1275
  // (`settingsNested` above). `info` is the BRIEF: what a card or a select dropdown
1263
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]) {