@uniweb/build 0.48.0 → 0.50.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.48.0",
3
+ "version": "0.50.0",
4
4
  "description": "Build tooling for the Uniweb Component Web Platform",
5
5
  "type": "module",
6
6
  "exports": {
@@ -59,13 +59,13 @@
59
59
  "yaml": "^2.5.0",
60
60
  "@uniweb/content-reader": "^1.2.4",
61
61
  "@uniweb/schemas": "^0.2.13",
62
- "@uniweb/projections": "^0.7.0",
63
- "@uniweb/theming": "^0.1.15",
64
62
  "@uniweb/semantic-parser": "^1.4.0",
63
+ "@uniweb/theming": "^0.1.15",
64
+ "@uniweb/projections": "^0.7.2",
65
65
  "@uniweb/content-writer": "^0.3.4"
66
66
  },
67
67
  "optionalDependencies": {
68
- "@uniweb/runtime": "^0.23.0"
68
+ "@uniweb/runtime": "^0.25.0"
69
69
  },
70
70
  "peerDependencies": {
71
71
  "vite": "^5.0.0 || ^6.0.0 || ^7.0.0",
@@ -74,7 +74,7 @@
74
74
  "@tailwindcss/vite": "^4.0.0",
75
75
  "@vitejs/plugin-react": "^4.0.0 || ^5.0.0",
76
76
  "vite-plugin-svgr": "^4.0.0",
77
- "@uniweb/core": "^0.27.0"
77
+ "@uniweb/core": "^0.29.0"
78
78
  },
79
79
  "peerDependenciesMeta": {
80
80
  "vite": {
@@ -10,7 +10,8 @@
10
10
  *
11
11
  * The `meta` export contains only properties needed at runtime:
12
12
  * - `background` - 'self' opt-out when component handles its own background
13
- * - `data` - CMS entity binding ({ type, limit })
13
+ * - `data` - the `content.data` keys the component declares, with their schema refs
14
+ * - `schemas` - field defaults for those keys
14
15
  * - `defaults` - Param default values
15
16
  * - `context` - Static capabilities for cross-block coordination
16
17
  * - `initialState` - Initial values for mutable block state
package/src/prerender.js CHANGED
@@ -22,6 +22,7 @@ import {
22
22
  routeBinding,
23
23
  parentRouteOf,
24
24
  deriveCacheKey,
25
+ evaluateQuery,
25
26
  } from '@uniweb/core'
26
27
  import { routePatternToRegex } from '@uniweb/core/route-match'
27
28
  import { executeFetch, mergeDataIntoContent, toFetchList, stripBuildOnlyFetchKeys } from './site/data-fetcher.js'
@@ -93,7 +94,9 @@ export function resolveExtensionPath(url, distDir, projectRoot, base) {
93
94
  * baker for the views a concrete parametric page binds to its route
94
95
  */
95
96
  export async function executeAllFetches(siteContent, siteDir, onProgress, localeInfo) {
96
- const fetchOptions = { siteRoot: siteDir, publicDir: 'public' }
97
+ // The locale this pass renders, which a `sort` collates texts in.
98
+ const locale = localeInfo?.locale ?? localeInfo?.defaultLocale ?? resolveDefaultLocale(siteContent.config) ?? null
99
+ const fetchOptions = { siteRoot: siteDir, publicDir: 'public', locale }
97
100
  const fetchedData = [] // Collected for DataStore pre-population
98
101
 
99
102
  // For non-default locales, translated collection data lives in dist/{locale}/data/
@@ -121,7 +124,7 @@ export async function executeAllFetches(siteContent, siteDir, onProgress, locale
121
124
 
122
125
  // Fetch options pointing to dist/ for localized data
123
126
  const localizedFetchOptions = isNonDefaultLocale
124
- ? { siteRoot: localeInfo.distDir, publicDir: '.' }
127
+ ? { siteRoot: localeInfo.distDir, publicDir: '.', locale }
125
128
  : fetchOptions
126
129
  const optionsFor = (cfg, oneFetch) => (cfg.path !== oneFetch.path ? localizedFetchOptions : fetchOptions)
127
130
  const entry = (cfg, data, scope) => ({ config: cfg, data, meta: { whole: cfg.whole }, _scope: scope })
@@ -129,21 +132,22 @@ export async function executeAllFetches(siteContent, siteDir, onProgress, locale
129
132
  const fetched = { site: new Map(), pages: new Map(), sections: new Map() }
130
133
 
131
134
  /**
132
- * Read one resolved config ONCE, as two things: the records it selects, uncut
133
- * (`routeSelection`), and the view it delivers — the same records cut by its
134
- * `limit`. ⭐ The view is what a page renders and hydrates; the selection is what
135
- * a parametric page expands over, because a `limit` is how many a list shows,
136
- * never which records have a page (ruled 2026-09-13 [Diego]). ⛔ Until then the
137
- * expansion read the cut list, so a query's `limit: 2` made two detail pages of
138
- * five. Null when the read failed.
135
+ * Read one resolved config ONCE, as two things: its query's SET (`routeSelection` —
136
+ * the query as saved, its `limit` included), and the view it delivers — that set
137
+ * with the fetch's `narrow` applied (`evaluateQuery`). ⭐ The view is what a page
138
+ * renders and hydrates; the set is what a parametric page expands over, because a
139
+ * parametric page has a URL for each record of its route query's set and no other —
140
+ * a fetch's narrowing never decides which pages exist (ruled 2026-09-14 [Diego]).
141
+ *
142
+ * ⛔ Until 2026-09-13 the expansion read the list a `limit` cut, so a list's
143
+ * `limit: 2` made two detail pages of five; from then until 2026-09-14 it read the
144
+ * records with no `limit` at all, the query's included. Null when the read failed.
139
145
  */
140
146
  const read = async (cfg, oneFetch) => {
141
147
  const result = await executeFetch(routeSelection(cfg), optionsFor(cfg, oneFetch))
142
148
  if (!result.data || result.error) return null
143
149
  const selection = result.data
144
- const view = Array.isArray(selection) && typeof cfg.limit === 'number' && cfg.limit > 0
145
- ? selection.slice(0, cfg.limit)
146
- : selection
150
+ const view = cfg.narrow ? evaluateQuery(selection, { narrow: cfg.narrow }, { locale }) : selection
147
151
  return { selection, view }
148
152
  }
149
153
 
@@ -249,8 +253,9 @@ export async function executeAllFetches(siteContent, siteDir, onProgress, locale
249
253
  * @param {Array} pages - Original pages array
250
254
  * @param {{ site?: Map, pages?: Map, sections?: Map }} fetched - what each level
251
255
  * selected, by binding key (`executeAllFetches`): `site` key → data; `pages` and
252
- * `sections` route → (key → data). Every record a binding selects, uncut by its
253
- * `limit` — a record past a list's count still gets its page
256
+ * `sections` route → (key → data). Each binding's query SET — the query as saved,
257
+ * its `limit` included, without the fetch's narrowing: a record past a list's count
258
+ * still gets its page, and one past the query's gets none
254
259
  * @param {function} onProgress - Progress callback
255
260
  * @param {Object} [stats] - receives `unrouted[route]`, the records with no param value
256
261
  * @param {Object} [options]
@@ -6,22 +6,19 @@
6
6
  * needed at render time:
7
7
  *
8
8
  * - background: 'self' when component handles its own background
9
- * - data: { type, limit } for CMS entity binding
9
+ * - data: { <key>: <schema ref> | null } — every `content.data` key the component
10
+ * declares, in order, with its schema ref (null for an inline shape)
11
+ * - schemas: { <key>: <lean fields> } — field defaults for the declared keys that have any
10
12
  * - defaults: param default values
11
13
  * - context: static capabilities for cross-block coordination
12
14
  * - initialState: initial values for mutable block state
13
- * - inheritData: internal flag for cascaded data delivery
14
- * true → deliver all data available at ancestor levels (default)
15
- * false → deliver nothing (component opted out with `data: false`)
16
15
  *
17
- * Data delivery is default-on: a component without any `data:` field
18
- * receives all data cascaded from its ancestor levels (block → page →
19
- * parent page → site) via `content.data.{schema}`. A component that
20
- * genuinely cannot tolerate ambient data declares `data: false`.
21
- *
22
- * `data: { entity: 'articles' }` is a **declaration**, not a gate. It
23
- * tells the editor and prepare-props what shape the component expects,
24
- * but does not restrict delivery.
16
+ * ⭐ `data:` IS THE DELIVERY — ruled 2026-09-14 [Diego]: a section's `content.data` holds
17
+ * the keys its component declares and nothing else, and which fetch fills each is worked
18
+ * out at render from the keys and their schema refs (`@uniweb/core` `fillDeclaredKeys`).
19
+ * A component with no `data:`, or `data: false`, receives none of its own. ⛔ Until then
20
+ * delivery was default-on — every key that reached a section — and `data:` was a hint for
21
+ * defaults and the editor; `data: false` was the opt-out, emitted as `inheritData: false`.
25
22
  *
26
23
  * Full metadata (titles, descriptions, hints, etc.) stays in schema.json
27
24
  * for the visual editor.
@@ -31,23 +28,15 @@ import { isRichSchema } from '@uniweb/core'
31
28
  import { flatRecordFields } from '@uniweb/schemas/conform'
32
29
 
33
30
  /**
34
- * Parse data string into structured object
35
- * 'events' -> { type: 'events', limit: null }
36
- * 'events:6' -> { type: 'events', limit: 6 }
31
+ * A `data:` entry's schema ref — a ref string, or `{ schema }` — or null for an inline
32
+ * shape, which names no schema and is filled only under its own key.
37
33
  *
38
- * @param {string} dataString
39
- * @returns {{ type: string, limit: number|null }}
34
+ * @param {string|Object} value
35
+ * @returns {string|null}
40
36
  */
41
- function parseDataString(dataString) {
42
- if (!dataString || typeof dataString !== 'string') {
43
- return null
44
- }
45
-
46
- const [type, limitStr] = dataString.split(':')
47
- return {
48
- type: type.trim(),
49
- limit: limitStr ? parseInt(limitStr, 10) : null,
50
- }
37
+ function dataRef(value) {
38
+ if (typeof value === 'string') return value || null
39
+ return value && typeof value === 'object' && typeof value.schema === 'string' && value.schema ? value.schema : null
51
40
  }
52
41
 
53
42
  /**
@@ -284,18 +273,22 @@ export function extractRuntimeSchema(fullMeta, dataSchemaMap = {}) {
284
273
  runtime.background = fullMeta.background
285
274
  }
286
275
 
287
- // Data schemas. `data:` is the single declaration surface for a section's
288
- // structured data: it maps each `content.data` key to its schema. A value
289
- // is a named ref (`'@/member'`), an inline field map, or an inline rich-form
290
- // (`{ fields: [...] }`, an editor form). Source-agnostic — the data may
291
- // arrive by fetch, tagged code block, or editor form; the schema and its
292
- // defaults are identical. The schema is a hint (defaults + editor), not a
293
- // delivery gate; delivery is default-on. `data: false` opts the section out
294
- // of all ambient data.
276
+ // Data. `data:` is the single declaration surface for a section's structured
277
+ // data: it maps each `content.data` key to its schema. A value is a named ref
278
+ // (`'@/member'`), an inline field map, or an inline rich-form (`{ fields: [...] }`,
279
+ // an editor form). Source-agnostic — the data may arrive by fetch, tagged code
280
+ // block, or editor form; the schema and its defaults are identical.
281
+ //
282
+ // ⭐ Every key reaches the runtime, a key with no fields included — `data` lists them
283
+ // with their refs, because the keys ARE what the section receives, and a key's ref is
284
+ // what a fetch of another name fills it by. `schemas` carries field defaults for the
285
+ // keys that have fields. `data: false` declares nothing, as no `data:` does.
295
286
  if (fullMeta.data === false) {
296
- runtime.inheritData = false
287
+ // declares no key
297
288
  } else if (fullMeta.data && typeof fullMeta.data === 'object' && !Array.isArray(fullMeta.data)) {
298
289
  for (const [key, value] of Object.entries(fullMeta.data)) {
290
+ runtime.data = runtime.data || {}
291
+ runtime.data[key] = dataRef(value)
299
292
  const lean = leanDataSchema(value, dataSchemaMap)
300
293
  if (lean) {
301
294
  runtime.schemas = runtime.schemas || {}
@@ -330,11 +323,6 @@ export function extractRuntimeSchema(fullMeta, dataSchemaMap = {}) {
330
323
  // (Top-level `schemas:` is gone — inline field maps and rich-forms are now
331
324
  // just `data:` entries with an inline value. See leanDataSchema.)
332
325
 
333
- // Data delivery is default-on. `runtime.inheritData` stays undefined unless
334
- // the component opts out with `data: false`, in which case EntityStore
335
- // delivers nothing. A `data:` binding is a hint (schema for defaults +
336
- // editor), never a delivery gate.
337
-
338
326
  return Object.keys(runtime).length > 0 ? runtime : null
339
327
  }
340
328
 
package/src/schema.js CHANGED
@@ -216,6 +216,8 @@ const DEFAULT_ONLY_CAPABILITIES = [
216
216
  'outputs',
217
217
  'handlers',
218
218
  'extension',
219
+ // the keys every section receives — what the foundation's handlers read (2026-09-14)
220
+ 'data',
219
221
  ]
220
222
 
221
223
  /**
@@ -143,7 +143,8 @@ export async function buildSiteData({
143
143
  resolvedSiteRoot,
144
144
  siteContent.config.queries,
145
145
  siteContent.config?.paths?.entities,
146
- basePath
146
+ basePath,
147
+ { locale: resolveDefaultLocale(siteContent.config) ?? null }
147
148
  )
148
149
  await writeQueryFiles(resolvedSiteRoot, byQuery, siteContent.config.queries)
149
150
 
@@ -35,7 +35,7 @@ import { normalizeHideIn, dropUnpublishedPages } from './nav-visibility.js'
35
35
  import { parseFetchConfig, toFetchList } from './data-fetcher.js'
36
36
  import { resolveExtensionUrls } from './extension-urls.js'
37
37
  import { buildTheme, extractFoundationVars } from '../theme/index.js'
38
- import { resolveDefaultLocale, resolvePublishableLocales, validateLanguageConfig, pageRouteQuery } from '@uniweb/core'
38
+ import { resolveDefaultLocale, resolvePublishableLocales, validateLanguageConfig, pageRouteQuery, currentFor } from '@uniweb/core'
39
39
  import { parseFrontmatter } from '../utils/frontmatter.js'
40
40
 
41
41
  // Try to import content-reader, fall back to simplified parser
@@ -306,10 +306,14 @@ const formatQueryNames = (value) =>
306
306
  isQueryNames(value) ? (Array.isArray(value) ? `[${value.join(', ')}]` : value) : '<name>'
307
307
 
308
308
  /**
309
- * ⚠️ A `current:` NOTHING WILL READ — said once per binding. `current:` applies on a
310
- * section's binding under the key its page's URL narrows (the route query,
311
- * `pageRouteQuery`); anywhere else the runtime ignores it, and the author would see
312
- * the section deliver its list as though the line were not there.
309
+ * ⚠️ A `current:` NOTHING WILL READ — said once per binding. `current:` follows the query a
310
+ * section's fetch names (`currentFor`, `@uniweb/core`, ruled 2026-09-14): on a page with a
311
+ * route query (`pageRouteQuery`) it is read on a fetch of that query or of any other, so it
312
+ * goes unread on a page with none — a static page, a parametric page whose sections name
313
+ * different queries with nothing above them, a layout area. There the runtime ignores it,
314
+ * and the author would see the section deliver its list as though the line were not there.
315
+ * ⛔ Until 2026-09-14 this warned by key, as the runtime then read it: on any key but the
316
+ * route query's.
313
317
  *
314
318
  * @param {Array<Object>} pages - collected pages, parents linked
315
319
  * @param {Object} layouts - collected layout areas (never on a parametric route)
@@ -324,24 +328,24 @@ function warnUnreadCurrent(pages, layouts, siteFetch) {
324
328
  sectionsOf: (p) => p.sections,
325
329
  site: siteFetch,
326
330
  }
327
- const check = (sections, where, routeKeyOf) => {
331
+ const check = (sections, where, routeOf) => {
328
332
  for (const section of sections || []) {
329
333
  for (const one of toFetchList(section.fetch)) {
330
334
  if (one?.current === undefined) continue
331
- const routeKey = routeKeyOf()
332
- if (one.as === routeKey) continue
335
+ const route = routeOf()
336
+ if (currentFor(one, route) !== null) continue
333
337
  console.warn(
334
338
  `[uniweb] ${where}: \`current: ${one.current}\` on content.data.${one.as} is ignored — it applies on a ` +
335
- `section of a parametric page, under the key its URL narrows` +
336
- (routeKey ? ` (here \`${routeKey}\`).` : '; this page has none.')
339
+ `section of a parametric page, to a fetch that names a query` +
340
+ (route ? ` (this page's URL names one of \`${route.key}\`).` : '; this page has none.')
337
341
  )
338
342
  }
339
- check(section.subsections, where, routeKeyOf)
343
+ check(section.subsections, where, routeOf)
340
344
  }
341
345
  }
342
346
  for (const page of pages) {
343
- let key
344
- check(page.sections, page.route, () => (key === undefined ? (key = pageRouteQuery(page, access)?.key ?? null) : key))
347
+ let route
348
+ check(page.sections, page.route, () => (route === undefined ? (route = pageRouteQuery(page, access) ?? null) : route))
345
349
  }
346
350
  for (const [name, areas] of Object.entries(layouts || {})) {
347
351
  for (const [area, areaPage] of Object.entries(areas || {})) {
@@ -1054,12 +1058,11 @@ async function processMarkdownFile(filePath, id, siteRoot, defaultStableId = nul
1054
1058
  // Extract @ component references → insets (mutates doc)
1055
1059
  const insets = extractInsets(proseMirrorContent)
1056
1060
 
1057
- // `query: team` → `fetch: { query: team }`; a list, one config per name, each
1058
- // delivered under its own `content.data` key — the one helper every level uses.
1059
- // Unrelated to a section type's `meta.js` `data:`, which declares the SHAPE of
1060
- // each `content.data` key and fetches nothing: delivery is default-on, so a
1061
- // section receives every fetch in the section → page → site cascade whether or
1062
- // not it names one here.
1061
+ // `query: team` → `fetch: { query: team }`; a list, one config per name, each under
1062
+ // its own `as` — the one helper every level uses. Unrelated to a section type's
1063
+ // `meta.js` `data:`, which declares the `content.data` keys a component receives and
1064
+ // fetches nothing: which fetch fills each is worked out at render (`fillDeclaredKeys`,
1065
+ // `@uniweb/core`, 2026-09-14).
1063
1066
  const resolvedFetch = declaredFetch({ fetch, query, data }, relative(siteRoot, filePath))
1064
1067
 
1065
1068
  // Stable ID for scroll targeting: frontmatter id > filename-derived > null
@@ -19,7 +19,7 @@ import { readFile } from 'node:fs/promises'
19
19
  import { join } from 'node:path'
20
20
  import { existsSync } from 'node:fs'
21
21
  import yaml from 'js-yaml'
22
- import { matchWhere, sortRecords, queryDataUrl, applyScope, whereOutsideLanguage, CURRENT_MODES } from '@uniweb/core'
22
+ import { matchWhere, sortRecords, queryDataUrl, evaluateQuery, whereOutsideLanguage, CURRENT_MODES } from '@uniweb/core'
23
23
 
24
24
  /**
25
25
  * Get a nested value from an object using dot notation
@@ -52,13 +52,17 @@ function getNestedValue(obj, path) {
52
52
  * records service refuses and the ruling dropped. A comma now THROWS here, at build
53
53
  * time, which is where an authoring error on the file lane belongs.
54
54
  *
55
+ * Texts are collated in `locale` — the page's (ruled 2026-09-14 [Diego]).
56
+ *
55
57
  * @param {Array} items - Items to sort
56
58
  * @param {string} sortExpr - Sort expression: `date`, `date desc`, `-date`
59
+ * @param {Object} [options]
60
+ * @param {string|null} [options.locale] - the page's locale
57
61
  * @returns {Array} Sorted items (new array)
58
62
  */
59
- export function applySort(items, sortExpr) {
63
+ export function applySort(items, sortExpr, { locale = null } = {}) {
60
64
  if (!sortExpr || !Array.isArray(items)) return items
61
- return sortRecords(items, sortExpr)
65
+ return sortRecords(items, sortExpr, { locale })
62
66
  }
63
67
 
64
68
  /**
@@ -79,45 +83,25 @@ export function applyWhere(items, where) {
79
83
  }
80
84
 
81
85
  /**
82
- * Apply post-processing to fetched data (where, sort, limit)
86
+ * Apply post-processing to fetched data — a resolved config's two levels, by
87
+ * `@uniweb/core`'s `evaluateQuery`, the one order of work the runtime's default
88
+ * fetcher uses too:
89
+ *
90
+ * 1. the query's set — `scope` over each record's placement (`path`), `where`,
91
+ * `sort`, `limit`;
92
+ * 2. the fetch's `narrow` of it — `where` (and `match`), `sort`, `limit`.
83
93
  *
84
- * Order of operations:
85
- * 1. where (where-object predicate) — narrows the record set
86
- * 2. sort
87
- * 3. limit
94
+ * A bad `sort:` throws here: an authoring error stops the build.
88
95
  *
89
96
  * @param {any} data - Fetched data
90
- * @param {object} config - Fetch config with optional where, sort, limit
97
+ * @param {object} config - A resolved fetch config
98
+ * @param {object} [options]
99
+ * @param {string|null} [options.locale] - the page's locale, which texts sort in
91
100
  * @returns {any} Processed data
92
101
  */
93
- export function applyPostProcessing(data, config) {
94
- if (!data || !Array.isArray(data)) return data
95
- if (!config.scope && !config.where && !config.sort && !config.limit) return data
96
-
97
- let result = data
98
-
99
- // `scope` first — the folder branch the rest of the query reads, over each
100
- // record's placement (`path`), as the runtime's default fetcher applies it.
101
- if (typeof config.scope === 'string' && config.scope) {
102
- result = applyScope(result, config.scope)
103
- }
104
-
105
- // Apply where-object predicate first (new path)
106
- if (config.where) {
107
- result = applyWhere(result, config.where)
108
- }
109
-
110
- // Apply sort
111
- if (config.sort) {
112
- result = applySort(result, config.sort)
113
- }
114
-
115
- // Apply limit last
116
- if (config.limit && config.limit > 0) {
117
- result = result.slice(0, config.limit)
118
- }
119
-
120
- return result
102
+ export function applyPostProcessing(data, config, { locale = null } = {}) {
103
+ if (!data || !Array.isArray(data) || !config) return data
104
+ return evaluateQuery(data, config, { locale })
121
105
  }
122
106
 
123
107
  /**
@@ -166,6 +150,27 @@ const RECOGNIZED_FETCH_KEYS = {
166
150
  ]),
167
151
  }
168
152
 
153
+ /**
154
+ * ⛔ `route:` ON A QUERY IS RETIRED (2026-09-14 [Diego]) — refused, because a query
155
+ * that still declares it would build and its cards would lose their links silently. A
156
+ * record links to the page whose route query is its query, as `$route`, which the
157
+ * runtime fills at render time on every lane; a fetch picks another page with
158
+ * `detailPage:`. The build baked `route:` into each compiled record as `route`, a field
159
+ * in the author's namespace that it overwrote.
160
+ *
161
+ * @param {Object} decl - a query declaration
162
+ * @param {string} context - where the declaration sits, for the message
163
+ */
164
+ export function refuseQueryRoute(decl, context) {
165
+ if (!decl || typeof decl !== 'object' || decl.route === undefined) return
166
+ throw new Error(
167
+ `[uniweb] ${context}: \`route:\` is retired. Each record links to the page that shows one record of this ` +
168
+ `query — the \`[slug]\` page whose URL names one of its records — as \`$route\`, with nothing to declare: ` +
169
+ `delete the line, and read \`record.$route\` in a component. To link to another page, add ` +
170
+ `\`detailPage: page:<id>\` to the fetch.`
171
+ )
172
+ }
173
+
169
174
  /**
170
175
  * ⛔ `under` IS RETIRED (2026-09-11 [Diego]) — refused, like every retired spelling
171
176
  * here, because an ignored predicate is a silently wrong answer. It existed for
@@ -545,8 +550,10 @@ export function parseFetchConfig(fetch, context = 'fetch', { level = null } = {}
545
550
  // (`@uniweb/core/fetch-config`) — and the parser does not know which this is.
546
551
  prerender: fetch.prerender,
547
552
  merge: fetch.merge ?? false,
548
- // A binding's adaptations of its query (`@uniweb/core/fetch-config`,
549
- // `narrowQuery`): `where` joins the query's, `sort` and `limit` replace its.
553
+ // A binding's adaptations of its query, kept as authored: the resolver makes them
554
+ // the fetch's `narrow` of the query's set (`@uniweb/core/fetch-config`,
555
+ // `setAndNarrow`) — its `where` filters the set, its `sort` re-orders it, its
556
+ // `limit` cuts it and never reaches past it.
550
557
  where: fetch.where,
551
558
  limit: fetch.limit,
552
559
  sort: fetch.sort,
@@ -698,6 +705,7 @@ export function stripBuildOnlyFetchKeys(siteContent) {
698
705
  * @param {object} options - Execution options
699
706
  * @param {string} options.siteRoot - Site root directory
700
707
  * @param {string} [options.publicDir='public'] - Public directory name
708
+ * @param {string|null} [options.locale] - the page's locale, which a `sort` collates texts in
701
709
  * @returns {Promise<{ data: any, error?: string }>} Fetched data or error
702
710
  *
703
711
  * @example
@@ -773,7 +781,7 @@ export async function executeFetch(config, options = {}) {
773
781
  }
774
782
 
775
783
  // Apply post-processing (where, sort, limit)
776
- data = applyPostProcessing(data, config)
784
+ data = applyPostProcessing(data, config, { locale: options.locale ?? null })
777
785
 
778
786
  // Ensure we return an array or object, defaulting to empty array
779
787
  return { data: data ?? [] }
@@ -136,7 +136,9 @@ export function shouldPrefetchInDev(cfg) {
136
136
  * @param {string} siteDir - Path to site directory
137
137
  */
138
138
  async function executeDevFetches(siteContent, siteDir) {
139
- const fetchOptions = { siteRoot: siteDir, publicDir: 'public' }
139
+ // The page's locale, which a `sort` collates texts in.
140
+ const locale = siteContent.config?.activeLocale ?? resolveDefaultLocale(siteContent.config) ?? null
141
+ const fetchOptions = { siteRoot: siteDir, publicDir: 'public', locale }
140
142
  const fetchedData = []
141
143
  // Resolved the way the runtime resolves it (see prerender.js::executeAllFetches
142
144
  // for why): the SPA hydrates by the cache key of ITS resolved config.
@@ -825,7 +827,9 @@ export function siteContentPlugin(options = {}) {
825
827
 
826
828
  if (queriesConfig) {
827
829
  console.log('[site-content] Materializing queries...')
828
- const byQuery = await processQueries(resolvedSitePath, queriesConfig, resolvedEntitiesDir, basePath)
830
+ const byQuery = await processQueries(resolvedSitePath, queriesConfig, resolvedEntitiesDir, basePath, {
831
+ locale: resolveDefaultLocale(earlyContent.config) ?? null,
832
+ })
829
833
  await writeQueryFiles(resolvedSitePath, byQuery, queriesConfig)
830
834
  }
831
835
  } catch (err) {
@@ -32,6 +32,7 @@ import { join } from 'node:path'
32
32
  import { existsSync, readFileSync } from 'node:fs'
33
33
  import { briefFields, flatRecordFields } from '@uniweb/schemas/conform'
34
34
  import { detectFoundationType } from './foundation-ref.js'
35
+ import { refuseQueryRoute } from './data-fetcher.js'
35
36
  import { readFile } from 'node:fs/promises'
36
37
  import yaml from 'js-yaml'
37
38
 
@@ -189,7 +190,7 @@ export async function resolveQueriesConfig(siteRoot, opts = {}) {
189
190
  }
190
191
 
191
192
  /** What a query over the site's records declares, which an external query cannot. */
192
- const SITE_RECORDS_ONLY = ['schema', 'model', 'scope', 'deferred', 'excerpt', 'route', 'path']
193
+ const SITE_RECORDS_ONLY = ['schema', 'model', 'scope', 'deferred', 'excerpt', 'path']
193
194
  /** The keys of an external query's `record:` request. */
194
195
  const RECORD_KEYS = ['url', 'method', 'body', 'transform']
195
196
 
@@ -202,9 +203,15 @@ const RECORD_KEYS = ['url', 'method', 'body', 'transform']
202
203
  * `transform` (a dot-path to its records), `where` / `sort` / `limit` evaluated over
203
204
  * them, and `record:` — `{ url, method, body, transform }` — for one record on a
204
205
  * parametric page. What describes the site's records — `schema`, `scope`,
205
- * `deferred`, `excerpt`, `route` — is refused beside `url:`. ⛔ `detailUrl:` is
206
+ * `deferred`, `excerpt` — is refused beside `url:`. ⛔ `detailUrl:` is
206
207
  * retired everywhere: its one real case is `record.url`.
207
208
  *
209
+ * ⛔ `route:` is retired everywhere too (2026-09-14 [Diego]): a record links to the page
210
+ * whose route query is its query, as `$route`, which the runtime fills at render time
211
+ * on every lane — and a fetch that wants another page names it with `detailPage:`. The
212
+ * build baked `route:` into each compiled record as `route`, a field in the author's
213
+ * namespace that it overwrote.
214
+ *
208
215
  * @param {Object} decl - one normalized declaration (`{ name, … }`)
209
216
  */
210
217
  export function refuseQueryDeclaration(decl) {
@@ -215,6 +222,7 @@ export function refuseQueryDeclaration(decl) {
215
222
  `on an external query — one with \`url:\`.`
216
223
  )
217
224
  }
225
+ refuseQueryRoute(decl, where)
218
226
  const external = decl.url !== undefined
219
227
  if (!external) {
220
228
  for (const key of ['method', 'body', 'transform', 'record']) {
@@ -34,7 +34,8 @@
34
34
  * merge — the loader flattens one level after collecting them.
35
35
  * - Converts markdown body to ProseMirror JSON
36
36
  * - Applies the query's fixed `where` and orders by its `sort`; its `limit` is
37
- * left to the runtime, so every record the query selects compiles
37
+ * left to the runtime, which makes each page's set, so every record the query's
38
+ * fixed `where` selects compiles
38
39
  * - Auto-generates excerpts and extracts first images (markdown items only)
39
40
  *
40
41
  * @module @uniweb/build/site/collection-processor
@@ -55,8 +56,8 @@ import { join, basename, extname, dirname, relative, resolve, sep } from 'node:p
55
56
  import { existsSync } from 'node:fs'
56
57
  import yaml from 'js-yaml'
57
58
  import { parseBibtex } from '@citestyle/bibtex'
58
- import { DATA_DIR, fillRoutePattern, withoutRouteVariables } from '@uniweb/core'
59
- import { applyWhere, applySort, refuseUnder, refuseOutsideLanguage } from './data-fetcher.js'
59
+ import { DATA_DIR, withoutRouteVariables } from '@uniweb/core'
60
+ import { applyWhere, applySort, refuseUnder, refuseOutsideLanguage, refuseQueryRoute } from './data-fetcher.js'
60
61
  import { resolveAssetPath, walkContentAssets, isLocalAssetPath } from './assets.js'
61
62
  import { readEntityPool, groupPoolBySchema, ENTITIES_DIR } from './entity-pool.js'
62
63
  import { readRecordsConfig, resolveFolder, FOLDER_MISSING } from './records-config.js'
@@ -93,8 +94,7 @@ try {
93
94
  *
94
95
  * // Extended form
95
96
  * parseQueryConfig('articles', {
96
- * path: 'collections/articles',
97
- * route: '/blog',
97
+ * schema: '@/article',
98
98
  * sort: 'date desc',
99
99
  * filter: 'published != false',
100
100
  * limit: 100
@@ -113,7 +113,6 @@ function parseQueryConfig(name, config) {
113
113
  name,
114
114
  schema: config,
115
115
  url: null,
116
- route: null,
117
116
  scope: null,
118
117
  sort: null,
119
118
  where: null,
@@ -126,13 +125,13 @@ function parseQueryConfig(name, config) {
126
125
 
127
126
  refuseUnder(config.where, `queries.${name}`)
128
127
  refuseOutsideLanguage(config.where, `queries.${name}`)
128
+ refuseQueryRoute(config, `queries.${name}`)
129
129
  return {
130
130
  name,
131
131
  // The query's schema selects its records from the pool — `entities/{schema}/`
132
132
  // declares the model, so the entities of a schema ARE the query's records.
133
133
  schema: config.schema || null,
134
134
  url: config.url || null,
135
- route: config.route || null,
136
135
  // The folder branch the query reads (`records.yml` placement). ⛔ Not read
137
136
  // here until 2026-09-11: a named query's `scope` was ignored on this lane.
138
137
  scope: typeof config.scope === 'string' ? config.scope : null,
@@ -617,7 +616,7 @@ function warnDuplicateSlugs(items, queryName) {
617
616
  * @param {Object} config - Parsed collection config
618
617
  * @returns {Promise<Array>} Array of processed items
619
618
  */
620
- async function collectItems(siteDir, config, entitiesDir, basePath) {
619
+ async function collectItems(siteDir, config, entitiesDir, basePath, locale = null) {
621
620
  // ⭐ THE QUERY NAMES A SCHEMA AND THE POOL FOLLOWS — the same resolution the
622
621
  // sync lane makes, from the same reader, so the two lanes cannot disagree
623
622
  // about which files are a query's records. They used to: this one recursed
@@ -686,32 +685,6 @@ async function collectItems(siteDir, config, entitiesDir, basePath) {
686
685
 
687
686
  warnDuplicateSlugs(items, config.name)
688
687
 
689
- // `route:` on the query — bake each record's canonical href.
690
- //
691
- // ⭐ THROUGH THE ONE ENCODER (`fillRoutePattern`, `@uniweb/core/route-match`),
692
- // which is what the runtime's `addDetailRoute` also calls. Until 2026-09-04 this
693
- // interpolated `${baseRoute}/${item.slug}` RAW while the runtime encoded, and a
694
- // record already carrying a baked route keeps it — so the same record got two
695
- // different hrefs depending on which lane served it (F14): a slug with a space
696
- // compared unequal to `location.pathname`, and a slug with a `/` became an
697
- // extra route segment. A record with no slug gets no route rather than
698
- // `/blog/undefined`.
699
- //
700
- // `route: /blog` names the base of a `[slug]` page, so the template is
701
- // `/blog/:slug`. `route: /blog/[...path]` names a `[...path]` page: the
702
- // template is `/blog/:path*` and the record's placement (`path`, the folder
703
- // `records.yml` put it in) becomes part of its href — `/blog/field/my-post`.
704
- if (config.route) {
705
- const base = config.route.replace(/\/$/, '')
706
- const template = base.endsWith('/[...path]')
707
- ? `${base.slice(0, -'/[...path]'.length)}/:path*`
708
- : `${base}/:slug`
709
- items = items.map((item) => {
710
- const route = fillRoutePattern(template, item)
711
- return route === null ? item : { ...item, route }
712
- })
713
- }
714
-
715
688
  // ⛔ ORDER MATCHES `data-fetcher.js::applyPostProcessing` — where, then sort. Two
716
689
  // lanes evaluate the same declaration (this one materializes a query to
717
690
  // `/data/<name>.json`; that one runs a page-level `fetch:`), so a difference in
@@ -737,16 +710,20 @@ async function collectItems(siteDir, config, entitiesDir, basePath) {
737
710
 
738
711
  // The query's `sort` orders the file; the runtime applies whichever sort wins —
739
712
  // a binding's own, else this one — so the order here only spares it the work.
713
+ // Texts collate in the site's default language; each page re-sorts in its own.
740
714
  if (config.sort) {
741
- items = applySort(items, config.sort)
715
+ items = applySort(items, config.sort, { locale })
742
716
  }
743
717
 
744
- // ⛔ `limit` IS NEVER BAKED (ruled 2026-09-13 [Diego]): it is how many a list
745
- // shows, and a binding may pick its own — more than the query's included — which
746
- // a file cut here could not give it. Every record the query selects compiles, and
747
- // gets its detail page; the runtime cuts each list (`@uniweb/core/fetch-config`,
748
- // `narrowQuery`). Until then this sliced the file, so a query's `limit: 2` made the
749
- // static build emit two detail pages while the records service resolved all five.
718
+ // ⛔ `limit` IS NEVER BAKED. A query's `limit` is part of its set (ruled 2026-09-14
719
+ // [Diego]), but the set is made per page: `scope` and the route-bound clauses the
720
+ // runtime applies first differ from page to page, and the `limit` cuts what they
721
+ // leave — so a file cut here would cut before them. Every record the query's fixed
722
+ // `where` selects compiles, in its `sort`; the runtime makes each page's set and then
723
+ // applies the fetch's `narrow` (`@uniweb/core`'s `evaluateQuery`), and a parametric
724
+ // page is expanded over the set (`prerender.js`). ⛔ Until 2026-09-13 this sliced the
725
+ // file — before a page's `scope` or routed clauses, which the runtime has applied
726
+ // since 2026-09-11, could select anything from it.
750
727
 
751
728
  return items
752
729
  }
@@ -757,6 +734,10 @@ async function collectItems(siteDir, config, entitiesDir, basePath) {
757
734
  * @param {string} siteDir - Site root directory
758
735
  * @param {Object} queriesConfig - the resolved QUERY declarations
759
736
  * @param {string} [entitiesDir] - pool directory override (`site.yml::paths.entities`)
737
+ * @param {string} [basePath='/']
738
+ * @param {Object} [options]
739
+ * @param {string|null} [options.locale] - the site's default language, which a query's `sort`
740
+ * collates texts in when it orders the compiled file
760
741
  * @returns {Promise<Object>} Map of collection name to items array
761
742
  *
762
743
  * @example
@@ -766,7 +747,7 @@ async function collectItems(siteDir, config, entitiesDir, basePath) {
766
747
  * })
767
748
  * // { articles: [...], products: [...] }
768
749
  */
769
- export async function processQueries(siteDir, queriesConfig, entitiesDir, basePath = '/') {
750
+ export async function processQueries(siteDir, queriesConfig, entitiesDir, basePath = '/', { locale = null } = {}) {
770
751
  if (!queriesConfig || typeof queriesConfig !== 'object') {
771
752
  return {}
772
753
  }
@@ -818,7 +799,7 @@ export async function processQueries(siteDir, queriesConfig, entitiesDir, basePa
818
799
  (managed ? 'Check records.yml lists them.' : 'Check entities/.')
819
800
  )
820
801
  }
821
- const items = await collectItems(siteDir, parsed, entitiesDir, basePath)
802
+ const items = await collectItems(siteDir, parsed, entitiesDir, basePath, locale)
822
803
  results[name] = items
823
804
  console.log(`[query-processor] Processed ${name}: ${items.length} items`)
824
805
  }
package/src/uwx/site.js CHANGED
@@ -803,13 +803,13 @@ const DECL_NOT_ON_WIRE = new Set([
803
803
  // convention supplied it. Decides hard-error vs soft-skip during sync;
804
804
  // `collections-config.js::toConfigQueries` strips it downstream too.
805
805
  'schemaExplicit',
806
- // ⭐ FRAMEWORK-LOCAL, and the one that proves the rule. `route:` is a real
807
- // authored field — `parseQueryConfig` reads it, and `collectItems` composes
808
- // each item's link as `<route>/<slug>` — but the backend's Model has no slot for
809
- // it, so emitting it would be sending build-time config to a store that validates
810
- // against a declared schema. Measured 2026-08-29: a first version of this change
811
- // passed unknown keys through blindly and would have started sending `route` from
812
- // every site that declares one.
806
+ // ⛔ RETIRED 2026-09-14, and kept here so a stale declaration never carries it.
807
+ // `route:` was framework-local — `collectItems` baked each item's link as
808
+ // `<route>/<slug>` — and the backend's Model has no slot for it. It is refused now
809
+ // (`data-fetcher.js::refuseQueryRoute`): a record links to its query's page as
810
+ // `$route`, filled at render time. Measured 2026-08-29: a first version of this list
811
+ // passed unknown keys through blindly and would have sent `route` from every site
812
+ // that declared one.
813
813
  'route',
814
814
  // Legacy predicate, translated to the canonical `where` upstream. No legacy
815
815
  // fields on the wire.
@@ -27,7 +27,7 @@ import { readFile } from 'node:fs/promises'
27
27
  import { existsSync } from 'node:fs'
28
28
  import { join, resolve, basename } from 'node:path'
29
29
  import yaml from 'js-yaml'
30
- import { queryNameFromUrl, siteReaches } from '@uniweb/core'
30
+ import { queryNameFromUrl, siteReaches, declaredKeys, fillDeclaredKeys } from '@uniweb/core'
31
31
 
32
32
  import { validateItem, isStaticallyCheckable, validateBound } from '@uniweb/schemas/conform'
33
33
  import { validateAndNormalizeSchema } from './resolve-data-schema.js'
@@ -103,54 +103,59 @@ export async function validateDataInputs({ siteRoot, foundationPath }) {
103
103
  const work = new Map() // pairKey -> { path, ref, schema, users: [{ route, section, key }] }
104
104
  const deferred = []
105
105
 
106
+ const byRoute = new Map((site.pages || []).map((p) => [p.route, p]))
106
107
  for (const page of site.pages || []) {
107
108
  walkSections(page.sections || [], (section) => {
108
109
  const type = section.type
109
110
  if (!type) return
110
111
  const bindings = foundation[type]?.data
111
- // the site's binding reaches a top-level page's sections only (`siteReaches`)
112
- const inputs = collectInputs(section, page.fetch, siteReaches(page.parent) ? config.fetch : null)
113
-
114
- // ⭐ **THE JOIN, RUN THE OTHER WAY: data arrived, but under no name this
115
- // section reads.** Everything below asks "for each input, is there a
116
- // binding?". This asks "for each binding, was anything delivered?" — and
117
- // the answer was silence until 2026-09-02.
112
+ // ⭐ THE RUNTIME'S JOIN (2026-09-14): a section receives the keys its component
113
+ // declares, and which fetch fills each is automatic `as` — `fillDeclaredKeys`, the
114
+ // same function the entity store delivers by — so a `post: '@std/article'` key
115
+ // filled by an `articles` fetch is checked against `@std/article`, and a fetch that
116
+ // fills no declared key is not checked at all, since no component receives it. The
117
+ // levels are the section's own fetch, its page's, its parent page's and — for a
118
+ // top-level page — the site's (`siteReaches`).
119
+ const declared = declaredKeys(bindings)
120
+ const levels = [section.fetch, page.fetch, byRoute.get(page.parent)?.fetch, siteReaches(page.parent) ? config.fetch : null]
121
+ const inputs = collectInputs(levels)
122
+ const held = nodesOfType(section.content, 'dataBlock').map((node) => node.attrs?.tag).filter(Boolean)
123
+ const fills = fillDeclaredKeys(declared, levels, { queries: config.queries, held })
124
+
125
+ // ⭐ **THE JOIN, RUN THE OTHER WAY: data arrived, and no key this section reads is
126
+ // filled by it.** Everything below asks "for each filled key, is its data right?".
127
+ // This asks "was anything this section reads filled?" — and the answer was silence
128
+ // until 2026-09-02.
118
129
  //
119
- // A section reads `content.data.<key>` for the keys its `meta.js` `data:`
120
- // declares. When the page delivers a query under a DIFFERENT name, the
121
- // section renders its heading and nothing else: no error, no warning, HTTP
122
- // 200, a clean console. Reported by `flows`, measured in a real browser —
123
- // two records-backed sections carrying 8 and 6 characters of text beside
124
- // static ones carrying 182/572/289/529/99.
130
+ // A section reads `content.data.<key>` for the keys its `meta.js` `data:` declares.
131
+ // When nothing on the page fills them — no fetch under the key, none of its schema —
132
+ // the section renders its heading and nothing else: no error, no warning, HTTP 200,
133
+ // a clean console. Reported by `flows`, measured in a real browser — two
134
+ // records-backed sections carrying 8 and 6 characters of text beside static ones
135
+ // carrying 182/572/289/529/99.
125
136
  //
126
- // ⚖️ **Narrow on purpose: only when SOMETHING was delivered.** `data:` in
127
- // `meta.js` is a hint rather than a delivery gate (`docs/reference/
128
- // data-fetching.md`), so a section declaring keys on a page with no data at
129
- // all is ordinary and silent. What is not ordinary is a page that fetched
130
- // something and a section on it that reads none of it — there the author
131
- // demonstrably intended data to arrive and the names did not meet.
132
- const declaredKeys = bindings ? Object.keys(bindings) : []
133
- if (declaredKeys.length > 0 && inputs.length > 0) {
137
+ // ⚖️ **Narrow on purpose: only when SOMETHING was delivered.** A section declaring
138
+ // keys on a page with no data at all is ordinary and silent. What is not ordinary is
139
+ // a page that fetched something and a section on it that receives none of it —
140
+ // there the author demonstrably intended data to arrive and the names did not meet.
141
+ const declaredNames = declared.map(([key]) => key)
142
+ if (declaredNames.length > 0 && inputs.length > 0 && fills.size === 0 && !declaredNames.some((k) => held.includes(k))) {
134
143
  const delivered = inputs.map((i) => i.as).filter(Boolean)
135
- if (delivered.length > 0 && !declaredKeys.some((k) => delivered.includes(k))) {
136
- setupErrors.push({
137
- file: `${page.route || '/'} · ${type}`,
138
- message:
139
- `section reads ${declaredKeys.map((k) => `content.data.${k}`).join(' or ')}, ` +
140
- `but this page delivers ${delivered.map((k) => `\`${k}\``).join(', ')}. ` +
141
- `The section will render with no data and nothing else will say so. ` +
142
- `Name the query for the key the section reads, or give the section its own ` +
143
- `\`query: <name>\`.`,
144
- // One user per declared key, so `uniweb validate` can print
145
- // `used by /team › Team › data.team` — the key is the thing to rename.
146
- users: declaredKeys.map((k) => ({ route: page.route, section: type, key: k })),
147
- })
148
- }
144
+ setupErrors.push({
145
+ file: `${page.route || '/'} · ${type}`,
146
+ message:
147
+ `section reads ${declaredNames.map((k) => `content.data.${k}`).join(' or ')}, ` +
148
+ `but this page delivers ${delivered.map((k) => `\`${k}\``).join(', ')}, and none of it fills them — ` +
149
+ `not by name, and not by schema. The section will render with no data and nothing else will say so. ` +
150
+ `Name the query for the key the section reads, give the fetch that key with \`as:\`, or give the ` +
151
+ `section its own \`query: <name>\`.`,
152
+ // One user per declared key, so `uniweb validate` can print
153
+ // `used by /team › Team › data.team` — the key is the thing to rename.
154
+ users: declaredNames.map((k) => ({ route: page.route, section: type, key: k })),
155
+ })
149
156
  }
150
157
 
151
- for (const input of inputs) {
152
- const key = input.as // the content.data KEY
153
-
158
+ for (const [key, { fetch: input }] of fills) {
154
159
  // An external query's records are its address's, fetched where the page renders.
155
160
  const external = typeof input.query === 'string' ? config.queries?.[input.query]?.url : undefined
156
161
  if (input.url || external) {
@@ -161,7 +166,7 @@ export async function validateDataInputs({ siteRoot, foundationPath }) {
161
166
 
162
167
  const binding = bindings?.[key]
163
168
  const ref = typeof binding === 'string' ? binding : binding?.schema
164
- if (!ref) continue // ungoverned input — no schema bound to this key
169
+ if (!ref) continue // ungoverned key — no schema declared for it
165
170
 
166
171
  const schema = dataSchemas[ref]
167
172
  if (!schema) continue // build guarantees refs resolve; defensive skip
@@ -475,17 +480,17 @@ async function loadStandardSchemas() {
475
480
  }
476
481
 
477
482
  /**
478
- * The data inputs available to a section, deduped by key. A section receives
479
- * its own fetch plus any inherited page-level and site-level fetch (default-on
480
- * cascade); when two levels share a key, the nearer one wins (section > page >
481
- * site) — the same precedence the runtime delivers.
483
+ * The data inputs reaching a section, deduped by key — the fetches of its levels, most
484
+ * specific first; when two levels share a key, the nearer one wins. What each input fills
485
+ * is `fillDeclaredKeys`'s to say; this lists what arrived, for the message when nothing
486
+ * the section reads is filled.
482
487
  */
483
- function collectInputs(section, pageFetch, siteFetch) {
488
+ function collectInputs(levels) {
484
489
  const byKey = new Map()
485
490
  // ⭐ Each level may declare SEVERAL — `query: [team, articles]` — so each is
486
491
  // flattened rather than read. Order is least- to most-specific and `set`
487
492
  // overwrites, which is what makes a section's declaration win the key.
488
- for (const source of [siteFetch, pageFetch, section.fetch]) {
493
+ for (const source of [...levels].reverse()) {
489
494
  for (const f of toFetchList(source)) {
490
495
  // ⛔ Gate on the BINDING KEY. A gate on the wrong name silently yields
491
496
  // NOTHING here — no inputs collected, no violations found, a green run —