@uniweb/build 0.16.18 → 0.16.20

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.16.18",
3
+ "version": "0.16.20",
4
4
  "description": "Build tooling for the Uniweb Component Web Platform",
5
5
  "type": "module",
6
6
  "exports": {
@@ -59,16 +59,16 @@
59
59
  "js-yaml": "^4.1.0",
60
60
  "sharp": "^0.35.3",
61
61
  "yaml": "^2.5.0",
62
- "@uniweb/content-writer": "0.3.3",
62
+ "@uniweb/schemas": "0.2.8",
63
+ "@uniweb/theming": "0.1.15",
63
64
  "@uniweb/projections": "0.2.5",
64
- "@uniweb/schemas": "0.2.6",
65
- "@uniweb/theming": "0.1.15"
65
+ "@uniweb/content-writer": "0.3.3"
66
66
  },
67
67
  "optionalDependencies": {
68
- "@uniweb/content-reader": "1.2.2",
69
- "@uniweb/runtime": "0.9.7",
70
- "@uniweb/schemas": "0.2.6",
71
- "@uniweb/semantic-parser": "1.2.1"
68
+ "@uniweb/runtime": "0.9.9",
69
+ "@uniweb/schemas": "0.2.8",
70
+ "@uniweb/semantic-parser": "1.2.1",
71
+ "@uniweb/content-reader": "1.2.2"
72
72
  },
73
73
  "peerDependencies": {
74
74
  "vite": "^5.0.0 || ^6.0.0 || ^7.0.0",
package/src/index.js CHANGED
@@ -19,6 +19,7 @@ export {
19
19
  validateItem,
20
20
  validateDataInputs,
21
21
  validateConceptBlocks,
22
+ validateTaggedDataBlocks,
22
23
  isStaticallyCheckable,
23
24
  } from './validate-data.js'
24
25
 
@@ -57,6 +57,7 @@ export {
57
57
  STRUCTURAL_KINDS,
58
58
  FORMAT_TYPES,
59
59
  SECTION_KINDS,
60
+ AUTHORING_TYPES,
60
61
  SCHEMA_EXTENSIONS,
61
62
  parseSchemaRef,
62
63
  validateAndNormalizeSchema,
@@ -0,0 +1,129 @@
1
+ /**
2
+ * Emit a workspace extension's built code into the site's own output.
3
+ *
4
+ * ── The gap this closes ──
5
+ *
6
+ * A site declares an extension by URL. The site-relative form —
7
+ * `extensions: ['/effects/entry.js']` — means "served from this site's own
8
+ * origin", and it is what the `extensions` template ships. But nothing ever put
9
+ * the file there.
10
+ *
11
+ * The result was a build that succeeds and a site that is wrong: prerender
12
+ * loads the extension from the workspace (via `resolveExtensionPath`) and
13
+ * renders its sections into the static HTML, then the browser fetches
14
+ * `/effects/entry.js`, gets a 404, `loadExtensions()` drops it, and hydration
15
+ * REPLACES the correct markup with `Component not found`. A visitor watches a
16
+ * working section break. Measured on the `extensions` template, 2026-08-05.
17
+ *
18
+ * ── Why here ──
19
+ *
20
+ * The build is the only party that knows both the declared URL and where the
21
+ * extension's `dist/` actually is, and the site's output is the only place the
22
+ * two can meet. This is the emission half of "site-hosted linked" — the shape
23
+ * the model doc lists as producible only by hand.
24
+ *
25
+ * ── What is emitted, and what is not ──
26
+ *
27
+ * The BROWSER delivery set. A foundation's `dist/` also carries things only
28
+ * other consumers want, and a static host should not serve them:
29
+ *
30
+ * entry.js, assets/** → emitted; the browser loads these
31
+ * entry-ssr.js → skipped; the single-file SSR twin, for an
32
+ * isolate that loads one module. Nothing on a
33
+ * static host reads it.
34
+ * meta/** → skipped; the editor schema. Authoring-time, and
35
+ * not something to publish to visitors.
36
+ * runtime-pin.json → skipped; build provenance, read by no browser.
37
+ * *.map → skipped; dev-only.
38
+ *
39
+ * Same browser/internal split the runtime's distribution channel draws, for the
40
+ * same reason: what a visitor fetches and what a renderer needs are different
41
+ * sets, and only one of them belongs on a public origin.
42
+ */
43
+
44
+ import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs'
45
+ import { join, relative, resolve } from 'node:path'
46
+
47
+ /** True for the `/effects/entry.js` form — the only one this site can serve. */
48
+ export function isSiteRelative(decl) {
49
+ const url = typeof decl === 'string' ? decl : decl?.url
50
+ return typeof url === 'string' && url.startsWith('/') && !url.startsWith('//')
51
+ }
52
+
53
+ /** Every file under `dir`, relative to it. */
54
+ function walk(dir, base = dir) {
55
+ if (!existsSync(dir)) return []
56
+ return readdirSync(dir).flatMap((entry) => {
57
+ const full = join(dir, entry)
58
+ return statSync(full).isDirectory() ? walk(full, base) : [relative(base, full)]
59
+ })
60
+ }
61
+
62
+ /** Is this file part of what a browser fetches? */
63
+ function isBrowserAsset(rel) {
64
+ if (rel.endsWith('.map')) return false
65
+ if (rel === 'runtime-pin.json') return false
66
+ if (rel.startsWith('meta/') || rel.startsWith(`meta\\`)) return false
67
+ if (/(^|[/\\])entry-ssr\.js$/.test(rel)) return false
68
+ return true
69
+ }
70
+
71
+ /**
72
+ * Locate the built `dist/` behind a site-relative extension URL.
73
+ *
74
+ * The same candidates `resolveExtensionPath` walks for prerender, kept in step
75
+ * deliberately: if prerender can load an extension from the workspace but the
76
+ * build cannot find it to emit, that is exactly the split that produced the
77
+ * bug — one lane resolving it and the other not.
78
+ *
79
+ * @returns {{ distDir: string, urlBase: string }|null}
80
+ */
81
+ export function resolveExtensionDist(url, siteDir) {
82
+ const parts = url.replace(/^\//, '').split('/')
83
+ if (parts.length < 2) return null
84
+ const pkgName = parts[0]
85
+ const projectRoot = resolve(siteDir, '..')
86
+
87
+ for (const candidate of [
88
+ join(projectRoot, pkgName, 'dist'),
89
+ join(projectRoot, 'extensions', pkgName, 'dist')
90
+ ]) {
91
+ if (existsSync(candidate)) return { distDir: candidate, urlBase: pkgName }
92
+ }
93
+ return null
94
+ }
95
+
96
+ /**
97
+ * Files to emit for a site's declared extensions.
98
+ *
99
+ * Returns `{ fileName, source }` pairs for Rollup's `emitFile`, plus the
100
+ * declarations that could not be resolved — the caller warns about those rather
101
+ * than failing, because an absolute-URL extension is legitimately not ours to
102
+ * emit and a missing workspace build is a warning the developer can act on.
103
+ *
104
+ * @param {Array} extensions - `site.yml::extensions`, as declared.
105
+ * @param {string} siteDir - the site package directory.
106
+ */
107
+ export function collectExtensionAssets(extensions, siteDir) {
108
+ const emit = []
109
+ const unresolved = []
110
+ if (!Array.isArray(extensions)) return { emit, unresolved }
111
+
112
+ for (const decl of extensions) {
113
+ if (!isSiteRelative(decl)) continue // absolute URL or a ref — someone else serves it
114
+ const url = typeof decl === 'string' ? decl : decl.url
115
+ const found = resolveExtensionDist(url, siteDir)
116
+ if (!found) {
117
+ unresolved.push(url)
118
+ continue
119
+ }
120
+ for (const rel of walk(found.distDir)) {
121
+ if (!isBrowserAsset(rel)) continue
122
+ emit.push({
123
+ fileName: `${found.urlBase}/${rel.split('\\').join('/')}`,
124
+ source: readFileSync(join(found.distDir, rel))
125
+ })
126
+ }
127
+ }
128
+ return { emit, unresolved }
129
+ }
@@ -52,6 +52,7 @@ import { processCollections, writeCollectionFiles } from './collection-processor
52
52
  import { executeFetch, mergeDataIntoContent } from './data-fetcher.js'
53
53
  import { shouldSplitContent } from './split-content.js'
54
54
  import { FONT_LINKS_MARKER } from './head-markers.js'
55
+ import { collectExtensionAssets } from './emit-extensions.js'
55
56
 
56
57
  // BCP 47 locale code pattern: en, zh-CN, zh-Hant, pt-BR, fr-CA, sr-Latn, etc.
57
58
  const LOCALE_RE = '[a-z]{2,3}(?:-[A-Za-z]{2,4})?'
@@ -1464,6 +1465,25 @@ export function siteContentPlugin(options = {}) {
1464
1465
  // markdown (retrieval). Free and on by default; a site opts out under
1465
1466
  // `agents:` in site.yml.
1466
1467
  emitProjections.call(this, finalContent)
1468
+
1469
+ // A site-relative extension (`/effects/entry.js`) is served from the
1470
+ // site's OWN origin, so the site's build is what has to put it there.
1471
+ // Without this the build succeeds, prerender renders the extension's
1472
+ // sections from the workspace, and the browser then 404s and replaces
1473
+ // them with `Component not found` on hydration.
1474
+ const { emit, unresolved } = collectExtensionAssets(
1475
+ finalContent.config?.extensions,
1476
+ resolve(sitePath)
1477
+ )
1478
+ for (const asset of emit) {
1479
+ this.emitFile({ type: 'asset', fileName: asset.fileName, source: asset.source })
1480
+ }
1481
+ for (const url of unresolved) {
1482
+ this.warn(
1483
+ `Extension '${url}' is site-relative but no built extension was found for it. ` +
1484
+ `The site will 404 on it at runtime — build the extension, or reference it by URL.`
1485
+ )
1486
+ }
1467
1487
  },
1468
1488
 
1469
1489
  closeBundle() {
@@ -150,6 +150,20 @@ function lowerSection(def, resolve, optResolve, path = '') {
150
150
  const out = {}
151
151
  if ((def.kind || 'single') === 'multi') out.multiple = true
152
152
  if (def.brief === true) out.brief = true
153
+ // Display prose IS a section key — the registry stores it and keys it for
154
+ // translation as `section.<name>.label` / `.description` (confirmed 2026-08-05).
155
+ // A LEAF is stored differently: its `label`/`description` are accepted by the
156
+ // registry's parser and then dropped FROM THE FIELD DECLARATION, because a
157
+ // field declaration has no slot for prose — field labels live in translation
158
+ // rows keyed `section.<name>.field.<key>.label`. Whether the parser relocates
159
+ // our inline values into those rows (as it relocates `enum` into a `one_of`
160
+ // constraint) or discards them is not stated, and it is the difference between
161
+ // authored field prose reaching the app and not. We keep emitting it either
162
+ // way: it is accepted, so there is no failure mode, and relocation needs no
163
+ // producer change. Do not restate this as "leaf prose is lost" — that reading
164
+ // was asserted here once on the strength of the word "dropped" alone.
165
+ if (def.label) out.label = def.label
166
+ if (def.description) out.description = def.description
153
167
  if (def.nestable) out.self_nesting = true
154
168
  if (def.append_only) out.append_only = true
155
169
 
@@ -243,6 +257,7 @@ function lowerField(rawField, resolve, optResolve, path = '') {
243
257
  ...lowerSection(
244
258
  {
245
259
  kind: 'multi',
260
+ ...sectionAttrsFromField(field),
246
261
  // `translatable: false` is load-bearing, not tidiness: a string field is
247
262
  // localized by default, and a localized key could differ per locale —
248
263
  // which would destroy the identity the key exists to carry. The key is an
@@ -251,7 +266,13 @@ function lowerField(rawField, resolve, optResolve, path = '') {
251
266
  [OPEN_MAP_KEY]: { type: 'string', required: true, translatable: false },
252
267
  ...value.fields
253
268
  },
254
- constraints: [{ kind: 'unique_field', field: OPEN_MAP_KEY, scope: 'section' }]
269
+ // The uniqueness rule is STRUCTURAL it is what makes the map's key the
270
+ // row's identity — so it is prepended rather than assigned: an author's
271
+ // own constraints on this field add to it and can never replace it.
272
+ constraints: [
273
+ { kind: 'unique_field', field: OPEN_MAP_KEY, scope: 'section' },
274
+ ...(sectionAttrsFromField(field).constraints || [])
275
+ ]
255
276
  },
256
277
  resolve,
257
278
  optResolve,
@@ -261,7 +282,7 @@ function lowerField(rawField, resolve, optResolve, path = '') {
261
282
  }
262
283
  return {
263
284
  type: 'section',
264
- ...lowerSection({ kind: 'single', fields: field.fields }, resolve, optResolve, path)
285
+ ...lowerSection({ kind: 'single', ...sectionAttrsFromField(field), fields: field.fields }, resolve, optResolve, path)
265
286
  }
266
287
  }
267
288
  if (type === 'array') {
@@ -269,7 +290,7 @@ function lowerField(rawField, resolve, optResolve, path = '') {
269
290
  if (items && items.type === 'object') {
270
291
  return {
271
292
  type: 'section',
272
- ...lowerSection({ kind: 'multi', fields: items.fields }, resolve, optResolve, path)
293
+ ...lowerSection({ kind: 'multi', ...sectionAttrsFromField(field), fields: items.fields }, resolve, optResolve, path)
273
294
  }
274
295
  }
275
296
  // A multi-valued LEAF or REFERENCE. `normalizeField` split this field in two
@@ -279,9 +300,22 @@ function lowerField(rawField, resolve, optResolve, path = '') {
279
300
  // to `items` — so rejoin the halves and lower them as one leaf carrying
280
301
  // `multiple: true`. Reading only `items.type` here is what silently dropped
281
302
  // both halves' attributes; the rejoin is the exact inverse of the split.
303
+ //
304
+ // An UNTYPED array (`items` omitted — the authoring format allows it) becomes
305
+ // `json`, not `string`. It used to invent `string`, which is a claim about the
306
+ // elements that the author never made and that is wrong the moment the list
307
+ // holds anything else: `@std/form`'s `enum` is documented as "bare strings
308
+ // AND/OR { value, label }", and it was reaching the registry declared as a
309
+ // list of strings. `json` is this vocabulary's word for an opaque structured
310
+ // value, which is exactly what an undeclared element type is. Declaring
311
+ // `items:` remains the way to say something stronger.
312
+ //
313
+ // Note the local checker is unaffected and still stricter: `validateItem`
314
+ // works on the IR, where the field is `type: array`, so it verifies the value
315
+ // IS a list. Only what the registry is told changes.
282
316
  const { items: _items, ...collection } = field
283
317
  return lowerLeaf(
284
- { ...collection, ...items, type: items ? items.type : 'string' },
318
+ { ...collection, ...items, type: items ? items.type : 'json' },
285
319
  resolve,
286
320
  optResolve,
287
321
  { multiple: true }
@@ -368,6 +402,35 @@ function asField(def) {
368
402
  return typeof def === 'string' ? { type: def } : (def && typeof def === 'object' ? def : {})
369
403
  }
370
404
 
405
+ // A nested section is authored as a FIELD (`{ type: object, description: … }`),
406
+ // but arrives on the wire as a section — so the attributes that belong to a
407
+ // SECTION have to travel from the field declaration onto the section body, where
408
+ // the registry has a slot for them. Without this an authored `description:` on a
409
+ // nested object was dropped twice over (once by the normalizer, then again here),
410
+ // and `constraints:` never arrived at all.
411
+ //
412
+ // `constraints` is the load-bearing one. A list of records is normally authored
413
+ // as a field — `authors: { type: object, many: true }` — so until this existed,
414
+ // section rules were declarable only in the `sections:` form and unreachable in
415
+ // the shape that usually needs them. `min_items` is the motivating rule; note it
416
+ // is a WRITE guarantee ("a delete may not take the section below N"), never a
417
+ // render guarantee — a component still handles an empty list, because the same
418
+ // Model is renderable by a foundation that never saw the constraint.
419
+ function sectionAttrsFromField(field) {
420
+ const out = {}
421
+ if (field.label) out.label = field.label
422
+ if (field.description) out.description = field.description
423
+ if (Array.isArray(field.constraints) && field.constraints.length) out.constraints = field.constraints
424
+ // `tree` (→ `self_nesting`) and `append_only` describe how a list of records
425
+ // behaves, so they belong to the section too. Same silent-drop as `constraints`
426
+ // had: a list authored as a FIELD could not be a tree or append-only, while the
427
+ // identical thing in `sections:` form could. `self_nesting` is valid on a nested
428
+ // section, not only a top-level one (backend, 2026-08-05).
429
+ if (field.nestable) out.nestable = true
430
+ if (field.append_only) out.append_only = true
431
+ return out
432
+ }
433
+
371
434
  function shortName(name) {
372
435
  return String(name).split('/').pop()
373
436
  }
@@ -48,6 +48,9 @@ try {
48
48
  * @param {string} [params.scope] - org scope (`@acme` or `acme`) resolving `@/x` -> `@acme/x`.
49
49
  * @param {Object} [params.exporter] - `{ tool, version, instance }` for the envelope.
50
50
  * @param {string} [params.exportedAt] - ISO timestamp (default: now).
51
+ * @param {string} [params.runtime] - the `@uniweb/runtime` version this build
52
+ * linked against (its compatibility floor), from `dist/runtime-pin.json`.
53
+ * Rides in `info.runtime`; omitted when unknown.
51
54
  * @param {string} [params.digest] - the foundation's content digest (`sha256:…`),
52
55
  * computed by the CLI over what register ships (shipping-model.md §4.1). Rides
53
56
  * in the foundation-schema entity's `info.digest`; the backend stores it
@@ -55,7 +58,7 @@ try {
55
58
  * can detect "code changed since release" with no local state.
56
59
  * @returns {Object} the `.uwx` document (uwx/1; entities, names only, no uuids).
57
60
  */
58
- export function buildRegistryPackage({ schema, foundationDir, scope, exporter, exportedAt, digest } = {}) {
61
+ export function buildRegistryPackage({ schema, foundationDir, scope, exporter, exportedAt, digest, runtime } = {}) {
59
62
  const self = schema?._self
60
63
  if (!self || !self.name || !self.version) {
61
64
  throw new Error('buildRegistryPackage: schema._self with name + version is required')
@@ -70,7 +73,7 @@ export function buildRegistryPackage({ schema, foundationDir, scope, exporter, e
70
73
 
71
74
  const foundationEntity = {
72
75
  model: FOUNDATION_SCHEMA,
73
- info: buildInfo(self, org, digest),
76
+ info: buildInfo(self, org, digest, runtime),
74
77
  schema: buildSchemaBlob(schema),
75
78
  i18n: { locales: loadI18nLocales(foundationDir) },
76
79
  'data-schemas': { refs: buildRefs(dataSchemas, scoped) },
@@ -144,12 +147,18 @@ function wrapEntities(entities, exporter, exportedAt) {
144
147
  // Identity card — decomposed so it's readable without opening the blob. The
145
148
  // optional `digest` (sha256:…) is the foundation's content fingerprint; the
146
149
  // backend stores it opaque and returns it on the foundation-latest read.
147
- function buildInfo(self, org, digest) {
150
+ function buildInfo(self, org, digest, runtime) {
148
151
  // Scope a bare foundation name (`src` -> `@acme/src`); leave an already-scoped name.
149
152
  const name = org && !String(self.name).startsWith('@') ? `@${org}/${self.name}` : self.name
150
153
  const info = { name, version: self.version, role: self.role || 'foundation' }
151
154
  if (self.description !== undefined) info.description = self.description
152
155
  if (digest) info.digest = digest
156
+ // The compatibility FLOOR this build links against (dist/runtime-pin.json).
157
+ // Omitted when unknown — and a consumer must read the omission as UNKNOWN
158
+ // rather than unconstrained, since a floor nobody stated cannot be shown to
159
+ // be satisfied. Same lift as `digest`: stated by the producer, opaque to the
160
+ // backend, acted on by whoever resolves a whole site.
161
+ if (runtime) info.runtime = runtime
153
162
  return info
154
163
  }
155
164
 
@@ -29,7 +29,7 @@ import { join, resolve, basename } from 'node:path'
29
29
  import yaml from 'js-yaml'
30
30
  import { collectionNameFromUrl } from '@uniweb/core'
31
31
 
32
- import { validateItem, isStaticallyCheckable } from '@uniweb/schemas/conform'
32
+ import { validateItem, isStaticallyCheckable, validateBound } from '@uniweb/schemas/conform'
33
33
  import { validateAndNormalizeSchema } from './resolve-data-schema.js'
34
34
 
35
35
  // The pure checker, re-exported so `@uniweb/build/validate` stays the one import
@@ -173,6 +173,13 @@ export async function validateDataInputs({ siteRoot, foundationPath }) {
173
173
  for (const ref of concepts.schemas) schemasSeen.add(ref)
174
174
  recordCount += concepts.checked
175
175
 
176
+ // Pass 4 — tagged data blocks, which join by the component's OWN binding.
177
+ const blocks = validateTaggedDataBlocks(site, foundation, dataSchemas)
178
+ violations.push(...blocks.violations)
179
+ deferred.push(...blocks.deferred)
180
+ for (const ref of blocks.schemas) schemasSeen.add(ref)
181
+ recordCount += blocks.checked
182
+
176
183
  return {
177
184
  violations,
178
185
  deferred,
@@ -307,11 +314,15 @@ export async function validateConceptBlocks(site) {
307
314
 
308
315
  /** Every concept block in a doc, including any nested inside a container. */
309
316
  function conceptBlockNodes(doc) {
317
+ return nodesOfType(doc, 'concept_block')
318
+ }
319
+
320
+ function nodesOfType(doc, type) {
310
321
  const out = []
311
322
  const walk = (nodes) => {
312
323
  for (const node of nodes || []) {
313
324
  if (!node) continue
314
- if (node.type === 'concept_block') out.push(node)
325
+ if (node.type === type) out.push(node)
315
326
  else if (Array.isArray(node.content)) walk(node.content)
316
327
  }
317
328
  }
@@ -319,6 +330,84 @@ function conceptBlockNodes(doc) {
319
330
  return out
320
331
  }
321
332
 
333
+ /**
334
+ * Check each ```` ```yaml:<tag> ```` / ```` ```json:<tag> ```` data block against
335
+ * the schema the section's own component BOUND to that key.
336
+ *
337
+ * This is the pass that closes an odd hole: a component declares
338
+ * `data: { form: '@std/form' }`, an author writes a ```` ```yaml:form ```` block,
339
+ * and until now **nothing checked one against the other**. The join walked
340
+ * `section.fetch` — collections and fetches — so a schema bound to a key that a
341
+ * tagged block fills was never applied to anything. `@std/form` existed for
342
+ * exactly this and had never run outside its own contract test.
343
+ *
344
+ * Unlike concept blocks (pass 3), the join here is NOT by convention. A concept
345
+ * block resolves `md:faq` → `@std/faq` mechanically, which is why that pass must
346
+ * stay silent when no such schema exists. This one uses the binding the component
347
+ * actually declared, so there is no naming rule and no registry — a tag nobody
348
+ * bound is simply not governed, and says nothing.
349
+ *
350
+ * The value needs no parsing: a tagged fence lands as a `dataBlock` node with its
351
+ * parsed value already on `attrs.data`, and a body that FAILED to parse never
352
+ * becomes one (it falls back to `codeBlock`), so a malformed block cannot reach
353
+ * here and be misreported as a schema violation.
354
+ *
355
+ * Uses `validateBound` rather than `validateItem` because a block's value may be
356
+ * a record OR a list — ```` ```yaml:nav ```` is a bare array. That dispatch is the
357
+ * reason root-list conformance had to land first.
358
+ *
359
+ * @param {Object} site - collected site content
360
+ * @param {Object} foundation - the built foundation schema (type → { data })
361
+ * @param {Object} dataSchemas - normalized schemas keyed by ref
362
+ * @returns {{ violations: Array, schemas: Set<string>, checked: number, deferred: Array }}
363
+ */
364
+ export function validateTaggedDataBlocks(site, foundation, dataSchemas) {
365
+ const violations = []
366
+ const schemas = new Set()
367
+ const deferred = []
368
+ let checked = 0
369
+
370
+ for (const page of site?.pages || []) {
371
+ walkSections(page.sections || [], (section) => {
372
+ const type = section.type
373
+ const bindings = type && foundation?.[type]?.data
374
+ if (!bindings || typeof bindings !== 'object') return
375
+
376
+ for (const node of nodesOfType(section.content, 'dataBlock')) {
377
+ const tag = node.attrs?.tag
378
+ if (!tag) continue
379
+
380
+ const binding = bindings[tag]
381
+ if (binding === undefined) continue // this key is not governed — say nothing
382
+
383
+ // A binding is a named ref, or an inline schema. Only a ref resolves to a
384
+ // normalized schema here; an inline one is reported rather than guessed at.
385
+ const ref = typeof binding === 'string' ? binding : binding?.schema
386
+ if (typeof ref !== 'string') {
387
+ deferred.push({ route: page.route, section: type, key: tag, reason: 'inline schema on the binding' })
388
+ continue
389
+ }
390
+ const schema = dataSchemas?.[ref]
391
+ if (!schema) continue // unresolved ref — the build reports that on its own
392
+
393
+ schemas.add(ref)
394
+ checked++
395
+ for (const finding of validateBound(schema, node.attrs?.data)) {
396
+ violations.push({
397
+ file: `${page.route || '/'} › ${type} › ${node.attrs?.language || 'yaml'}:${tag}`,
398
+ schema: ref,
399
+ item: `data.${tag}`,
400
+ users: [{ route: page.route, section: type, key: tag }],
401
+ ...finding,
402
+ })
403
+ }
404
+ }
405
+ })
406
+ }
407
+
408
+ return { violations, schemas, checked, deferred }
409
+ }
410
+
322
411
  /** `parseContent`, or null when the parser is not installed. */
323
412
  async function loadSemanticParser() {
324
413
  try {
@@ -45,9 +45,24 @@ let _buildingSSRBundle = false
45
45
 
46
46
  /**
47
47
  * Emit dist/runtime-pin.json declaring the @uniweb/runtime version this
48
- * foundation was built against. Read by the edge isolate (under the
49
- * Strategy S split-bundle path) to decide which runtime/{ver}/ssr.js to
50
- * side-load from R2.
48
+ * foundation was built against.
49
+ *
50
+ * **NOTHING ENFORCES IT.** `uniweb register` reads the pin and carries it
51
+ * with the foundation (as `info.runtime`), so the floor reaches a party that
52
+ * could act on it — but no lane checks it yet. This header previously said a
53
+ * server-side isolate read the pin to pick which runtime to side-load, and that
54
+ * a "semver resolver" applied the policy. Neither existed, and for a while the
55
+ * pin was written and read by nothing at all. The wrong version had reached
56
+ * four documentation surfaces before anyone checked it against the consumers,
57
+ * which is the cost of describing a design as though it had shipped.
58
+ *
59
+ * The pin is a **compatibility floor**, not a selector, and it cannot be a
60
+ * selector: a site loads a primary foundation plus N extensions, each emitting
61
+ * its own pin, while a site has exactly one runtime — pins are plural, the
62
+ * choice is singular. The selector is `site.yml::runtime`. The pin's use is
63
+ * VALIDATION — is a site's runtime at or above max() of every loaded
64
+ * foundation's floor? — which belongs wherever all of those foundations are
65
+ * held, not here: this build sees one foundation.
51
66
  *
52
67
  * Reads the resolved version from the foundation's node_modules/@uniweb/
53
68
  * runtime/package.json so the pin reflects what was actually linked at
@@ -56,13 +71,13 @@ let _buildingSSRBundle = false
56
71
  *
57
72
  * Silently no-ops when @uniweb/runtime isn't resolvable (e.g., the
58
73
  * foundation depends on the runtime via a workspace alias that puts it
59
- * elsewhere). The edge resolver treats the absence of a pin as the
60
- * legacy single-bundle path, so omitting the pin is harmless during the
61
- * dual-mode window.
74
+ * elsewhere). Nothing breaks without it but note the absence means the
75
+ * foundation states NO floor, and "unknown" is not "unconstrained": a floor
76
+ * nobody declared cannot be shown to be satisfied.
62
77
  *
63
- * Optional foundation-author override: a `uniweb.runtimePolicy` field
64
- * in the foundation's own package.json gets recorded alongside the
65
- * runtime version so the registry's semver resolver can apply it.
78
+ * Optional foundation-author override: a `uniweb.runtimePolicy` field in the
79
+ * foundation's own package.json is recorded alongside the runtime version,
80
+ * declaring the author's intent for the validation above.
66
81
  *
67
82
  * @param {string} outDir - dist/ directory to write to.
68
83
  * @param {string} projectRoot - foundation project root (where package.json lives).
@@ -555,11 +570,9 @@ export function foundationBuildPlugin(options = {}) {
555
570
  // site build's theme.css — this is harmless redundancy there.
556
571
  await emitFoundationVarsCss(outDir, schema)
557
572
 
558
- // Emit runtime-pin.json so the edge isolate (under Strategy S) can
559
- // side-load the matching runtime/{ver}/ssr.js. Lands silently before
560
- // the dual-mode resolver ships; foundations published in the dual-mode
561
- // window already have the pin and start using the split-bundle path
562
- // automatically once the edge is updated.
573
+ // Record which @uniweb/runtime this build linked against. A compatibility
574
+ // floor for a validation step that does not exist yet, and read by nothing
575
+ // today see emitRuntimePin's header before assuming otherwise.
563
576
  await emitRuntimePin(outDir, resolvedRoot)
564
577
 
565
578
  // Emit dist/entry-ssr.js — the single-file SSR twin of the (code-split)