@uniweb/build 0.16.19 → 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.19",
3
+ "version": "0.16.20",
4
4
  "description": "Build tooling for the Uniweb Component Web Platform",
5
5
  "type": "module",
6
6
  "exports": {
@@ -59,14 +59,14 @@
59
59
  "js-yaml": "^4.1.0",
60
60
  "sharp": "^0.35.3",
61
61
  "yaml": "^2.5.0",
62
- "@uniweb/schemas": "0.2.7",
62
+ "@uniweb/schemas": "0.2.8",
63
63
  "@uniweb/theming": "0.1.15",
64
- "@uniweb/content-writer": "0.3.3",
65
- "@uniweb/projections": "0.2.5"
64
+ "@uniweb/projections": "0.2.5",
65
+ "@uniweb/content-writer": "0.3.3"
66
66
  },
67
67
  "optionalDependencies": {
68
- "@uniweb/schemas": "0.2.7",
69
- "@uniweb/runtime": "0.9.8",
68
+ "@uniweb/runtime": "0.9.9",
69
+ "@uniweb/schemas": "0.2.8",
70
70
  "@uniweb/semantic-parser": "1.2.1",
71
71
  "@uniweb/content-reader": "1.2.2"
72
72
  },
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,
@@ -152,11 +152,16 @@ function lowerSection(def, resolve, optResolve, path = '') {
152
152
  if (def.brief === true) out.brief = true
153
153
  // Display prose IS a section key — the registry stores it and keys it for
154
154
  // translation as `section.<name>.label` / `.description` (confirmed 2026-08-05).
155
- // Note the asymmetry with a LEAF, which is the opposite way round: a leaf's
156
- // `label`/`description` are accepted by the registry's parser and then DROPPED,
157
- // because a field declaration has no slot for prose — field labels live in
158
- // translation rows (`section.<name>.field.<key>.label`), which this producer
159
- // does not emit today. So section prose arrives; leaf prose does not.
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.
160
165
  if (def.label) out.label = def.label
161
166
  if (def.description) out.description = def.description
162
167
  if (def.nestable) out.self_nesting = true
@@ -252,7 +257,7 @@ function lowerField(rawField, resolve, optResolve, path = '') {
252
257
  ...lowerSection(
253
258
  {
254
259
  kind: 'multi',
255
- ...sectionProse(field),
260
+ ...sectionAttrsFromField(field),
256
261
  // `translatable: false` is load-bearing, not tidiness: a string field is
257
262
  // localized by default, and a localized key could differ per locale —
258
263
  // which would destroy the identity the key exists to carry. The key is an
@@ -261,7 +266,13 @@ function lowerField(rawField, resolve, optResolve, path = '') {
261
266
  [OPEN_MAP_KEY]: { type: 'string', required: true, translatable: false },
262
267
  ...value.fields
263
268
  },
264
- 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
+ ]
265
276
  },
266
277
  resolve,
267
278
  optResolve,
@@ -271,7 +282,7 @@ function lowerField(rawField, resolve, optResolve, path = '') {
271
282
  }
272
283
  return {
273
284
  type: 'section',
274
- ...lowerSection({ kind: 'single', ...sectionProse(field), fields: field.fields }, resolve, optResolve, path)
285
+ ...lowerSection({ kind: 'single', ...sectionAttrsFromField(field), fields: field.fields }, resolve, optResolve, path)
275
286
  }
276
287
  }
277
288
  if (type === 'array') {
@@ -279,7 +290,7 @@ function lowerField(rawField, resolve, optResolve, path = '') {
279
290
  if (items && items.type === 'object') {
280
291
  return {
281
292
  type: 'section',
282
- ...lowerSection({ kind: 'multi', ...sectionProse(field), fields: items.fields }, resolve, optResolve, path)
293
+ ...lowerSection({ kind: 'multi', ...sectionAttrsFromField(field), fields: items.fields }, resolve, optResolve, path)
283
294
  }
284
295
  }
285
296
  // A multi-valued LEAF or REFERENCE. `normalizeField` split this field in two
@@ -289,9 +300,22 @@ function lowerField(rawField, resolve, optResolve, path = '') {
289
300
  // to `items` — so rejoin the halves and lower them as one leaf carrying
290
301
  // `multiple: true`. Reading only `items.type` here is what silently dropped
291
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.
292
316
  const { items: _items, ...collection } = field
293
317
  return lowerLeaf(
294
- { ...collection, ...items, type: items ? items.type : 'string' },
318
+ { ...collection, ...items, type: items ? items.type : 'json' },
295
319
  resolve,
296
320
  optResolve,
297
321
  { multiple: true }
@@ -379,14 +403,31 @@ function asField(def) {
379
403
  }
380
404
 
381
405
  // A nested section is authored as a FIELD (`{ type: object, description: … }`),
382
- // but arrives on the wire as a section — so its prose has to travel from the
383
- // field declaration onto the section body, where the registry has a slot for it.
384
- // Without this an authored `description:` on a nested object was dropped twice
385
- // over: once by the normalizer, then again here.
386
- function sectionProse(field) {
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) {
387
420
  const out = {}
388
421
  if (field.label) out.label = field.label
389
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
390
431
  return out
391
432
  }
392
433
 
@@ -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)