@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 +8 -8
- package/src/index.js +1 -0
- package/src/resolve-data-schema.js +1 -0
- package/src/site/emit-extensions.js +129 -0
- package/src/site/plugin.js +20 -0
- package/src/uwx/data-schema.js +67 -4
- package/src/uwx/registry-package.js +12 -3
- package/src/validate-data.js +91 -2
- package/src/vite-foundation-plugin.js +27 -14
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@uniweb/build",
|
|
3
|
-
"version": "0.16.
|
|
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/
|
|
62
|
+
"@uniweb/schemas": "0.2.8",
|
|
63
|
+
"@uniweb/theming": "0.1.15",
|
|
63
64
|
"@uniweb/projections": "0.2.5",
|
|
64
|
-
"@uniweb/
|
|
65
|
-
"@uniweb/theming": "0.1.15"
|
|
65
|
+
"@uniweb/content-writer": "0.3.3"
|
|
66
66
|
},
|
|
67
67
|
"optionalDependencies": {
|
|
68
|
-
"@uniweb/
|
|
69
|
-
"@uniweb/
|
|
70
|
-
"@uniweb/
|
|
71
|
-
"@uniweb/
|
|
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
|
@@ -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
|
+
}
|
package/src/site/plugin.js
CHANGED
|
@@ -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() {
|
package/src/uwx/data-schema.js
CHANGED
|
@@ -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
|
-
|
|
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 : '
|
|
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
|
|
package/src/validate-data.js
CHANGED
|
@@ -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 ===
|
|
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.
|
|
49
|
-
*
|
|
50
|
-
*
|
|
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).
|
|
60
|
-
*
|
|
61
|
-
*
|
|
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
|
-
*
|
|
65
|
-
*
|
|
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
|
-
//
|
|
559
|
-
//
|
|
560
|
-
//
|
|
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)
|