@uniweb/build 0.77.0 → 0.79.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.
@@ -28,6 +28,7 @@ import { siteProjectToDocument } from './site.js'
28
28
  import { siteSelfScope, refuseOrgOption } from './self-scope.js'
29
29
  import { stampUnitUuids, collectUnitUuids } from './site-diff.js'
30
30
  import { emitEntitySyncPackage } from './entity-document.js'
31
+ import { recordQueriesShown } from './shown-records.js'
31
32
  import { isLocalAssetPath } from '../site/assets.js'
32
33
 
33
34
  const SITE_MODEL_NAME = '@uniweb/site-content'
@@ -42,17 +43,28 @@ const cacheKey = (entity) => `${entity.model} ${entity.id}`
42
43
  // uuid→version map (the sync-cache); an entity the map doesn't know is left
43
44
  // unconditional rather than guessed at. See entity-document.js for what the
44
45
  // token means on the wire.
45
- const withBaseVersion = (baseVersions, itemBaseVersions) => (entity) => {
46
+ const withBaseVersion = (baseVersions, itemBaseVersions, { every = false } = {}) => (entity) => {
46
47
  const uuid = entity?.document?.$uuid
47
48
  const v = uuid ? baseVersions[uuid] : null
48
- // Per-item preconditions, narrowed to the records THIS package actually carries.
49
- // The cache spans the whole site, but a package holds only what changed; sending
50
- // tokens for absent records would bloat the manifest and assert preconditions on
51
- // items we are not touching.
49
+ // Per-item preconditions.
50
+ //
51
+ // ⭐ THE SITE-CONTENT ENTITY SENDS EVERY VERSION THIS COPY HOLDS (`every`), for items
52
+ // present AND for items the files dropped. A push deletes only the items it holds a
53
+ // version for, and only while the site left them unchanged — so a dropped item's
54
+ // version is what makes leaving it out a deletion, and without it the item is kept
55
+ // [Diego, 2026-10-07; backend's gate, kb/framework/plans/services-exchange.md]. The
56
+ // caller holds a version only for an item it has seen (`heldTokens`, cli), so none is
57
+ // ever sent for an item the backend holds and this copy does not.
58
+ // ⛔ Until then this narrowed to the units the document carried: a dropped page was
59
+ // deleted by today's rule anyway, and nothing else was sent a version.
60
+ //
61
+ // Any other entity: narrowed to the units it carries, as before — the records lane
62
+ // is gated by its entity version, and sends none.
52
63
  let items = null
53
64
  if (itemBaseVersions && Object.keys(itemBaseVersions).length) {
54
65
  items = {}
55
- for (const itemUuid of Object.values(collectUnitUuids(entity.document))) {
66
+ const uuids = every ? Object.keys(itemBaseVersions) : Object.values(collectUnitUuids(entity.document))
67
+ for (const itemUuid of uuids) {
56
68
  const t = itemBaseVersions[itemUuid]
57
69
  if (t) items[itemUuid] = t
58
70
  }
@@ -215,8 +227,6 @@ function rewriteEntityAssets(node, map, ids, noStamp = null) {
215
227
  * @param {string} [opts.sourceLocale] - localized-field wrap locale
216
228
  * @param {Object<string,string>} [opts.priorHashes] - sync-cache (send-only-changed)
217
229
  * @param {boolean} [opts.sendAll] - bypass the prior-hash filter
218
- * @param {object[]} [opts.serviceRows] - the `services` Section to send, as the
219
- * caller decided it (site.js `requestedServices`)
220
230
  * @param {Object<string,string>} [opts.itemUuids] - unit path → backend `$uuid`,
221
231
  * stamped onto the site-content document so the backend matches our items
222
232
  * instead of re-minting them (which deletes and recreates every page and
@@ -225,11 +235,12 @@ function rewriteEntityAssets(node, map, ids, noStamp = null) {
225
235
  * identity of its list items, as a previous push or pull banked it
226
236
  * (`record-items.js`); stamped onto each record the backend holds, so a re-sent
227
237
  * record updates its items instead of replacing them.
228
- * @param {Object<string,string>} [opts.itemBaseVersions] - record `$uuid` → opaque
229
- * per-ITEM `version`. Narrowed at emit to the records the package carries and
230
- * sent as `entries[].item_base_versions`, so the backend can refuse only the
231
- * records that genuinely moved instead of the whole document. Omit to push
232
- * those records unconditionally.
238
+ * @param {Object<string,string>} [opts.itemBaseVersions] - item `$uuid` → opaque
239
+ * per-ITEM `version`, for every site-content item this copy has seen. Sent
240
+ * whole on the site-content entity as `entries[].item_base_versions` — items
241
+ * present and items the files dropped — so the backend refuses only the items
242
+ * that genuinely moved, and deletes only what this copy saw (`withBaseVersion`).
243
+ * Omit to push unconditionally.
233
244
  * @param {Object<string,string>} [opts.baseVersions] - backend-uuid → opaque
234
245
  * `version` token, the push gate's optimistic-concurrency precondition.
235
246
  * Each sent entity whose `$uuid` the map knows carries it as a top-level
@@ -292,6 +303,7 @@ export async function emitSyncPackages(siteRoot, opts = {}) {
292
303
  const priorHashes = opts.priorHashes || {}
293
304
  const sendAll = !!opts.sendAll
294
305
  const stamp = withBaseVersion(opts.baseVersions || {}, opts.itemBaseVersions || {})
306
+ const stampSite = withBaseVersion(opts.baseVersions || {}, opts.itemBaseVersions || {}, { every: true })
295
307
  const exporter = opts.exporter
296
308
  const exportedAt = opts.exportedAt
297
309
  refuseOrgOption(opts, 'emitSyncPackages')
@@ -376,13 +388,7 @@ export async function emitSyncPackages(siteRoot, opts = {}) {
376
388
  // and the page and `settings` keys that carry Open Graph texts per language.
377
389
  ...(Array.isArray(opts.queryFields) ? { queryFields: opts.queryFields } : {}),
378
390
  ...(Array.isArray(opts.pageFields) ? { pageFields: opts.pageFields } : {}),
379
- ...(Array.isArray(opts.settingsFields) ? { settingsFields: opts.settingsFields } : {}),
380
- // The `services` Section as the caller decided it — the site's rows with the
381
- // owner's changed asks applied — or the Sections withheld. Passed through
382
- // rather than decided here: the site's rows and the last agreement are what
383
- // the CLI reads. See site.js at `requestedServices`.
384
- ...(Array.isArray(opts.serviceRows) ? { serviceRows: opts.serviceRows } : {}),
385
- ...(opts.declareServices === false ? { declareServices: false } : {})
391
+ ...(Array.isArray(opts.settingsFields) ? { settingsFields: opts.settingsFields } : {})
386
392
  })
387
393
  : null
388
394
  // Deploy-derived `info` fields are stamped here — NOT authored in site.yml, so they
@@ -584,7 +590,7 @@ export async function emitSyncPackages(siteRoot, opts = {}) {
584
590
  // --- site-content lane -------------------------------------------------------
585
591
  let siteContent = null
586
592
  if (siteEntity && changed(siteEntity)) {
587
- siteContent = { ...emitLane([stamp(siteEntity)], exporter, exportedAt), index: [{ kind: 'site' }] }
593
+ siteContent = { ...emitLane([stampSite(siteEntity)], exporter, exportedAt), index: [{ kind: 'site' }] }
588
594
  }
589
595
 
590
596
  // The site's current uuid (from site.yml): the verb keys the folder push route on
@@ -636,5 +642,8 @@ export async function emitSyncPackages(siteRoot, opts = {}) {
636
642
  // records this package SENDS that the backend holds with list items and no bank entry
637
643
  // (their uuids) — sent as they are, the backend refuses each one (`identity_required`).
638
644
  recordItemIdentity,
645
+ // The queries whose records the site's pages show live — what the `records` service
646
+ // delivers on a published site (`shown-records.js`). Whether it changed or not.
647
+ recordsShown: siteDoc ? recordQueriesShown(siteDoc) : [],
639
648
  }
640
649
  }
@@ -0,0 +1,288 @@
1
+ // Edits to a YAML file an author wrote — ONE engine, `editYamlText`, for everything
2
+ // in the framework that changes such a file.
3
+ //
4
+ // `site.yml`, `theme.yml`, `page.yml`, `queries.yml` are hand-authored — they carry
5
+ // comments, blank lines and an author-chosen layout. Round-tripping them through
6
+ // js-yaml (load → dump) discards every comment and re-flows the file. So nothing
7
+ // writes one whole: the engine rewrites only the entries that change.
8
+ //
9
+ // - `editYamlText(text, before, after)` — the engine: text in, text out.
10
+ // - `setYamlKey` / `replaceInYamlList` — the two edits a command decides on before
11
+ // it writes anything (`uniweb rename`), as text, so a file it cannot edit stops
12
+ // the command while nothing has moved.
13
+ // - Writing a file goes through `project-writer.js` (`writeSiteConfig`,
14
+ // `writeThemeFile`, …), which reads it, merges, and hands the engine both values.
15
+ //
16
+ // ⛔ *Until 2026-10-07 there were three: this module's line-level `upsertYamlScalar`
17
+ // and `removeYamlScalar` (one top-level scalar line), the CLI's `utils/yaml-edit.js`
18
+ // (`setTopLevelScalar`, `replaceInTopLevelList`), and the engine. The two line-level
19
+ // ones refused or misplaced what the engine edits — a block scalar, a list entry
20
+ // beside a comment, a new key PREPENDED above a file's header comments.*
21
+
22
+ import yaml from 'js-yaml'
23
+ import { parseDocument, isMap, isSeq, isScalar, Scalar, visit } from 'yaml'
24
+ import { YAML_OPTIONS } from '../utils/yaml-schema.js'
25
+
26
+ /** js-yaml's options for every value the framework writes into YAML, so output is byte-stable. */
27
+ export const YAML_DUMP_OPTS = { lineWidth: -1, quotingType: "'", forceQuotes: false, noRefs: true }
28
+
29
+ const isPlainObject = (v) => v !== null && typeof v === 'object' && !Array.isArray(v)
30
+
31
+ /** Deep equality of two values read from YAML: sorted-key JSON. */
32
+ const sameValue = (a, b) => {
33
+ const canonical = (v) =>
34
+ JSON.stringify(v, (_k, x) => (isPlainObject(x) ? Object.fromEntries(Object.keys(x).sort().map((k) => [k, x[k]])) : x))
35
+ return canonical(a) === canonical(b)
36
+ }
37
+
38
+ /** Offset of the start of the line holding `pos`. */
39
+ const lineStart = (text, pos) => text.lastIndexOf('\n', pos - 1) + 1
40
+
41
+ /** The column a block map's key starts at — null when anything but spaces precedes it (`- key:`). */
42
+ function columnOf(text, key) {
43
+ const start = lineStart(text, key.range[0])
44
+ const lead = text.slice(start, key.range[0])
45
+ return /^ *$/.test(lead) ? lead.length : null
46
+ }
47
+
48
+ /** Where a pair's text ends: after its value, its trailing comment and its newline. */
49
+ function pairEnd(text, pair) {
50
+ let end = (pair.value ?? pair.key).range[2]
51
+ if (text[end - 1] !== '\n') {
52
+ const nl = text.indexOf('\n', end)
53
+ const rest = nl === -1 ? text.slice(end) : text.slice(end, nl)
54
+ if (/^\s*(#.*)?$/.test(rest)) end = nl === -1 ? text.length : nl + 1
55
+ }
56
+ return end
57
+ }
58
+
59
+ /** Every line but the first — or every line, for text that starts a line — indented to `col`. */
60
+ function indented(text, col, first) {
61
+ if (!col) return text
62
+ const pad = ' '.repeat(col)
63
+ return text
64
+ .split('\n')
65
+ .map((line, i) => (line && (i > 0 || first) ? pad + line : line))
66
+ .join('\n')
67
+ }
68
+
69
+ /** An alias or a `<<` merge key: one edit could change what another place says. */
70
+ function usesReferences(doc) {
71
+ let found = false
72
+ visit(doc, {
73
+ Alias() {
74
+ found = true
75
+ return visit.BREAK
76
+ },
77
+ Pair(_, pair) {
78
+ if (isScalar(pair.key) && pair.key.value === '<<') {
79
+ found = true
80
+ return visit.BREAK
81
+ }
82
+ }
83
+ })
84
+ return found
85
+ }
86
+
87
+ /**
88
+ * A scalar written where `node` stood — in the node's quotes when it was quoted and the
89
+ * new value is text (`"…"` stays `"…"`), else as js-yaml writes it; inside a flow
90
+ * collection, in a form that cannot run into the next entry. Null when it does not fit
91
+ * on one line, or `node` spans lines.
92
+ */
93
+ function scalarText(text, node, value, dump, inFlow) {
94
+ if (!node.range || node.range[1] <= node.range[0]) return null
95
+ if (text.slice(node.range[0], node.range[1]).includes('\n')) return null
96
+ if (value !== null && typeof value === 'object') return null
97
+ let out
98
+ if (typeof value === 'string' && node.type === Scalar.QUOTE_DOUBLE) out = JSON.stringify(value)
99
+ else if (typeof value === 'string' && node.type === Scalar.QUOTE_SINGLE) out = `'${value.replace(/'/g, "''")}'`
100
+ else if (inFlow) {
101
+ const list = dump([value], { flowLevel: 0 }).trim()
102
+ if (!list.startsWith('[') || !list.endsWith(']')) return null
103
+ out = list.slice(1, -1)
104
+ } else out = dump(value).replace(/\n$/, '')
105
+ return out.includes('\n') ? null : out
106
+ }
107
+
108
+ /**
109
+ * Make one list say `after` — `before` is what it says now, the same length — by
110
+ * rewriting each changed entry where it stands. False for anything else (a list that
111
+ * grew or shrank, an entry that is not a one-line scalar): the caller writes it whole.
112
+ */
113
+ function editSeq(text, seq, before, after, edits, dump) {
114
+ if (seq.items.length !== before.length || before.length !== after.length) return false
115
+ const inner = []
116
+ for (let i = 0; i < after.length; i++) {
117
+ if (sameValue(before[i], after[i])) continue
118
+ const node = seq.items[i]
119
+ const now = isScalar(node) ? scalarText(text, node, after[i], dump, seq.flow) : null
120
+ if (now === null) return false
121
+ inner.push({ start: node.range[0], end: node.range[1], text: now })
122
+ }
123
+ edits.push(...inner)
124
+ return true
125
+ }
126
+
127
+ /**
128
+ * Make one block map say `after` — `before` is what it says now — by edits to its own
129
+ * entries: a removed key's lines go, a changed scalar is rewritten where it stands, a
130
+ * changed map or same-length list is edited the same way one level down, anything else
131
+ * is written whole in its place, and a new key goes after the last one. False when the
132
+ * map is not one this can edit (a key that is not a plain scalar, or not first on its line).
133
+ */
134
+ function editMap(text, map, before, after, edits, dump) {
135
+ if (!map.items.length || !map.items[0].key?.range) return false
136
+ const col = columnOf(text, map.items[0].key)
137
+ if (col === null) return false
138
+ const seen = new Set()
139
+ let end = 0
140
+ for (const pair of map.items) {
141
+ if (!isScalar(pair.key) || !pair.key.range || columnOf(text, pair.key) !== col) return false
142
+ const key = String(pair.key.value)
143
+ if (seen.has(key)) return false
144
+ seen.add(key)
145
+ const value = pair.value
146
+ const stop = pairEnd(text, pair)
147
+ end = Math.max(end, stop)
148
+ if (!Object.hasOwn(after, key) || after[key] === undefined) {
149
+ edits.push({ start: lineStart(text, pair.key.range[0]), end: stop, text: '' })
150
+ continue
151
+ }
152
+ if (Object.hasOwn(before, key) && sameValue(before[key], after[key])) continue
153
+ const next = after[key]
154
+ const inner = []
155
+ if (isMap(value) && !value.flow && isPlainObject(before[key]) && isPlainObject(next)) {
156
+ if (editMap(text, value, before[key], next, inner, dump)) {
157
+ edits.push(...inner)
158
+ continue
159
+ }
160
+ } else if (isSeq(value) && Array.isArray(before[key]) && Array.isArray(next)) {
161
+ if (editSeq(text, value, before[key], next, inner, dump)) {
162
+ edits.push(...inner)
163
+ continue
164
+ }
165
+ } else if (isScalar(value)) {
166
+ const now = scalarText(text, value, next, dump, false)
167
+ if (now !== null) {
168
+ edits.push({ start: value.range[0], end: value.range[1], text: now })
169
+ continue
170
+ }
171
+ }
172
+ // Written whole — in flow style when the author wrote it that way (`tags: [a, b]`).
173
+ const flow = (isMap(value) || isSeq(value)) && value.flow
174
+ edits.push({
175
+ start: pair.key.range[0],
176
+ end: stop,
177
+ text: indented(dump({ [key]: next }, flow ? { flowLevel: 1 } : {}), col, false)
178
+ })
179
+ }
180
+ for (const key of Object.keys(after)) {
181
+ if (seen.has(key) || after[key] === undefined) continue
182
+ const lead = end > 0 && text[end - 1] !== '\n' ? '\n' : ''
183
+ edits.push({ start: end, end, text: lead + indented(dump({ [key]: after[key] }), col, true) })
184
+ }
185
+ return true
186
+ }
187
+
188
+ /**
189
+ * Edit the YAML `text` so it says `after` — `before` is what it says now, as the build
190
+ * reads it — changing only the entries that differ, and leaving every other byte as its
191
+ * author wrote it: comments, blank lines, key order, quoting, a list written on one line.
192
+ *
193
+ * ⭐ `yaml` LOCATES, js-yaml WRITES AND READS. The comment-preserving library parses the
194
+ * text for where each entry sits; a changed entry is rendered by js-yaml, as the writers
195
+ * render a whole file — keeping the quotes it had, where it had them; and the result is
196
+ * read back by the build's own reader, and kept only if it says exactly `after`. ⛔ Not
197
+ * `yaml`'s Document API, edited and printed: it prints the WHOLE file again, and with no
198
+ * edit at all it changed 16 of the 17 template `site.yml` files — long lines folded,
199
+ * `[a, b]` padded to `[ a, b ]` — and with the best options still 4 of 17, comments moved
200
+ * onto a key's line or indented under the map before them (measured 2026-10-07). Every
201
+ * such line is one a pull did not change, and a conflict waiting in a merge.
202
+ *
203
+ * ⛔ *Until 2026-10-07 the writers dumped the whole file, and a pull that changed one key
204
+ * of `site.yml` left the Starter template's 93 lines as 10 — every comment gone — and a
205
+ * `pull --merge` then conflicted on the whole commented header (F1, F12).*
206
+ *
207
+ * @param {string} text - the file as it is
208
+ * @param {object} before - what it says, read by the build's reader
209
+ * @param {object} after - what it must say
210
+ * @param {object} [dumpOptions] - js-yaml's, for what is written
211
+ * @returns {string|null} the edited text; null when the file cannot be edited in place —
212
+ * not a map, an alias or merge key, a parse error, or a result that reads differently
213
+ */
214
+ export function editYamlText(text, before, after, dumpOptions = YAML_DUMP_OPTS) {
215
+ if (typeof text !== 'string' || !isPlainObject(before) || !isPlainObject(after)) return null
216
+ let doc
217
+ try {
218
+ doc = parseDocument(text)
219
+ } catch {
220
+ return null
221
+ }
222
+ if (doc.errors.length || usesReferences(doc)) return null
223
+ const dump = (value, extra = {}) => yaml.dump(value, { ...dumpOptions, ...extra })
224
+ const edits = []
225
+ if (doc.contents === null || doc.contents === undefined) {
226
+ // Comments and nothing else: what the file says goes after them.
227
+ const lead = text && !text.endsWith('\n') ? '\n' : ''
228
+ edits.push({ start: text.length, end: text.length, text: lead + dump(after) })
229
+ } else if (!isMap(doc.contents) || doc.contents.flow || !editMap(text, doc.contents, before, after, edits, dump)) {
230
+ return null
231
+ }
232
+ // Last first, so an offset is never moved by an edit before it. At one offset: a removal
233
+ // before an insertion, and the outer map's new keys before the inner map's, so the inner
234
+ // map's land first in the text.
235
+ const order = edits.map((e, i) => ({ ...e, i })).sort((a, b) => b.start - a.start || b.end - a.end || b.i - a.i)
236
+ let out = text
237
+ for (const e of order) out = out.slice(0, e.start) + e.text + out.slice(e.end)
238
+ let read
239
+ try {
240
+ read = yaml.load(out, YAML_OPTIONS) ?? {}
241
+ } catch {
242
+ return null
243
+ }
244
+ return sameValue(read, after) ? out : null
245
+ }
246
+
247
+ /** What `text` says, as the build reads it — null when it is not YAML, or not a map. */
248
+ function readMap(text) {
249
+ try {
250
+ const data = yaml.load(text, YAML_OPTIONS) ?? {}
251
+ return isPlainObject(data) ? data : null
252
+ } catch {
253
+ return null
254
+ }
255
+ }
256
+
257
+ /**
258
+ * Set top-level `key` to `value` in `text` — added after the last key when it is not
259
+ * there — editing nothing else.
260
+ *
261
+ * @param {string} text - the file's contents
262
+ * @param {string} key - a top-level key, e.g. `foundation`
263
+ * @param {*} value
264
+ * @returns {string|null} the new contents; null when the text cannot be edited in place
265
+ * (`editYamlText`) — a caller deciding before it writes refuses then
266
+ */
267
+ export function setYamlKey(text, key, value) {
268
+ const before = readMap(text)
269
+ return before ? editYamlText(text, before, { ...before, [key]: value }) : null
270
+ }
271
+
272
+ /**
273
+ * Replace entries of the top-level list `key` — each `from` entry becomes its `to` — where
274
+ * they stand, in the quotes they were written in. An entry that only CONTAINS `from` is
275
+ * left alone, and so is everything else in the file.
276
+ *
277
+ * @param {string} text - the file's contents
278
+ * @param {string} key - a top-level list key, e.g. `extensions`
279
+ * @param {Map<*, *>} replacements - old entry → new entry
280
+ * @returns {string|null} the new contents; null when `key` is not a list, or the text
281
+ * cannot be edited in place
282
+ */
283
+ export function replaceInYamlList(text, key, replacements) {
284
+ const before = readMap(text)
285
+ if (!before || !Array.isArray(before[key])) return null
286
+ const list = before[key].map((v) => (replacements.has(v) ? replacements.get(v) : v))
287
+ return editYamlText(text, before, { ...before, [key]: list })
288
+ }
@@ -31,7 +31,7 @@ import { YAML_OPTIONS } from './utils/yaml-schema.js'
31
31
  import { queryNameFromUrl, declaredKeys, fillDeclaredKeys, fetchLevels, pageRouteQuery } from '@uniweb/core'
32
32
  import { parentRouteOf } from '@uniweb/core/route-match'
33
33
 
34
- import { validateItem, validateRecordFile, validateBound, contentBodyField, rootListSection, referencesOf } from '@uniweb/schemas/conform'
34
+ import { validateItem, validateRecordFile, validateBound, validateKeyValue, contentBodyField, rootListSection, referencesOf } from '@uniweb/schemas/conform'
35
35
  import { isUuid } from './uwx/uuid.js'
36
36
  import { validateAndNormalizeSchema, buildDataSchemaMap } from './resolve-data-schema.js'
37
37
 
@@ -147,6 +147,10 @@ export async function validateDataInputs({ siteRoot, foundationPath }) {
147
147
  // `@uniweb/core/page-data`) — ⛔ it wrote its own list until 2026-09-19, which read only
148
148
  // the declared parent and had no route binding for a nested page.
149
149
  const declared = declaredKeys(bindings)
150
+ // Each key's schema ref as the runtime reads it — never the raw value, whose spelling varies
151
+ // (⛔ until 2026-10-07 a `'…/*'` value was looked up whole here, matched no schema, and its
152
+ // key was never checked).
153
+ const refOf = new Map(declared.map(([key, ref]) => [key, ref]))
150
154
  const levels = fetchLevels({ own: section.fetch, page: page.fetch, parent, route, site: config.fetch })
151
155
  const inputs = collectInputs(levels)
152
156
  const held = nodesOfType(section.content, 'dataBlock').map((node) => node.attrs?.tag).filter(Boolean)
@@ -194,8 +198,7 @@ export async function validateDataInputs({ siteRoot, foundationPath }) {
194
198
  }
195
199
  if (!input.path) continue
196
200
 
197
- const binding = bindings?.[key]
198
- const ref = typeof binding === 'string' ? binding : binding?.schema
201
+ const ref = refOf.get(key)
199
202
  if (!ref) continue // ungoverned key — no schema declared for it
200
203
 
201
204
  const schema = dataSchemas[ref]
@@ -480,9 +483,12 @@ function nodesOfType(doc, type) {
480
483
  * becomes one (it falls back to `codeBlock`), so a malformed block cannot reach
481
484
  * here and be misreported as a schema violation.
482
485
  *
483
- * Uses `validateBound` rather than `validateItem` because a block's value may be
484
- * a record OR a list — ```` ```yaml:nav ```` is a bare array. That dispatch is the
485
- * reason root-list conformance had to land first.
486
+ * ⭐ A block holds what its key DECLARES (ruled 2026-10-07 [Diego]): a list of records, or one
487
+ * with `single: true`; each its brief, or as stored with `whole: true`; a list schema's list either
488
+ * way — `validateKeyValue`, the one rule for a key's value. ⛔ Until 2026-10-07 the schema's root
489
+ * alone decided (`validateBound`), so a block under a key typed by a record schema had to hold ONE
490
+ * record while a query under the same key delivered a list — and a key declared `'…/*'` was looked
491
+ * up with its suffix, matched no schema, and was never checked.
486
492
  *
487
493
  * @param {Object} site - collected site content
488
494
  * @param {Object} foundation - the built foundation schema (type → { data })
@@ -501,16 +507,17 @@ export function validateTaggedDataBlocks(site, foundation, dataSchemas) {
501
507
  const bindings = type && foundation?.[type]?.data
502
508
  if (!bindings || typeof bindings !== 'object') return
503
509
 
510
+ const declared = new Map(declaredKeys(bindings).map(([key, ref, whole, single]) => [key, { ref, whole, single }]))
504
511
  for (const node of nodesOfType(section.content, 'dataBlock')) {
505
512
  const tag = node.attrs?.tag
506
513
  if (!tag) continue
507
514
 
508
- const binding = bindings[tag]
509
- if (binding === undefined) continue // this key is not governed — say nothing
515
+ const declaration = declared.get(tag)
516
+ if (declaration === undefined) continue // this key is not governed — say nothing
510
517
 
511
- // A binding is a named ref, or an inline schema. Only a ref resolves to a
518
+ // A key is typed by a named ref, or by an inline schema. Only a ref resolves to a
512
519
  // normalized schema here; an inline one is reported rather than guessed at.
513
- const ref = typeof binding === 'string' ? binding : binding?.schema
520
+ const { ref, whole, single } = declaration
514
521
  if (typeof ref !== 'string') {
515
522
  deferred.push({ route: page.route, section: type, key: tag, reason: 'inline schema on the binding' })
516
523
  continue
@@ -520,7 +527,7 @@ export function validateTaggedDataBlocks(site, foundation, dataSchemas) {
520
527
 
521
528
  schemas.add(ref)
522
529
  checked++
523
- for (const finding of validateBound(schema, node.attrs?.data)) {
530
+ for (const finding of validateKeyValue(schema, node.attrs?.data, { whole, single })) {
524
531
  violations.push({
525
532
  file: `${page.route || '/'} › ${type} › ${node.attrs?.language || 'yaml'}:${tag}`,
526
533
  schema: ref,
@@ -1,79 +0,0 @@
1
- // Surgical, comment-preserving upsert of a TOP-LEVEL scalar key in a YAML file.
2
- //
3
- // `site.yml` and `collections.yml` are hand-authored — they carry comments and an
4
- // author-chosen key order. Round-tripping them through js-yaml (load → dump) would
5
- // discard every comment and re-flow the file. The sync back-fill only ever needs to
6
- // write ONE machine-owned scalar (the entity `$uuid`) into such a file, so we edit
7
- // the single line in place and leave the rest byte-for-byte.
8
- //
9
- // Scope, stated: top-level (column-0) scalar keys only — which is all `$uuid` ever
10
- // is. Not a general YAML writer; do not reach for it to set nested or list values.
11
-
12
- import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'node:fs'
13
- import { dirname } from 'node:path'
14
-
15
- // A top-level `key:` line (column 0, no leading space), capturing any inline value.
16
- function topLevelKeyLine(key) {
17
- // Escape regex metachars in the key (`$uuid` contains `$`).
18
- const esc = key.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
19
- return new RegExp(`^${esc}:[^\\n]*$`, 'm')
20
- }
21
-
22
- /**
23
- * Set `key: value` at the top level of the YAML file at `filePath`, preserving all
24
- * other lines (comments included). If the key already exists at column 0, its line
25
- * is replaced; otherwise a new line is prepended (machine-owned identity reads
26
- * cleanest at the top). The file (and its directory) is created if absent.
27
- *
28
- * @param {string} filePath
29
- * @param {string} key - a top-level scalar key (e.g. `$uuid`)
30
- * @param {string} value - the scalar value, written verbatim (uuids need no quoting)
31
- * @returns {boolean} true if the file changed
32
- */
33
- export function upsertYamlScalar(filePath, key, value) {
34
- const line = `${key}: ${value}`
35
- let text = ''
36
- if (existsSync(filePath)) text = readFileSync(filePath, 'utf8')
37
-
38
- const re = topLevelKeyLine(key)
39
- let next
40
- if (re.test(text)) {
41
- next = text.replace(re, line)
42
- } else if (text.length === 0) {
43
- next = line + '\n'
44
- } else {
45
- // Prepend, keeping the rest intact (and a trailing newline if the file lacked one).
46
- const body = text.endsWith('\n') ? text : text + '\n'
47
- next = line + '\n' + body
48
- }
49
- if (next === text) return false
50
- mkdirSync(dirname(filePath), { recursive: true })
51
- writeFileSync(filePath, next)
52
- return true
53
- }
54
-
55
- /**
56
- * Remove a TOP-LEVEL scalar key from the YAML file at `filePath`, preserving every
57
- * other line (comments included) — the inverse of `upsertYamlScalar`, with the same
58
- * scope: one `key: value` line at column 0. A key followed by indented lines, or
59
- * opening a block scalar (`|` / `>`), is not a one-line scalar and is left alone
60
- * rather than half-removed.
61
- *
62
- * @param {string} filePath
63
- * @param {string} key - a top-level scalar key (e.g. `preview`)
64
- * @returns {boolean} true if the file changed
65
- */
66
- export function removeYamlScalar(filePath, key) {
67
- if (!existsSync(filePath)) return false
68
- const lines = readFileSync(filePath, 'utf8').split('\n')
69
- const esc = key.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
70
- const re = new RegExp(`^${esc}:(.*)$`)
71
- const at = lines.findIndex((l) => re.test(l))
72
- if (at === -1) return false
73
- const inline = lines[at].replace(re, '$1').trim()
74
- const continues = at + 1 < lines.length && /^[ \t]+\S/.test(lines[at + 1])
75
- if (continues || /^[|>]/.test(inline)) return false
76
- lines.splice(at, 1)
77
- writeFileSync(filePath, lines.join('\n'))
78
- return true
79
- }