@uniweb/core 0.37.2 → 0.38.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/core",
3
- "version": "0.37.2",
3
+ "version": "0.38.0",
4
4
  "description": "Core classes for the Uniweb platform - Uniweb, Website, Page, Block",
5
5
  "type": "module",
6
6
  "exports": {
package/src/data-keys.js CHANGED
@@ -24,49 +24,55 @@
24
24
 
25
25
  import { fetchEntries } from './fetch-config.js'
26
26
 
27
- /** The suffix that asks for whole records: `'@std/article/*'`. */
28
- export const WHOLE_SUFFIX = '/*'
29
-
30
27
  /**
31
- * A declared key's schema ref, and whether its component expects WHOLE records — as `data:`
32
- * spells it: a ref string (`'@std/article'`), an entry naming one (`{ schema: '@/member' }`),
33
- * or neither for an inline shape — a field map or a form — which names no schema.
34
- *
35
- * ⭐ `/*` after the ref asks for whole records (`'@std/article/*'`) — the record as stored, one
36
- * key per section — where a bare ref asks for briefs, the brief's fields at the top (ruled
37
- * 2026-09-27 [Diego]). The runtime asks each key's question accordingly; a key declared whole
38
- * that its fetch cannot fill whole is `null`.
39
- *
40
- * Who passes what. The runtime: a section type's LEAN `data` — a ref string or null, from the
41
- * runtime schema the build extracts (`@uniweb/build`'s `runtime-schema.js`) — and the foundation's
42
- * own `main.js` `data:` as written. A build-time reader: a `meta.js` value as written, or a
43
- * registered foundation schema's in its normalized form (`schemaFormat: 3`, `@uniweb/schemas`'s
44
- * `normalizeFoundationSchema`), which writes every ref as `{ kind: 'schema', schema, whole }` — the
45
- * ref qualified and `whole` said outright.
28
+ * A declared key's schema ref, and its two flags — as `data:` spells them: a ref string
29
+ * (`'@std/article'`), the long form (`{ schema: '@std/article', single: true, whole: true }`), or
30
+ * neither for an inline shape — a field map or a form — which names no schema.
31
+ *
32
+ * ⭐ TWO FLAGS, INDEPENDENT (ruled 2026-10-07 [Diego]):
33
+ *
34
+ * - **`single`** — how many: one record, or `null`, where a key holds a list by default. The
35
+ * runtime asks for the first record the binding selects (`./page-data.js`) and delivers it.
36
+ * - **`whole`** — how much of each: the record as stored, one key per section, where a key holds
37
+ * briefs by default, the brief's fields at the top (ruled 2026-09-27). The runtime asks each
38
+ * key's question accordingly; a key declared whole that its fetch cannot fill whole is `null`.
39
+ *
40
+ * ⛔ `'@std/article/*'` asked for whole records until 2026-10-07, and is retired: the build and
41
+ * `register` refuse it, so no artifact carries it, and it is not read here.
42
+ *
43
+ * Who passes what. The runtime: a section type's LEAN `data` — a ref string, `{ schema, single?,
44
+ * whole? }` or null, from the runtime schema the build extracts (`@uniweb/build`'s
45
+ * `runtime-schema.js`) — and the foundation's `main.js` `data:` leaned the same way. A build-time
46
+ * reader: a `meta.js` value as written, or a registered foundation schema's in its normalized form
47
+ * (`schemaFormat: 3`, `@uniweb/schemas`'s `normalizeFoundationSchema`), which writes every ref as
48
+ * `{ kind: 'schema', schema, single, whole }` — the ref qualified and both flags said outright.
46
49
  *
47
50
  * @param {*} value - one `data:` entry's value, authored, leaned or normalized
48
- * @returns {{ ref: string|null, whole: boolean }}
51
+ * @returns {{ ref: string|null, single: boolean, whole: boolean }}
49
52
  */
50
53
  export function dataRefOf(value) {
51
- const raw = typeof value === 'string'
52
- ? value
53
- : (value && typeof value === 'object' && typeof value.schema === 'string' ? value.schema : '')
54
- if (!raw) return { ref: null, whole: false }
55
- if (raw.endsWith(WHOLE_SUFFIX)) return { ref: raw.slice(0, -WHOLE_SUFFIX.length) || null, whole: true }
56
- return { ref: raw, whole: typeof value === 'object' && value.whole === true }
54
+ if (typeof value === 'string') return { ref: value || null, single: false, whole: false }
55
+ // The long form's `schema` is a ref; an inline field map may have a field called `schema`.
56
+ if (value && typeof value === 'object' && typeof value.schema === 'string' && value.schema.startsWith('@')) {
57
+ return { ref: value.schema, single: value.single === true, whole: value.whole === true }
58
+ }
59
+ return { ref: null, single: false, whole: false }
57
60
  }
58
61
 
59
62
  /**
60
- * The keys a section's component receives, in order, each with its schema ref and whether
61
- * it expects whole records (`dataRefOf`) — its component's `data:` first, then its
62
- * foundation's. A key both declare is the component's.
63
+ * The keys a section's component receives, in order, each with its schema ref and its two flags
64
+ * (`dataRefOf`) — its component's `data:` first, then its foundation's. A key both declare is the
65
+ * component's.
66
+ *
67
+ * `data: false`, a missing `data:` or anything that is not a map declares nothing. Each value
68
+ * may be as authored, as the build leans it, or as a registered schema normalizes it.
63
69
  *
64
- * `data: false`, a missing `data:` or anything that is not a map declares nothing. Each
65
- * value may be as authored or as the build leans it (`{ key: ref|null }`).
70
+ * ⚠️ `single` is the FOURTH member, appended 2026-10-07, so a caller destructuring the first three
71
+ * reads what it always did.
66
72
  *
67
73
  * @param {Object|false|null|undefined} componentData - the component's `data:`
68
74
  * @param {Object|false|null|undefined} [foundationData] - its foundation's `main.js` `data:`
69
- * @returns {Array<[string, string|null, boolean]>} `[key, ref, whole]`
75
+ * @returns {Array<[string, string|null, boolean, boolean]>} `[key, ref, whole, single]`
70
76
  */
71
77
  export function declaredKeys(componentData, foundationData = null) {
72
78
  const out = []
@@ -76,8 +82,8 @@ export function declaredKeys(componentData, foundationData = null) {
76
82
  for (const [key, value] of Object.entries(data)) {
77
83
  if (!key || seen.has(key)) continue
78
84
  seen.add(key)
79
- const { ref, whole } = dataRefOf(value)
80
- out.push([key, ref, whole])
85
+ const { ref, single, whole } = dataRefOf(value)
86
+ out.push([key, ref, whole, single])
81
87
  }
82
88
  }
83
89
  return out
@@ -137,7 +143,7 @@ function parseRef(ref) {
137
143
  * The mapping says where an answer lands; it renames no fetch. A fetch keeps its `as`
138
144
  * for its question, its cache key and its prefetch.
139
145
  *
140
- * @param {Array<[string, string|null, boolean]>} declared - `declaredKeys`
146
+ * @param {Array<[string, string|null, boolean, boolean]>} declared - `declaredKeys`
141
147
  * @param {Array<*>} levels - each level's `fetch`, most specific first; falsy levels skipped
142
148
  * @param {Object} [options]
143
149
  * @param {Object|null} [options.queries] - the site's `config.queries`, for each fetch's
package/src/index.js CHANGED
@@ -24,7 +24,7 @@ export { substitutePlaceholders } from './substitute-placeholders.js'
24
24
  // reads them and a consumer that needs them imports `@uniweb/core/records-service`.
25
25
  export { resolveFetchConfigs, routeQuery, pageRouteQuery, routeSelection, sectionFetches, siteReaches, withoutRouteVariables, CURRENT_MODES, currentFor } from './fetch-config.js'
26
26
  export { buildDetailConfig, ROUTE_HANDLE_KEY } from './detail-url.js'
27
- export { declaredKeys, sameSchema, fillDeclaredKeys, dataRefOf, WHOLE_SUFFIX } from './data-keys.js'
27
+ export { declaredKeys, sameSchema, fillDeclaredKeys, dataRefOf } from './data-keys.js'
28
28
  // ⭐ What a page needs — the one rule. `fetchLevels` is the part every lane has an answer for
29
29
  // (which fetches reach a block); the drivers and the per-key program are `@uniweb/core/page-data`.
30
30
  export { fetchLevels } from './page-data.js'
package/src/page-data.js CHANGED
@@ -80,13 +80,14 @@ export function fetchLevels({ own = null, page = null, parent = null, route = nu
80
80
  * Each filling fetch is resolved on its own, so two fetches of one `as` can fill two keys.
81
81
  *
82
82
  * @param {Object} input
83
- * @param {Array<[string, string|null, boolean]>} input.declared - `declaredKeys(meta)`
83
+ * @param {Array<[string, string|null, boolean, boolean]>} input.declared - `declaredKeys(meta)`
84
84
  * @param {Array<*>} input.levels - `fetchLevels`
85
85
  * @param {Object} [input.holds] - what the block already holds, by key
86
86
  * @param {Object|null} [input.queries] - the site's `config.queries`
87
87
  * @param {Object} [input.options] - what each fetch is resolved with (`resolveFetchConfigs`)
88
- * @returns {Map<string, { cfg: Object, held: *, whole: boolean }>} declared key → its filling config,
89
- * and whether its component expects whole records, in the order `declared` lists the keys
88
+ * @returns {Map<string, { cfg: Object, held: *, whole: boolean, single: boolean }>} declared key → its
89
+ * filling config, and its two flags — whether its component expects whole records, and one record —
90
+ * in the order `declared` lists the keys
90
91
  */
91
92
  export function blockFills({ declared, levels, holds = {}, queries = null, options = {} }) {
92
93
  const out = new Map()
@@ -102,12 +103,12 @@ export function blockFills({ declared, levels, holds = {}, queries = null, optio
102
103
  if (!firstOfKey.has(fetch.as)) firstOfKey.set(fetch.as, `${at}:${index}`)
103
104
  }))
104
105
 
105
- const wholeKeys = new Set(declared.filter(([, , whole]) => whole).map(([key]) => key))
106
+ const flags = new Map(declared.map(([key, , whole, single]) => [key, { whole: whole === true, single: single === true }]))
106
107
  for (const [key, { fetch, level, index }] of fills) {
107
108
  const cfg = resolveFetchConfigs([fetch], options).get(fetch.as)
108
109
  if (!cfg) continue
109
110
  const held = firstOfKey.get(fetch.as) === `${level}:${index}` ? holds[fetch.as] : undefined
110
- out.set(key, { cfg, held, whole: wholeKeys.has(key) })
111
+ out.set(key, { cfg, held, ...flags.get(key) })
111
112
  }
112
113
  return out
113
114
  }
@@ -159,13 +160,19 @@ function heldWhole(peekRecord, match) {
159
160
  * reading the cache never does, because the cache holds answers only.
160
161
  *
161
162
  * ⭐ **What is asked follows what the component expects** (`whole`, from its `data:` —
162
- * `'@std/article/*'`; ruled 2026-09-27 [Diego]). Briefs by default: the records service's question
163
- * as it is, a compiled file's list, and on a parametric page the record FOUND in its query's set.
164
- * Whole records when declared: the question carries `whole: true`, and a list off the service is
163
+ * `{ schema, whole: true }`; ruled 2026-09-27 [Diego]). Briefs by default: the records service's
164
+ * question as it is, a compiled file's list, and on a parametric page the record FOUND in its query's
165
+ * set. Whole records when declared: the question carries `whole: true`, and a list off the service is
165
166
  * followed by each record's own request. A key declared whole that cannot be filled whole — its
166
167
  * source has no request for one record — is `null`: the contract cannot be met. An external query's
167
168
  * records have no data schema and so no brief: what its source answers is each record whole.
168
169
  *
170
+ * ⭐ **And how many** (`single`, ruled 2026-10-07 [Diego]): a key that holds one record asks for one —
171
+ * the binding's question cut to its first record (`narrow.limit: 1`), so a whole one is fetched whole
172
+ * alone, never each record of the set. The answer is still a list, of one or none, which the runtime
173
+ * makes the record or `null` (`runtime/src/prepare-props.js`). A parametric page's record is found in
174
+ * the query's whole set as before: `current: only` asks for one already.
175
+ *
169
176
  * ⛔ **A failed request delivers NOTHING under its key, and says so.** The key is absent from the
170
177
  * returned value and the message rides on `error`, because `[]` is a value: a request that failed
171
178
  * must not be indistinguishable from a query with no records. ⛔ Until 2026-09-27 a parametric
@@ -188,9 +195,10 @@ function heldWhole(peekRecord, match) {
188
195
  * and a record found there answers with the brief it holds. No value at all means *unknown*,
189
196
  * which is not the same as `[]`, *not in the set*.
190
197
  * @param {boolean} [where.whole] - the component expects whole records
198
+ * @param {boolean} [where.single] - the component expects one record
191
199
  * @returns {Generator<Object|Object[], { value?: *, error?: string, errorConfig?: Object }>}
192
200
  */
193
- export function* keyProgram(cfg, { dynamicContext = null, route = null, held = undefined, peekRecord = null, current: forced = null, opportunistic = false, whole = false } = {}) {
201
+ export function* keyProgram(cfg, { dynamicContext = null, route = null, held = undefined, peekRecord = null, current: forced = null, opportunistic = false, whole = false, single = false } = {}) {
194
202
  /** A step this caller can do without: a miss answers `{ missing: true }` instead of stopping. */
195
203
  const maybe = (request) => (opportunistic ? { request, optional: true } : request)
196
204
  // The block holds this fetch's answer already — a static build prerendered it.
@@ -207,14 +215,19 @@ export function* keyProgram(cfg, { dynamicContext = null, route = null, held = u
207
215
  // How this fetch uses the page's record: by the query it names (`currentFor`).
208
216
  const current = forced ?? (dynamicContext ? currentFor(cfg, route) : null)
209
217
 
218
+ // What a list of the binding's records is asked as — its first only, for a key holding one — and
219
+ // the answer cut to it here too, since a source need not honour `narrow` (a foundation's transport).
220
+ const listed = single ? firstOnly(cfg) : cfg
221
+ const cut = (data) => (single && Array.isArray(data) ? data.slice(0, 1) : data)
222
+
210
223
  if (current === 'exclude') {
211
224
  // The query's records without this page's, its `limit` counting the others.
212
- const view = othersView(cfg)
225
+ const view = othersView(listed)
213
226
  const asked = whole && cfg.ask ? { ...view, whole: true } : view
214
227
  const answer = yield asked
215
228
  if (answer.error) return { error: answer.error, errorConfig: asked }
216
229
  if (answer.data === undefined || answer.data === null) return {}
217
- const others = othersOf(answer.data, cfg, isPageRecord(dynamicContext, cfg.routeField))
230
+ const others = othersOf(answer.data, listed, isPageRecord(dynamicContext, cfg.routeField))
218
231
  if (!whole || cfg.ask) return { value: others }
219
232
  return yield* wholeRecordsOf(cfg, others)
220
233
  }
@@ -273,12 +286,21 @@ export function* keyProgram(cfg, { dynamicContext = null, route = null, held = u
273
286
 
274
287
  // A fetch the page's record plays no part in, and `current: include`: the records as the fetch
275
288
  // describes them, the page's among the rest.
276
- const asked = whole && cfg.ask ? { ...cfg, whole: true } : cfg
289
+ const asked = whole && cfg.ask ? { ...listed, whole: true } : listed
277
290
  const answer = yield asked
278
291
  if (answer.error) return { error: answer.error, errorConfig: asked }
279
292
  if (answer.data === undefined || answer.data === null) return {}
280
- if (!whole || cfg.ask) return { value: answer.data }
281
- return yield* wholeRecordsOf(cfg, answer.data)
293
+ if (!whole || cfg.ask) return { value: cut(answer.data) }
294
+ return yield* wholeRecordsOf(cfg, cut(answer.data))
295
+ }
296
+
297
+ /**
298
+ * A `single` key's question: the binding's own, cut to its first record. The fetch's `narrow` is
299
+ * where a cut belongs — applied after the query's set on every lane — and a `limit` of its own is at
300
+ * least one, so the first record is the same either way.
301
+ */
302
+ function firstOnly(cfg) {
303
+ return { ...cfg, narrow: { ...(cfg.narrow || {}), limit: 1 } }
282
304
  }
283
305
 
284
306
  /**
@@ -340,14 +362,14 @@ function asSteps(value) {
340
362
  /**
341
363
  * Every filling key's program, ready to run.
342
364
  *
343
- * @param {Map<string, { cfg: Object, held: *, whole?: boolean }>} fills - `blockFills`
365
+ * @param {Map<string, { cfg: Object, held: *, whole?: boolean, single?: boolean }>} fills - `blockFills`
344
366
  * @param {Object} where - passed to each `keyProgram`
345
367
  * @returns {Map<string, Generator>}
346
368
  */
347
369
  export function planFor(fills, where = {}) {
348
370
  const plan = new Map()
349
- for (const [key, { cfg, held, whole = false }] of fills) {
350
- plan.set(key, keyProgram(cfg, { ...where, held, whole }))
371
+ for (const [key, { cfg, held, whole = false, single = false }] of fills) {
372
+ plan.set(key, keyProgram(cfg, { ...where, held, whole, single }))
351
373
  }
352
374
  return plan
353
375
  }
package/src/services.js CHANGED
@@ -16,10 +16,11 @@ import { applyBasePath } from './base-path.js'
16
16
  * deployment offers, which the site never had to know about. When a host
17
17
  * offers a service, that is the answer: it is the authority on what a
18
18
  * hosted site is given.
19
- * 2. **The site**, authored — `search:`, `submit:`, `assistant:`, `tracking:`
20
- * in site.yml. The operator's own declaration — used for anything the host
21
- * does not provide, and on a static site, where no host speaks, the whole
22
- * answer.
19
+ * 2. **The site**, authored — its entry under `services:` in site.yml, which a
20
+ * built site carries as `config.<name>`. The operator's own declaration —
21
+ * used for anything the host does not provide, and on a static site, where
22
+ * no host speaks, the whole answer. *(This named the top-level `search:`,
23
+ * `submit:`, `assistant:` and `tracking:` keys, retired 2026-10-06.)*
23
24
  *
24
25
  * ## ⛔ THE TWO TIERS ARE NOT TWO SOURCES OF ONE KIND OF THING
25
26
  *
package/src/website.js CHANGED
@@ -1158,7 +1158,7 @@ export default class Website {
1158
1158
  * The registry is open: any name resolves, whether or not the framework ships
1159
1159
  * a client for it.
1160
1160
  *
1161
- * @param {string} name - the service name — `search`, `submit`, `api`, …
1161
+ * @param {string} name - the service name — `search`, `submit`, `backend`, …
1162
1162
  * @returns {boolean}
1163
1163
  */
1164
1164
  isServiceEnabled(name) {