@uniweb/build 0.16.16 → 0.16.18
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 +3 -2
- package/src/dev/plugin.js +8 -0
- package/src/resolve-data-schema.js +28 -402
- package/src/uwx/data-schema.js +152 -37
- package/src/validate-data.js +12 -225
- package/src/vite-foundation-plugin.js +32 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@uniweb/build",
|
|
3
|
-
"version": "0.16.
|
|
3
|
+
"version": "0.16.18",
|
|
4
4
|
"description": "Build tooling for the Uniweb Component Web Platform",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
@@ -61,12 +61,13 @@
|
|
|
61
61
|
"yaml": "^2.5.0",
|
|
62
62
|
"@uniweb/content-writer": "0.3.3",
|
|
63
63
|
"@uniweb/projections": "0.2.5",
|
|
64
|
+
"@uniweb/schemas": "0.2.6",
|
|
64
65
|
"@uniweb/theming": "0.1.15"
|
|
65
66
|
},
|
|
66
67
|
"optionalDependencies": {
|
|
67
68
|
"@uniweb/content-reader": "1.2.2",
|
|
68
69
|
"@uniweb/runtime": "0.9.7",
|
|
69
|
-
"@uniweb/schemas": "0.2.
|
|
70
|
+
"@uniweb/schemas": "0.2.6",
|
|
70
71
|
"@uniweb/semantic-parser": "1.2.1"
|
|
71
72
|
},
|
|
72
73
|
"peerDependencies": {
|
package/src/dev/plugin.js
CHANGED
|
@@ -26,6 +26,7 @@ import { readFile } from 'node:fs/promises'
|
|
|
26
26
|
import { existsSync, readdirSync } from 'node:fs'
|
|
27
27
|
import { build } from 'vite'
|
|
28
28
|
import { resolveFoundationSrcPath } from '../utils/foundation-source-root.js'
|
|
29
|
+
import { DEV_REBUILD_MARKER } from '../vite-foundation-plugin.js'
|
|
29
30
|
|
|
30
31
|
/** Directories that never hold foundation source and must never be walked. */
|
|
31
32
|
const UNWATCHABLE_DIRS = new Set(['node_modules', 'dist', 'build', 'coverage'])
|
|
@@ -126,6 +127,13 @@ export function foundationDevPlugin(options = {}) {
|
|
|
126
127
|
root: resolvedFoundationPath,
|
|
127
128
|
configFile: existsSync(configPath) ? configPath : false,
|
|
128
129
|
logLevel: 'warn',
|
|
130
|
+
// Marks this as a dev rebuild so the foundation plugin skips the
|
|
131
|
+
// `entry-ssr.js` sub-build — a second full Vite pass, on every save,
|
|
132
|
+
// producing something nothing in the dev loop reads. A marker plugin
|
|
133
|
+
// rather than an option because the foundation's own vite.config.js
|
|
134
|
+
// constructs the plugin, not us; and rather than `mode`/`command`,
|
|
135
|
+
// which both say "build" here since this IS a real build.
|
|
136
|
+
plugins: [{ name: DEV_REBUILD_MARKER }],
|
|
129
137
|
build: {
|
|
130
138
|
outDir: 'dist',
|
|
131
139
|
emptyOutDir: true,
|
|
@@ -25,11 +25,14 @@
|
|
|
25
25
|
* scope directory for that one name. Both take precedence over the '@org/schemas'
|
|
26
26
|
* package convention (file over directory over package); see `loadSchemaAliases`.
|
|
27
27
|
*
|
|
28
|
-
* The authoring format
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
28
|
+
* The authoring format itself — the type vocabulary and the normalization of its
|
|
29
|
+
* friendly aliases to canonical kinds — lives in `@uniweb/schemas/format`, and is
|
|
30
|
+
* re-exported below so every existing import of this module keeps working. It was
|
|
31
|
+
* moved there because it is a contract with more than one consumer (this build,
|
|
32
|
+
* the `@uniweb/schemas` package's own `validate()`, and any tooling that reads a
|
|
33
|
+
* schema), and a reader that cannot reach it grows a second copy that drifts —
|
|
34
|
+
* which is exactly what had happened. This module keeps the half that needs a
|
|
35
|
+
* disk: finding a ref's file, alias routing, and closing the resolution graph.
|
|
33
36
|
*/
|
|
34
37
|
|
|
35
38
|
import { readFile } from 'node:fs/promises'
|
|
@@ -39,42 +42,26 @@ import { pathToFileURL } from 'node:url'
|
|
|
39
42
|
import { createRequire } from 'node:module'
|
|
40
43
|
import yaml from 'js-yaml'
|
|
41
44
|
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
export
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
// `text` (a file-based rich-content body — round-trips as the raw source string).
|
|
63
|
-
const FORMAT_TYPE_ALIASES = {
|
|
64
|
-
url: { type: 'string', format: 'url' },
|
|
65
|
-
email: { type: 'string', format: 'email' },
|
|
66
|
-
markdown: { type: 'text', format: 'markdown' },
|
|
67
|
-
html: { type: 'text', format: 'html' },
|
|
68
|
-
// `richtext` → a ProseMirror rich document (`json` + `format: prosemirror`): the
|
|
69
|
-
// framework's standard way to represent rich text — the structured, lossless form
|
|
70
|
-
// the visual app edits (text, media, tables, code, data blocks, icons, and inline
|
|
71
|
-
// components). Synced to file mode as enhanced markdown via content-writer. Contrast
|
|
72
|
-
// `markdown`/`html`, which are source-string bodies (raw text, no structured editor).
|
|
73
|
-
richtext: { type: 'json', format: 'prosemirror' },
|
|
74
|
-
}
|
|
75
|
-
// The advertised format-aliasing type words (drives the "Known types" hint).
|
|
76
|
-
export const FORMAT_TYPES = new Set(['url', 'email', 'markdown', 'html', 'richtext'])
|
|
77
|
-
export const SECTION_KINDS = new Set(['single', 'multi', 'binder'])
|
|
45
|
+
import {
|
|
46
|
+
SCHEMA_EXTENSIONS,
|
|
47
|
+
parseSchemaRef,
|
|
48
|
+
validateAndNormalizeSchema,
|
|
49
|
+
collectNestedRefs,
|
|
50
|
+
} from '@uniweb/schemas/format'
|
|
51
|
+
|
|
52
|
+
// The authoring format, re-exported so this module stays the build's single
|
|
53
|
+
// entry point for schema resolution + normalization (no call site moved when the
|
|
54
|
+
// format itself did).
|
|
55
|
+
export {
|
|
56
|
+
SCALAR_KINDS,
|
|
57
|
+
STRUCTURAL_KINDS,
|
|
58
|
+
FORMAT_TYPES,
|
|
59
|
+
SECTION_KINDS,
|
|
60
|
+
SCHEMA_EXTENSIONS,
|
|
61
|
+
parseSchemaRef,
|
|
62
|
+
validateAndNormalizeSchema,
|
|
63
|
+
collectNestedRefs,
|
|
64
|
+
} from '@uniweb/schemas/format'
|
|
78
65
|
|
|
79
66
|
// Scope → schema package resolution. The shared standard schemas are referenced
|
|
80
67
|
// under '@std' but ship in the framework's '@uniweb/schemas' package; every
|
|
@@ -86,30 +73,6 @@ const SCOPE_PACKAGE = { std: '@uniweb/schemas' }
|
|
|
86
73
|
const RESERVED_SYSTEM_SCOPE = 'uniweb'
|
|
87
74
|
const packageForScope = (scope) => SCOPE_PACKAGE[scope] ?? `@${scope}/schemas`
|
|
88
75
|
|
|
89
|
-
/**
|
|
90
|
-
* Parse a data-schema ref into `{ scope, name }`.
|
|
91
|
-
* '@/member' → { scope: '', name: 'member' } (self namespace)
|
|
92
|
-
* '@std/person' → { scope: 'std', name: 'person' }
|
|
93
|
-
*/
|
|
94
|
-
export function parseSchemaRef(ref) {
|
|
95
|
-
if (typeof ref !== 'string' || ref[0] !== '@') {
|
|
96
|
-
throw new Error(
|
|
97
|
-
`Invalid data-schema ref ${JSON.stringify(ref)}: must start with '@' ` +
|
|
98
|
-
`(e.g. '@/member' for this foundation, or '@std/person' for a shared standard).`
|
|
99
|
-
)
|
|
100
|
-
}
|
|
101
|
-
const slash = ref.indexOf('/')
|
|
102
|
-
if (slash === -1) {
|
|
103
|
-
throw new Error(`Invalid data-schema ref '${ref}': expected '@<scope>/<name>' (use '@/<name>' for this foundation).`)
|
|
104
|
-
}
|
|
105
|
-
const scope = ref.slice(1, slash) // '' for '@/...'
|
|
106
|
-
const name = ref.slice(slash + 1)
|
|
107
|
-
if (!name || name.includes('/')) {
|
|
108
|
-
throw new Error(`Invalid data-schema ref '${ref}': expected a single '<name>' segment after the namespace.`)
|
|
109
|
-
}
|
|
110
|
-
return { scope, name }
|
|
111
|
-
}
|
|
112
|
-
|
|
113
76
|
/**
|
|
114
77
|
* Collect every distinct schema ref used by a foundation's section bindings.
|
|
115
78
|
* Reads `data: { key: '<ref>' }` (short) and `data: { key: { schema: '<ref>' } }`
|
|
@@ -336,343 +299,6 @@ function resolvePackageEntryFile(packageDir, pkg) {
|
|
|
336
299
|
return join(packageDir, entry || 'index.js')
|
|
337
300
|
}
|
|
338
301
|
|
|
339
|
-
// --- validation + normalization --------------------------------------------
|
|
340
|
-
|
|
341
|
-
/**
|
|
342
|
-
* Validate a schema definition against the authoring format and return a
|
|
343
|
-
* normalized copy (type aliases folded to canonical kinds). Pure — no I/O.
|
|
344
|
-
* Throws an Error naming the schema + the offending field/section.
|
|
345
|
-
*
|
|
346
|
-
* @param {Object} schema - the schema as authored
|
|
347
|
-
* @param {string} ref - for error messages (e.g. '@/product')
|
|
348
|
-
* @returns {Object} normalized schema
|
|
349
|
-
*/
|
|
350
|
-
export function validateAndNormalizeSchema(schema, ref) {
|
|
351
|
-
if (!schema || typeof schema !== 'object' || Array.isArray(schema)) {
|
|
352
|
-
throw new Error(`Data schema '${ref}' did not export a schema object.`)
|
|
353
|
-
}
|
|
354
|
-
|
|
355
|
-
const out = {}
|
|
356
|
-
for (const k of ['name', 'version', 'description']) {
|
|
357
|
-
if (schema[k] !== undefined) out[k] = schema[k]
|
|
358
|
-
}
|
|
359
|
-
|
|
360
|
-
// The model's sort axis names a DATE FIELD IN THE BRIEF section (not a boolean,
|
|
361
|
-
// not a field-level flag) — the lowering stamps `sort_date: true` on that field.
|
|
362
|
-
// Authored as `sort_date` (the authoring vocabulary is snake_case, like
|
|
363
|
-
// `append_only`); `sortDate` is an accepted alias. Both normalize to `sortDate`,
|
|
364
|
-
// the single key the lowering reads — carrying the two spellings through verbatim
|
|
365
|
-
// meant an authored `sort_date` was silently dropped.
|
|
366
|
-
const sortDate = schema.sort_date ?? schema.sortDate
|
|
367
|
-
if (sortDate !== undefined) {
|
|
368
|
-
if (typeof sortDate !== 'string') {
|
|
369
|
-
throw new Error(
|
|
370
|
-
`Data schema '${ref}': 'sort_date' must name a date field in the brief section, got ${typeof sortDate}.`
|
|
371
|
-
)
|
|
372
|
-
}
|
|
373
|
-
out.sortDate = sortDate
|
|
374
|
-
}
|
|
375
|
-
|
|
376
|
-
const hasFields = schema.fields !== undefined
|
|
377
|
-
const hasSections = schema.sections !== undefined
|
|
378
|
-
if (hasFields && hasSections) {
|
|
379
|
-
throw new Error(`Data schema '${ref}': declare either 'fields' (shorthand) or 'sections', not both.`)
|
|
380
|
-
}
|
|
381
|
-
if (!hasFields && !hasSections) {
|
|
382
|
-
throw new Error(`Data schema '${ref}': must declare 'fields' or 'sections'.`)
|
|
383
|
-
}
|
|
384
|
-
|
|
385
|
-
if (hasSections) {
|
|
386
|
-
out.sections = normalizeSections(schema.sections, ref)
|
|
387
|
-
} else {
|
|
388
|
-
out.fields = normalizeFields(schema.fields, ref, '')
|
|
389
|
-
}
|
|
390
|
-
return out
|
|
391
|
-
}
|
|
392
|
-
|
|
393
|
-
function normalizeSections(sections, ref) {
|
|
394
|
-
if (!sections || typeof sections !== 'object' || Array.isArray(sections)) {
|
|
395
|
-
throw new Error(`Data schema '${ref}': 'sections' must be a map of section name → definition.`)
|
|
396
|
-
}
|
|
397
|
-
const briefState = { count: 0 }
|
|
398
|
-
const out = {}
|
|
399
|
-
for (const [name, section] of Object.entries(sections)) {
|
|
400
|
-
out[name] = normalizeSection(section, ref, `sections.${name}`, briefState)
|
|
401
|
-
}
|
|
402
|
-
return out
|
|
403
|
-
}
|
|
404
|
-
|
|
405
|
-
function normalizeSection(section, ref, path, briefState) {
|
|
406
|
-
if (!section || typeof section !== 'object' || Array.isArray(section)) {
|
|
407
|
-
throw new Error(`Data schema '${ref}': section '${path}' must be an object.`)
|
|
408
|
-
}
|
|
409
|
-
if (section.many !== undefined && typeof section.many !== 'boolean') {
|
|
410
|
-
throw new Error(`Data schema '${ref}': section '${path}' 'many' must be a boolean.`)
|
|
411
|
-
}
|
|
412
|
-
// Cardinality. Friendly sugar: `many: true` → a list of records; a section with
|
|
413
|
-
// only child `sections:` (no `fields:`) is a binder — inferred, never written.
|
|
414
|
-
// Explicit `kind:` is still honored (the lower-level form it normalizes to).
|
|
415
|
-
let kind = section.kind
|
|
416
|
-
if (kind === undefined) {
|
|
417
|
-
if (section.many === true) kind = 'multi'
|
|
418
|
-
else if (section.fields === undefined && section.sections !== undefined) kind = 'binder'
|
|
419
|
-
else kind = 'single'
|
|
420
|
-
}
|
|
421
|
-
if (!SECTION_KINDS.has(kind)) {
|
|
422
|
-
throw new Error(`Data schema '${ref}': section '${path}' has invalid kind '${kind}' (expected single | multi | binder).`)
|
|
423
|
-
}
|
|
424
|
-
const out = { kind }
|
|
425
|
-
|
|
426
|
-
if (section.brief === true) {
|
|
427
|
-
if (kind !== 'single') {
|
|
428
|
-
throw new Error(`Data schema '${ref}': brief section '${path}' must be a single record (drop 'many').`)
|
|
429
|
-
}
|
|
430
|
-
if (++briefState.count > 1) {
|
|
431
|
-
throw new Error(`Data schema '${ref}': more than one section marked 'brief: true' (at most one).`)
|
|
432
|
-
}
|
|
433
|
-
out.brief = true
|
|
434
|
-
}
|
|
435
|
-
|
|
436
|
-
if (kind === 'binder') {
|
|
437
|
-
if (section.fields !== undefined) {
|
|
438
|
-
throw new Error(`Data schema '${ref}': binder section '${path}' carries only child 'sections', not 'fields'.`)
|
|
439
|
-
}
|
|
440
|
-
if (section.sections === undefined) {
|
|
441
|
-
throw new Error(`Data schema '${ref}': binder section '${path}' must declare child 'sections'.`)
|
|
442
|
-
}
|
|
443
|
-
}
|
|
444
|
-
if (section.fields !== undefined) out.fields = normalizeFields(section.fields, ref, path)
|
|
445
|
-
if (section.sections !== undefined) {
|
|
446
|
-
const childBrief = { count: 0 }
|
|
447
|
-
out.sections = {}
|
|
448
|
-
for (const [n, s] of Object.entries(section.sections)) {
|
|
449
|
-
out.sections[n] = normalizeSection(s, ref, `${path}.sections.${n}`, childBrief)
|
|
450
|
-
}
|
|
451
|
-
}
|
|
452
|
-
if (section.constraints !== undefined) out.constraints = section.constraints
|
|
453
|
-
|
|
454
|
-
// `tree: true` (friendly) / `nestable: true` (lower-level) — a list section whose
|
|
455
|
-
// records form a tree among themselves. Carried into the IR so the lowering maps
|
|
456
|
-
// it to the model's `self_nesting`. The parent/child link is internal to the
|
|
457
|
-
// backend (`parent_item_id`); no explicit field expresses it.
|
|
458
|
-
const treeFlag = section.tree ?? section.nestable
|
|
459
|
-
if (treeFlag !== undefined) {
|
|
460
|
-
if (typeof treeFlag !== 'boolean') {
|
|
461
|
-
throw new Error(`Data schema '${ref}': section '${path}' 'tree' must be a boolean.`)
|
|
462
|
-
}
|
|
463
|
-
if (treeFlag && kind !== 'multi') {
|
|
464
|
-
throw new Error(`Data schema '${ref}': section '${path}' is 'tree: true' but not a list — only a 'many: true' section can form a tree.`)
|
|
465
|
-
}
|
|
466
|
-
if (treeFlag) out.nestable = true
|
|
467
|
-
}
|
|
468
|
-
|
|
469
|
-
// `append_only` — a multi whose records are insert-only: the backend accepts
|
|
470
|
-
// appends but refuses edits or deletes of existing items, so the section is
|
|
471
|
-
// tamper-evident (activity logs, submissions, audit trails). Carried into the IR
|
|
472
|
-
// verbatim for the submission lowering to emit as the model's `append_only`.
|
|
473
|
-
// Like `nestable`, only a `multi` section can be append-only.
|
|
474
|
-
if (section.append_only !== undefined) {
|
|
475
|
-
if (typeof section.append_only !== 'boolean') {
|
|
476
|
-
throw new Error(`Data schema '${ref}': section '${path}' 'append_only' must be a boolean.`)
|
|
477
|
-
}
|
|
478
|
-
if (section.append_only && kind !== 'multi') {
|
|
479
|
-
throw new Error(`Data schema '${ref}': section '${path}' is 'append_only: true' but not a list — only a 'many: true' section can be append-only.`)
|
|
480
|
-
}
|
|
481
|
-
if (section.append_only) out.append_only = true
|
|
482
|
-
}
|
|
483
|
-
|
|
484
|
-
return out
|
|
485
|
-
}
|
|
486
|
-
|
|
487
|
-
function normalizeFields(fields, ref, path) {
|
|
488
|
-
if (!fields || typeof fields !== 'object' || Array.isArray(fields)) {
|
|
489
|
-
throw new Error(`Data schema '${ref}': 'fields'${path ? ` in '${path}'` : ''} must be a map of field name → definition.`)
|
|
490
|
-
}
|
|
491
|
-
const out = {}
|
|
492
|
-
for (const [name, field] of Object.entries(fields)) {
|
|
493
|
-
out[name] = normalizeField(field, ref, path ? `${path}.${name}` : name)
|
|
494
|
-
}
|
|
495
|
-
return out
|
|
496
|
-
}
|
|
497
|
-
|
|
498
|
-
function normalizeField(field, ref, path) {
|
|
499
|
-
// Shorthand: a bare type string.
|
|
500
|
-
if (typeof field === 'string') field = { type: field }
|
|
501
|
-
if (!field || typeof field !== 'object' || Array.isArray(field)) {
|
|
502
|
-
throw new Error(`Data schema '${ref}': field '${path}' must be an object or a type string.`)
|
|
503
|
-
}
|
|
504
|
-
|
|
505
|
-
// Sugar: `many: true` → a list. Wrap the field-minus-`many` as the array's item
|
|
506
|
-
// type (lowers to the canonical `multiple`). The common cases —
|
|
507
|
-
// `{ ref: '@/x', many: true }`, `{ type: string, many: true }` — read as "a list
|
|
508
|
-
// of X" with no `array`/`items` ceremony.
|
|
509
|
-
if (field.many !== undefined) {
|
|
510
|
-
if (typeof field.many !== 'boolean') {
|
|
511
|
-
throw new Error(`Data schema '${ref}': field '${path}' 'many' must be a boolean.`)
|
|
512
|
-
}
|
|
513
|
-
if (field.many) {
|
|
514
|
-
// Collection-level metadata (required, default, label, help, description)
|
|
515
|
-
// rides on the array; the type-bearing attributes describe each item.
|
|
516
|
-
const ITEM_KEYS = new Set(['type', 'ref', 'options', 'enum', 'fields', 'items', 'format'])
|
|
517
|
-
const out = { type: 'array' }
|
|
518
|
-
const item = {}
|
|
519
|
-
for (const [k, v] of Object.entries(field)) {
|
|
520
|
-
if (k === 'many') continue
|
|
521
|
-
if (ITEM_KEYS.has(k)) item[k] = v
|
|
522
|
-
else out[k] = v
|
|
523
|
-
}
|
|
524
|
-
out.items = normalizeField(item, ref, `${path}[]`)
|
|
525
|
-
return out
|
|
526
|
-
}
|
|
527
|
-
const { many, ...rest } = field // many: false → a single value
|
|
528
|
-
field = rest
|
|
529
|
-
}
|
|
530
|
-
|
|
531
|
-
// Sugar: infer `type` from `ref:`/`options:` when omitted — `{ ref: '@/x' }` is a
|
|
532
|
-
// reference; `{ options: '@/x' }` is a curated picklist value.
|
|
533
|
-
if (field.type === undefined) {
|
|
534
|
-
if (typeof field.ref === 'string') field = { ...field, type: 'ref' }
|
|
535
|
-
else if (typeof field.options === 'string') field = { ...field, type: 'string' }
|
|
536
|
-
}
|
|
537
|
-
|
|
538
|
-
const rawType = field.type
|
|
539
|
-
if (typeof rawType !== 'string') {
|
|
540
|
-
throw new Error(`Data schema '${ref}': field '${path}' has no 'type'.`)
|
|
541
|
-
}
|
|
542
|
-
|
|
543
|
-
const out = {}
|
|
544
|
-
// Carry-through metadata (render hints / flags / value).
|
|
545
|
-
for (const k of ['required', 'default', 'label', 'help', 'description', 'translatable', 'format']) {
|
|
546
|
-
if (field[k] !== undefined) out[k] = field[k]
|
|
547
|
-
}
|
|
548
|
-
|
|
549
|
-
// Resolve the type: format-aliases (url/email → string; markdown/html → text;
|
|
550
|
-
// richtext → json) carry a `format` marker; else the plain alias map; else verbatim.
|
|
551
|
-
const formatAlias = FORMAT_TYPE_ALIASES[rawType]
|
|
552
|
-
if (formatAlias) {
|
|
553
|
-
out.type = formatAlias.type
|
|
554
|
-
out.format = field.format ?? formatAlias.format
|
|
555
|
-
} else {
|
|
556
|
-
out.type = TYPE_ALIASES[rawType] ?? rawType
|
|
557
|
-
}
|
|
558
|
-
|
|
559
|
-
if (!SCALAR_KINDS.has(out.type) && !STRUCTURAL_KINDS.has(out.type)) {
|
|
560
|
-
throw new Error(
|
|
561
|
-
`Data schema '${ref}': field '${path}' has unknown type '${rawType}'. ` +
|
|
562
|
-
`Known: ${[...SCALAR_KINDS, ...STRUCTURAL_KINDS, ...Object.keys(TYPE_ALIASES), ...FORMAT_TYPES].sort().join(', ')}.`
|
|
563
|
-
)
|
|
564
|
-
}
|
|
565
|
-
|
|
566
|
-
// Content `format` markers are registered per-shape (uwx-format.md §3): the
|
|
567
|
-
// rich-content markers `markdown`/`html` belong on a `text` field; `prosemirror`
|
|
568
|
-
// (a ProseMirror doc) and `scene` (a Scene Composition Format payload — an opaque
|
|
569
|
-
// structured blob the app edits via the Designer / visual canvas) both belong on
|
|
570
|
-
// a `json` field. Catch a mismatch at build time, not at publish (the backend
|
|
571
|
-
// rejects it). Value-validator formats (email/url) are unrestricted here.
|
|
572
|
-
if ((out.format === 'markdown' || out.format === 'html') && out.type !== 'text') {
|
|
573
|
-
throw new Error(
|
|
574
|
-
`Data schema '${ref}': field '${path}' has format '${out.format}', valid only on a 'text' field (got '${out.type}').`
|
|
575
|
-
)
|
|
576
|
-
}
|
|
577
|
-
if ((out.format === 'prosemirror' || out.format === 'scene') && out.type !== 'json') {
|
|
578
|
-
throw new Error(
|
|
579
|
-
`Data schema '${ref}': field '${path}' has format '${out.format}', valid only on a 'json' field (got '${out.type}').`
|
|
580
|
-
)
|
|
581
|
-
}
|
|
582
|
-
|
|
583
|
-
// Picklists: enum = inline list; options = a curated '@/x' ref (item_ref).
|
|
584
|
-
if (field.enum !== undefined) {
|
|
585
|
-
if (!Array.isArray(field.enum)) {
|
|
586
|
-
throw new Error(`Data schema '${ref}': field '${path}' 'enum' must be a list of values.`)
|
|
587
|
-
}
|
|
588
|
-
out.enum = field.enum
|
|
589
|
-
}
|
|
590
|
-
if (field.options !== undefined) {
|
|
591
|
-
if (typeof field.options !== 'string' || field.options[0] !== '@') {
|
|
592
|
-
throw new Error(
|
|
593
|
-
`Data schema '${ref}': field '${path}' 'options' must be a '@/<name>' ref to a curated options schema. ` +
|
|
594
|
-
`For an inline list use 'enum:'.`
|
|
595
|
-
)
|
|
596
|
-
}
|
|
597
|
-
parseSchemaRef(field.options) // shape-check the ref
|
|
598
|
-
out.options = field.options
|
|
599
|
-
}
|
|
600
|
-
|
|
601
|
-
// Structural kinds.
|
|
602
|
-
if (out.type === 'object') {
|
|
603
|
-
// Two ways to describe an object, and they answer different questions:
|
|
604
|
-
//
|
|
605
|
-
// fields the object's KNOWN keys — `{ street, city }`
|
|
606
|
-
// values an OPEN MAP whose keys belong to the author and whose values all
|
|
607
|
-
// conform to one shape — `{ <anything>: <a field spec> }`
|
|
608
|
-
//
|
|
609
|
-
// `values` is to an object what `items` is to an array, and it exists for
|
|
610
|
-
// the same reason: a form's `fields` is a map keyed by author-chosen names
|
|
611
|
-
// (see `@std/form`), which could not be described at all until this landed.
|
|
612
|
-
// Requested by the editor team 2026-07-31 — the shape they needed and could
|
|
613
|
-
// not state in this vocabulary either.
|
|
614
|
-
if (field.values !== undefined && field.fields !== undefined) {
|
|
615
|
-
throw new Error(
|
|
616
|
-
`Data schema '${ref}': object field '${path}' declares both 'fields' and 'values'. ` +
|
|
617
|
-
`Use 'fields' for known keys, 'values' for an open map — not both.`
|
|
618
|
-
)
|
|
619
|
-
}
|
|
620
|
-
if (field.values !== undefined) {
|
|
621
|
-
out.values = normalizeField(field.values, ref, `${path}{}`)
|
|
622
|
-
} else if (field.fields === undefined) {
|
|
623
|
-
throw new Error(
|
|
624
|
-
`Data schema '${ref}': object field '${path}' must declare nested 'fields' (known keys) or 'values' (an open map).`
|
|
625
|
-
)
|
|
626
|
-
} else {
|
|
627
|
-
out.fields = normalizeFields(field.fields, ref, path)
|
|
628
|
-
}
|
|
629
|
-
} else if (out.type === 'array') {
|
|
630
|
-
// `items` (the element type) is recommended but optional — an array with
|
|
631
|
-
// no declared element type is an untyped list.
|
|
632
|
-
if (field.items !== undefined) {
|
|
633
|
-
out.items = normalizeField(field.items, ref, `${path}[]`)
|
|
634
|
-
}
|
|
635
|
-
} else if (out.type === 'ref') {
|
|
636
|
-
if (typeof field.ref !== 'string' || field.ref[0] !== '@') {
|
|
637
|
-
throw new Error(`Data schema '${ref}': ref field '${path}' must name a target schema, e.g. ref: '@/person'.`)
|
|
638
|
-
}
|
|
639
|
-
parseSchemaRef(field.ref)
|
|
640
|
-
out.ref = field.ref
|
|
641
|
-
}
|
|
642
|
-
|
|
643
|
-
return out
|
|
644
|
-
}
|
|
645
|
-
|
|
646
|
-
/**
|
|
647
|
-
* Walk a normalized schema and collect every nested `ref`/`options` target —
|
|
648
|
-
* the data schemas this one depends on. Used to close the resolution graph.
|
|
649
|
-
*
|
|
650
|
-
* @param {Object} schema - a normalized schema
|
|
651
|
-
* @returns {string[]} distinct ref strings
|
|
652
|
-
*/
|
|
653
|
-
export function collectNestedRefs(schema) {
|
|
654
|
-
const found = new Set()
|
|
655
|
-
const walkFields = (fields) => {
|
|
656
|
-
for (const field of Object.values(fields || {})) {
|
|
657
|
-
if (typeof field !== 'object' || !field) continue
|
|
658
|
-
if (typeof field.ref === 'string') found.add(field.ref)
|
|
659
|
-
if (typeof field.options === 'string') found.add(field.options)
|
|
660
|
-
if (field.fields) walkFields(field.fields)
|
|
661
|
-
if (field.items) walkFields({ _: field.items })
|
|
662
|
-
if (field.values) walkFields({ _: field.values })
|
|
663
|
-
}
|
|
664
|
-
}
|
|
665
|
-
const walkSections = (sections) => {
|
|
666
|
-
for (const section of Object.values(sections || {})) {
|
|
667
|
-
if (section?.fields) walkFields(section.fields)
|
|
668
|
-
if (section?.sections) walkSections(section.sections)
|
|
669
|
-
}
|
|
670
|
-
}
|
|
671
|
-
if (schema?.fields) walkFields(schema.fields)
|
|
672
|
-
if (schema?.sections) walkSections(schema.sections)
|
|
673
|
-
return [...found]
|
|
674
|
-
}
|
|
675
|
-
|
|
676
302
|
// --- internals --------------------------------------------------------------
|
|
677
303
|
|
|
678
304
|
function findSelfSchemaFile(srcDir, name) {
|
package/src/uwx/data-schema.js
CHANGED
|
@@ -29,6 +29,14 @@ const TEXT_KINDS = new Set(['string', 'text'])
|
|
|
29
29
|
// carrying one round-trips as the RAW source string (no ProseMirror conversion).
|
|
30
30
|
const CONTENT_TEXT_FORMATS = new Set(['markdown', 'html'])
|
|
31
31
|
|
|
32
|
+
// The field an open map's key lowers into. `name` matches the idiom already in use
|
|
33
|
+
// for this shape (the backend's site-content `collections` section), so an open map
|
|
34
|
+
// and a hand-authored row set produce the same wire shape rather than two spellings
|
|
35
|
+
// of one thing. Not configurable on purpose: a second way to spell it would be a
|
|
36
|
+
// vocabulary addition for a collision that does not exist yet — a value schema that
|
|
37
|
+
// declares its own `name` is an error instead, which names the problem precisely.
|
|
38
|
+
const OPEN_MAP_KEY = 'name'
|
|
39
|
+
|
|
32
40
|
/**
|
|
33
41
|
* A `text` field marked as rich content (`format: markdown` or `html`): the
|
|
34
42
|
* file-based body target. Round-trips as the raw source string — what the retired
|
|
@@ -119,7 +127,7 @@ function lowerSectionsForm(sectionsMap, resolve, optResolve) {
|
|
|
119
127
|
let explicit = null
|
|
120
128
|
let firstSingle = null
|
|
121
129
|
for (const [secName, def] of Object.entries(sectionsMap)) {
|
|
122
|
-
sections[secName] = lowerSection(def, resolve, optResolve)
|
|
130
|
+
sections[secName] = lowerSection(def, resolve, optResolve, secName)
|
|
123
131
|
if (def.brief === true) explicit = secName
|
|
124
132
|
if (!firstSingle && (def.kind || 'single') === 'single') firstSingle = secName
|
|
125
133
|
}
|
|
@@ -138,7 +146,7 @@ function lowerSectionsForm(sectionsMap, resolve, optResolve) {
|
|
|
138
146
|
// are type: section"); `nestable` → `self_nesting`; `append_only` (insert-only
|
|
139
147
|
// records) passes through; authored cross-cutting `constraints` pass through as a
|
|
140
148
|
// bare array. Leaves and nested sections share one ordered `fields:` namespace.
|
|
141
|
-
function lowerSection(def, resolve, optResolve) {
|
|
149
|
+
function lowerSection(def, resolve, optResolve, path = '') {
|
|
142
150
|
const out = {}
|
|
143
151
|
if ((def.kind || 'single') === 'multi') out.multiple = true
|
|
144
152
|
if (def.brief === true) out.brief = true
|
|
@@ -147,74 +155,181 @@ function lowerSection(def, resolve, optResolve) {
|
|
|
147
155
|
|
|
148
156
|
const fields = {}
|
|
149
157
|
for (const [key, rawField] of Object.entries(def.fields || {})) {
|
|
150
|
-
fields[key] = lowerField(rawField, resolve, optResolve)
|
|
158
|
+
fields[key] = lowerField(rawField, resolve, optResolve, path ? `${path}/${key}` : key)
|
|
151
159
|
}
|
|
152
160
|
// Explicit child sections (sections-form, e.g. under a binder) → `type: section`
|
|
153
161
|
// fields, in the same ordered namespace as the leaves.
|
|
154
162
|
for (const [childName, childDef] of Object.entries(def.sections || {})) {
|
|
155
|
-
|
|
163
|
+
const childPath = path ? `${path}/${childName}` : childName
|
|
164
|
+
fields[childName] = {
|
|
165
|
+
type: 'section',
|
|
166
|
+
...lowerSection(childDef, resolve, optResolve, childPath)
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
// A section with neither leaves nor sub-sections carries nothing, and is not a
|
|
170
|
+
// valid section on this wire by either party's reckoning. Refusing here fails at
|
|
171
|
+
// the schema author's screen; emitting it fails in a consumer's restore, which is
|
|
172
|
+
// the last possible moment and the wrong screen (2026-08-04: `@std/form` shipped
|
|
173
|
+
// exactly this, because `values:` had no lowering and silently produced no fields).
|
|
174
|
+
if (!Object.keys(fields).length) {
|
|
175
|
+
throw new Error(
|
|
176
|
+
`Data schema: section '${path || '(root)'}' declares no fields and no sub-sections. ` +
|
|
177
|
+
`A section must carry at least one leaf or child section.`
|
|
178
|
+
)
|
|
156
179
|
}
|
|
157
|
-
|
|
180
|
+
out.fields = fields
|
|
158
181
|
if (Array.isArray(def.constraints) && def.constraints.length) out.constraints = def.constraints
|
|
159
182
|
return out
|
|
160
183
|
}
|
|
161
184
|
|
|
162
|
-
// Lower one field to its declaration value
|
|
163
|
-
//
|
|
164
|
-
// object → a single nested section
|
|
185
|
+
// Lower one field to its declaration value:
|
|
186
|
+
// object → a single nested section
|
|
165
187
|
// array of object → a multi nested section
|
|
166
|
-
// array of ref → entity_ref + multiple
|
|
167
|
-
// array of scalar → the scalar kind + multiple
|
|
168
|
-
// ref → entity_ref (model by name)
|
|
169
|
-
|
|
188
|
+
// array of ref → entity_ref + multiple ┐
|
|
189
|
+
// array of scalar → the scalar kind + multiple ├─ leaf-shaped: lowerLeaf
|
|
190
|
+
// ref → entity_ref (model by name) │
|
|
191
|
+
// scalar → the kind + its attributes ┘
|
|
192
|
+
//
|
|
193
|
+
// Every LEAF-SHAPED output goes through `lowerLeaf`, which is the whole point of
|
|
194
|
+
// the split: the field attributes (`label` / `description` / `required`, plus
|
|
195
|
+
// `enum` / `format` / `localized`) are emitted in exactly one place, so a kind
|
|
196
|
+
// cannot quietly miss them. They used to be emitted inline at the end of this
|
|
197
|
+
// function, which each structural branch returned before reaching — so a
|
|
198
|
+
// multi-valued leaf reached the wire as a bare `{ type, multiple }`, losing its
|
|
199
|
+
// closed set, its format validation and its localization, and an `entity_ref`
|
|
200
|
+
// lost its label/description/required while `item_ref` (which fell through)
|
|
201
|
+
// kept them. That asymmetry between the two reference kinds is what gave the
|
|
202
|
+
// bug away: nothing had decided it, the control flow had.
|
|
203
|
+
//
|
|
204
|
+
// SECTION-shaped outputs (`object`, `array of object`) still carry none of
|
|
205
|
+
// those attributes. That is NOT settled — a section body's documented shape is
|
|
206
|
+
// `multiple` / `brief` / `self_nesting` / `append_only` / `constraints` /
|
|
207
|
+
// `fields`, so whether the wire accepts `label` / `description` / `required` on
|
|
208
|
+
// a section is the consumer's contract to state, not ours to assume. Until it
|
|
209
|
+
// does, an authored `required: true` on a nested object or a list of records is
|
|
210
|
+
// still dropped here. `@std/publication`'s `authors` is exactly that case.
|
|
211
|
+
function lowerField(rawField, resolve, optResolve, path = '') {
|
|
170
212
|
const field = asField(rawField)
|
|
171
213
|
const type = field.type
|
|
172
214
|
|
|
173
215
|
if (type === 'object') {
|
|
174
|
-
|
|
216
|
+
// An OPEN MAP (`values:`) is ROWS, not a singleton. Its keys belong to the
|
|
217
|
+
// author, which makes them data — so the map lowers to a `multi` section whose
|
|
218
|
+
// key field carries what was the object key, with a section-scoped uniqueness
|
|
219
|
+
// rule making that key the row's identity. This is the same shape `array of
|
|
220
|
+
// object` already lowers to, and the idiom the backend's own site-content
|
|
221
|
+
// `collections` section uses; no new wire construct is involved.
|
|
222
|
+
//
|
|
223
|
+
// Identity is the KEY, never row position — a round-trip that rebuilds the map
|
|
224
|
+
// from order looks correct and drifts the first time rows are reordered.
|
|
225
|
+
// Authoring order is still preserved into row order, because for a form the
|
|
226
|
+
// field order is what the visitor sees.
|
|
227
|
+
if (field.values !== undefined) {
|
|
228
|
+
const value = asField(field.values)
|
|
229
|
+
if (value.type !== 'object' || !value.fields) {
|
|
230
|
+
throw new Error(
|
|
231
|
+
`Data schema: open map at '${path}' declares 'values' that is not an object with ` +
|
|
232
|
+
`'fields'. An open map lowers to rows, and a row needs declared columns.`
|
|
233
|
+
)
|
|
234
|
+
}
|
|
235
|
+
if (value.fields[OPEN_MAP_KEY]) {
|
|
236
|
+
throw new Error(
|
|
237
|
+
`Data schema: open map at '${path}' has a value field named '${OPEN_MAP_KEY}', which ` +
|
|
238
|
+
`is the field the map's key lowers into. Rename that field.`
|
|
239
|
+
)
|
|
240
|
+
}
|
|
241
|
+
return {
|
|
242
|
+
type: 'section',
|
|
243
|
+
...lowerSection(
|
|
244
|
+
{
|
|
245
|
+
kind: 'multi',
|
|
246
|
+
// `translatable: false` is load-bearing, not tidiness: a string field is
|
|
247
|
+
// localized by default, and a localized key could differ per locale —
|
|
248
|
+
// which would destroy the identity the key exists to carry. The key is an
|
|
249
|
+
// identifier, never content.
|
|
250
|
+
fields: {
|
|
251
|
+
[OPEN_MAP_KEY]: { type: 'string', required: true, translatable: false },
|
|
252
|
+
...value.fields
|
|
253
|
+
},
|
|
254
|
+
constraints: [{ kind: 'unique_field', field: OPEN_MAP_KEY, scope: 'section' }]
|
|
255
|
+
},
|
|
256
|
+
resolve,
|
|
257
|
+
optResolve,
|
|
258
|
+
path
|
|
259
|
+
)
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
return {
|
|
263
|
+
type: 'section',
|
|
264
|
+
...lowerSection({ kind: 'single', fields: field.fields }, resolve, optResolve, path)
|
|
265
|
+
}
|
|
175
266
|
}
|
|
176
267
|
if (type === 'array') {
|
|
177
268
|
const items = field.items ? asField(field.items) : null
|
|
178
269
|
if (items && items.type === 'object') {
|
|
179
|
-
return {
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
// that once forced a child multi section is retired).
|
|
184
|
-
const out = { type: 'entity_ref', multiple: true }
|
|
185
|
-
if (items.ref) out.model = resolve(items.ref)
|
|
186
|
-
return out
|
|
270
|
+
return {
|
|
271
|
+
type: 'section',
|
|
272
|
+
...lowerSection({ kind: 'multi', fields: items.fields }, resolve, optResolve, path)
|
|
273
|
+
}
|
|
187
274
|
}
|
|
188
|
-
//
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
275
|
+
// A multi-valued LEAF or REFERENCE. `normalizeField` split this field in two
|
|
276
|
+
// when it expanded `many: true` — collection-level metadata (`required`,
|
|
277
|
+
// `label`, `description`, `translatable`) stayed on the array, the
|
|
278
|
+
// type-bearing attributes (`type`, `ref`, `options`, `enum`, `format`) moved
|
|
279
|
+
// to `items` — so rejoin the halves and lower them as one leaf carrying
|
|
280
|
+
// `multiple: true`. Reading only `items.type` here is what silently dropped
|
|
281
|
+
// both halves' attributes; the rejoin is the exact inverse of the split.
|
|
282
|
+
const { items: _items, ...collection } = field
|
|
283
|
+
return lowerLeaf(
|
|
284
|
+
{ ...collection, ...items, type: items ? items.type : 'string' },
|
|
285
|
+
resolve,
|
|
286
|
+
optResolve,
|
|
287
|
+
{ multiple: true }
|
|
288
|
+
)
|
|
195
289
|
}
|
|
196
290
|
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
291
|
+
return lowerLeaf(field, resolve, optResolve)
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
// Lower a LEAF-SHAPED field — a scalar, a reference (`entity_ref`), or a curated
|
|
295
|
+
// picklist (`item_ref`) — with or without `multiple`. The single place field
|
|
296
|
+
// attributes are emitted, so every leaf-shaped kind carries the same set.
|
|
297
|
+
function lowerLeaf(field, resolve, optResolve, { multiple = false } = {}) {
|
|
298
|
+
const leafType = field.type
|
|
299
|
+
|
|
300
|
+
// `richtext` is NOT a kind — it is the author alias for a ProseMirror document
|
|
301
|
+
// (`json` + `format: prosemirror`), normalized upstream in resolve-data-schema.js,
|
|
302
|
+
// so normalized IR never carries a raw `richtext` kind. The only way one reaches
|
|
303
|
+
// here is a STALE prebuilt schema.json (a foundation built before the 2026-06-02
|
|
304
|
+
// kind retirement and loaded from dist/meta/schema.json without re-resolving).
|
|
305
|
+
// Fail locally — rebuild the foundation — rather than ship a kind the backend
|
|
306
|
+
// rejects. (Routing multi-valued leaves through here closes a hole: the old
|
|
307
|
+
// array branch never consulted this guard, so `richtext` could reach the wire
|
|
308
|
+
// as long as it was a list.)
|
|
309
|
+
if (leafType === 'richtext') {
|
|
205
310
|
throw new Error(
|
|
206
311
|
'This foundation carries the retired `richtext` kind in its built schema — ' +
|
|
207
312
|
'rebuild it (`richtext` is now json + format: prosemirror).'
|
|
208
313
|
)
|
|
209
314
|
}
|
|
210
|
-
const leafType = type
|
|
211
315
|
const leafFormat = field.format
|
|
212
316
|
|
|
213
317
|
const out = { type: leafType }
|
|
318
|
+
if (multiple) out.multiple = true
|
|
214
319
|
if (field.label) out.label = field.label
|
|
215
320
|
if (field.description) out.description = field.description
|
|
216
321
|
if (field.required) out.required = true
|
|
217
322
|
|
|
323
|
+
// A reference to a whole entity, hydrating to the target's brief. It is a
|
|
324
|
+
// FIELD, so it carries field attributes — the same ones `item_ref` below has
|
|
325
|
+
// always carried. Never localized: the text a reader sees belongs to the
|
|
326
|
+
// referenced entity, which localizes on its own.
|
|
327
|
+
if (leafType === 'ref') {
|
|
328
|
+
out.type = 'entity_ref'
|
|
329
|
+
if (field.ref) out.model = resolve(field.ref)
|
|
330
|
+
return out
|
|
331
|
+
}
|
|
332
|
+
|
|
218
333
|
// A curated picklist is an item_ref (machine-ish — never localized).
|
|
219
334
|
if (field.options !== undefined) {
|
|
220
335
|
out.type = 'item_ref'
|
package/src/validate-data.js
CHANGED
|
@@ -6,23 +6,21 @@
|
|
|
6
6
|
* question: *is my data correct according to the schemas I said it should
|
|
7
7
|
* comply with?*
|
|
8
8
|
*
|
|
9
|
-
* Two layers:
|
|
9
|
+
* Two layers, and only the second one lives here:
|
|
10
10
|
* - `validateItem(schema, item)` — pure, facet-driven: walks a normalized
|
|
11
11
|
* schema's declared facets (required / type / enum / format / nested
|
|
12
|
-
* object+array) and emits one finding per failed facet. No I/O.
|
|
12
|
+
* object+array / open map) and emits one finding per failed facet. No I/O.
|
|
13
|
+
* It now lives in `@uniweb/schemas/conform`, beside the vocabulary it must
|
|
14
|
+
* agree with, and is re-exported below so every caller here is unchanged.
|
|
13
15
|
* - `validateDataInputs({ siteRoot, foundationPath })` — the join: pairs each
|
|
14
16
|
* section's data input with the schema its `meta.js` binds to that key,
|
|
15
17
|
* validates each unique (file, schema) pair once, and attributes findings
|
|
16
|
-
* back to the sections that use it.
|
|
18
|
+
* back to the sections that use it. This half needs a disk, so it stays.
|
|
17
19
|
*
|
|
18
20
|
* This is a pre-live dev/CI gate, not a render-time guard. The runtime stays
|
|
19
21
|
* tolerant (apply defaults, ignore the rest); a wrong value is best caught
|
|
20
22
|
* here, before a site is live — so the engine returns findings and the caller
|
|
21
23
|
* decides whether they should fail a build (CI treats them as errors).
|
|
22
|
-
*
|
|
23
|
-
* The type vocabulary (`SCALAR_KINDS` / `FORMAT_TYPES`) is shared with the
|
|
24
|
-
* schema normalizer, so what *normalizes* and what *conforms* speak one
|
|
25
|
-
* definition of each kind.
|
|
26
24
|
*/
|
|
27
25
|
|
|
28
26
|
import { readFile } from 'node:fs/promises'
|
|
@@ -31,218 +29,18 @@ import { join, resolve, basename } from 'node:path'
|
|
|
31
29
|
import yaml from 'js-yaml'
|
|
32
30
|
import { collectionNameFromUrl } from '@uniweb/core'
|
|
33
31
|
|
|
34
|
-
import {
|
|
32
|
+
import { validateItem, isStaticallyCheckable } from '@uniweb/schemas/conform'
|
|
33
|
+
import { validateAndNormalizeSchema } from './resolve-data-schema.js'
|
|
34
|
+
|
|
35
|
+
// The pure checker, re-exported so `@uniweb/build/validate` stays the one import
|
|
36
|
+
// path callers know (`uniweb validate`, the CLI, and the contract tests all
|
|
37
|
+
// reach it here) even though the implementation moved next to the vocabulary.
|
|
38
|
+
export { validateItem, isStaticallyCheckable } from '@uniweb/schemas/conform'
|
|
35
39
|
import { buildSchema } from './schema.js'
|
|
36
40
|
import { resolveFoundationSrcPath } from './utils/foundation-source-root.js'
|
|
37
41
|
import { collectSiteContent } from './site/content-collector.js'
|
|
38
42
|
import { processCollections } from './site/collection-processor.js'
|
|
39
43
|
|
|
40
|
-
// --- the pure validator -----------------------------------------------------
|
|
41
|
-
|
|
42
|
-
/**
|
|
43
|
-
* Validate one data item against a normalized data schema.
|
|
44
|
-
*
|
|
45
|
-
* Operates on the *normalized* schema (canonical kinds + `required` / `enum` /
|
|
46
|
-
* `format` / nested `fields` / `items`) — the shape `dataSchemas[ref]` carries.
|
|
47
|
-
* Facet-driven: each declared facet contributes its own check, so a new facet
|
|
48
|
-
* in the schema model is covered without restructuring this function.
|
|
49
|
-
*
|
|
50
|
-
* Scope: a `fields`-form schema (the locally-testable case). A `sections`-form
|
|
51
|
-
* (rich) schema describes the backend's section/item graph, which a flat file
|
|
52
|
-
* can't reproduce — callers defer those rather than pass them here; given one,
|
|
53
|
-
* this returns `[]`.
|
|
54
|
-
*
|
|
55
|
-
* @param {Object} schema - a normalized data schema (`{ fields }` or `{ sections }`)
|
|
56
|
-
* @param {*} item - the data item to check
|
|
57
|
-
* @returns {Array<{ field: string, rule: string, message: string }>}
|
|
58
|
-
*/
|
|
59
|
-
export function validateItem(schema, item) {
|
|
60
|
-
if (!schema || typeof schema !== 'object') return []
|
|
61
|
-
if (schema.fields) return validateFields(schema.fields, item, '')
|
|
62
|
-
// `sections`-form schemas are deferred upstream (rich model — not reproducible
|
|
63
|
-
// from a flat file); this is a no-op safety net.
|
|
64
|
-
return []
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
/**
|
|
68
|
-
* Whether a normalized schema can be checked statically against a flat file.
|
|
69
|
-
* `fields`-form yes; `sections`-form no (the rich, backend-graph case).
|
|
70
|
-
*/
|
|
71
|
-
export function isStaticallyCheckable(schema) {
|
|
72
|
-
return !!(schema && typeof schema === 'object' && schema.fields)
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
function validateFields(fields, obj, prefix) {
|
|
76
|
-
const out = []
|
|
77
|
-
const record = isPlainObject(obj) ? obj : {}
|
|
78
|
-
for (const [name, rawDef] of Object.entries(fields)) {
|
|
79
|
-
const def = asFieldDef(rawDef)
|
|
80
|
-
const path = prefix ? `${prefix}.${name}` : name
|
|
81
|
-
const has = Object.prototype.hasOwnProperty.call(record, name) && record[name] != null
|
|
82
|
-
|
|
83
|
-
// required — a promised field with no value. Don't flag a merely-absent
|
|
84
|
-
// optional field: the runtime fills it from `default` (or leaves it unset).
|
|
85
|
-
if (def.required === true && !has) {
|
|
86
|
-
out.push(violation(path, 'required', `missing required field '${path}'`))
|
|
87
|
-
continue
|
|
88
|
-
}
|
|
89
|
-
if (!has) continue
|
|
90
|
-
|
|
91
|
-
out.push(...validateValue(def, record[name], path))
|
|
92
|
-
}
|
|
93
|
-
return out
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
function validateValue(def, value, path) {
|
|
97
|
-
const out = []
|
|
98
|
-
const kind = def.type
|
|
99
|
-
|
|
100
|
-
// ref / options — a reference into the entity graph (entity_ref / item_ref).
|
|
101
|
-
// Its target isn't resolvable without the backend, so the value can't be
|
|
102
|
-
// checked statically. `required` already ran in validateFields; presence is
|
|
103
|
-
// all we can assert here.
|
|
104
|
-
if (kind === 'ref' || def.options !== undefined) return out
|
|
105
|
-
|
|
106
|
-
// enum (inline picklist) — the value must be one of the allowed set. Mirrors
|
|
107
|
-
// the runtime, which checks enum membership regardless of the base type, so a
|
|
108
|
-
// wrong-type-and-wrong-value lands as one clear enum finding (not two).
|
|
109
|
-
if (Array.isArray(def.enum)) {
|
|
110
|
-
if (!def.enum.includes(value)) {
|
|
111
|
-
out.push(violation(path, 'enum', `${fmt(value)} is not one of [${def.enum.map(fmt).join(', ')}]`))
|
|
112
|
-
}
|
|
113
|
-
return out
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
if (kind === 'object') {
|
|
117
|
-
if (!isPlainObject(value)) {
|
|
118
|
-
out.push(violation(path, 'type', `expected object, got ${typeName(value)}`))
|
|
119
|
-
} else if (def.fields) {
|
|
120
|
-
out.push(...validateFields(def.fields, value, path))
|
|
121
|
-
} else if (def.values) {
|
|
122
|
-
// An OPEN MAP: the keys are the author's, every value conforms to one
|
|
123
|
-
// shape. `values` is to an object what `items` is to an array.
|
|
124
|
-
//
|
|
125
|
-
// Note what this deliberately does NOT do: reject a key. It cannot — the
|
|
126
|
-
// keys are the whole point — and it must not reject unexpected keys
|
|
127
|
-
// WITHIN a value either, which falls out of `validateFields` walking the
|
|
128
|
-
// schema's fields rather than the data's. That tolerance is load-bearing
|
|
129
|
-
// for `@std/form`: a form definition may carry per-field keys the current
|
|
130
|
-
// builder cannot author (hand-written, or from a newer editor), and the
|
|
131
|
-
// editor's boundary passes them through untouched. A stricter check here
|
|
132
|
-
// would fail builds on good content.
|
|
133
|
-
for (const [key, item] of Object.entries(value)) {
|
|
134
|
-
out.push(...validateValue(def.values, item, `${path}.${key}`))
|
|
135
|
-
}
|
|
136
|
-
}
|
|
137
|
-
return out
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
if (kind === 'array') {
|
|
141
|
-
if (!Array.isArray(value)) {
|
|
142
|
-
out.push(violation(path, 'type', `expected array, got ${typeName(value)}`))
|
|
143
|
-
} else if (def.items !== undefined) {
|
|
144
|
-
const itemDef = asFieldDef(def.items)
|
|
145
|
-
value.forEach((el, i) => out.push(...validateValue(itemDef, el, `${path}[${i}]`)))
|
|
146
|
-
}
|
|
147
|
-
return out
|
|
148
|
-
}
|
|
149
|
-
|
|
150
|
-
// scalar kind
|
|
151
|
-
if (!isKind(kind, value)) {
|
|
152
|
-
out.push(violation(path, 'type', `expected ${kind}, got ${typeName(value)}`))
|
|
153
|
-
return out
|
|
154
|
-
}
|
|
155
|
-
|
|
156
|
-
// format (url / email) — only on present string scalars
|
|
157
|
-
if (typeof value === 'string' && FORMAT_TYPES.has(def.format)) {
|
|
158
|
-
if (def.format === 'email' && !isEmailish(value)) {
|
|
159
|
-
out.push(violation(path, 'format', `${fmt(value)} is not a valid email`))
|
|
160
|
-
} else if (def.format === 'url' && !isUrlish(value)) {
|
|
161
|
-
out.push(violation(path, 'format', `${fmt(value)} is not a valid url`))
|
|
162
|
-
}
|
|
163
|
-
}
|
|
164
|
-
|
|
165
|
-
return out
|
|
166
|
-
}
|
|
167
|
-
|
|
168
|
-
// Scalar kinds this checker knows how to verify. Kept in lockstep with the
|
|
169
|
-
// normalizer's SCALAR_KINDS by the coverage guard at the bottom of this file —
|
|
170
|
-
// adding a kind to the shared vocabulary without teaching the checker throws at
|
|
171
|
-
// module load, rather than silently passing everything via the default branch.
|
|
172
|
-
const KNOWN_SCALAR_KINDS = new Set([
|
|
173
|
-
'string', 'text', 'file',
|
|
174
|
-
'int', 'decimal', 'bool', 'date', 'datetime', 'json',
|
|
175
|
-
])
|
|
176
|
-
|
|
177
|
-
/**
|
|
178
|
-
* Does a value match a canonical scalar kind?
|
|
179
|
-
*/
|
|
180
|
-
function isKind(kind, value) {
|
|
181
|
-
switch (kind) {
|
|
182
|
-
case 'string':
|
|
183
|
-
case 'text':
|
|
184
|
-
case 'file':
|
|
185
|
-
return typeof value === 'string'
|
|
186
|
-
case 'int':
|
|
187
|
-
return typeof value === 'number' && Number.isInteger(value)
|
|
188
|
-
case 'decimal':
|
|
189
|
-
return typeof value === 'number' && Number.isFinite(value)
|
|
190
|
-
case 'bool':
|
|
191
|
-
return typeof value === 'boolean'
|
|
192
|
-
case 'date':
|
|
193
|
-
case 'datetime':
|
|
194
|
-
// YAML parses bare dates to Date objects; JSON carries them as strings.
|
|
195
|
-
return typeof value === 'string' || value instanceof Date
|
|
196
|
-
case 'json':
|
|
197
|
-
return true // structured / untyped — no scalar constraint
|
|
198
|
-
default:
|
|
199
|
-
return true // unknown kind → forward-compatible, not a violation
|
|
200
|
-
}
|
|
201
|
-
}
|
|
202
|
-
|
|
203
|
-
// Lenient format checks — strict enough to catch garbage, loose enough not to
|
|
204
|
-
// flag the shapes authors legitimately write (bare domains, root-relative
|
|
205
|
-
// paths). The north star is no false positives.
|
|
206
|
-
function isEmailish(v) {
|
|
207
|
-
return /^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(v.trim())
|
|
208
|
-
}
|
|
209
|
-
function isUrlish(v) {
|
|
210
|
-
const s = v.trim()
|
|
211
|
-
if (!s || /\s/.test(s)) return false
|
|
212
|
-
if (/^[a-z][a-z0-9+.-]*:\/\//i.test(s)) return true // scheme://
|
|
213
|
-
if (s.startsWith('//') || s.startsWith('/') || s.startsWith('./') || s.startsWith('../')) return true
|
|
214
|
-
if (/^[\w-]+(\.[\w-]+)+/.test(s)) return true // bare domain (example.com, sub.site.io/x)
|
|
215
|
-
return false
|
|
216
|
-
}
|
|
217
|
-
|
|
218
|
-
function violation(field, rule, message) {
|
|
219
|
-
return { field, rule, message }
|
|
220
|
-
}
|
|
221
|
-
|
|
222
|
-
function asFieldDef(def) {
|
|
223
|
-
// Normalized schemas always carry objects, but tolerate a bare type string
|
|
224
|
-
// (the authoring shorthand) so callers can validate against either form.
|
|
225
|
-
if (typeof def === 'string') return { type: def }
|
|
226
|
-
return def && typeof def === 'object' ? def : { type: undefined }
|
|
227
|
-
}
|
|
228
|
-
|
|
229
|
-
function isPlainObject(v) {
|
|
230
|
-
return v !== null && typeof v === 'object' && !Array.isArray(v) && !(v instanceof Date)
|
|
231
|
-
}
|
|
232
|
-
|
|
233
|
-
function typeName(v) {
|
|
234
|
-
if (v === null) return 'null'
|
|
235
|
-
if (Array.isArray(v)) return 'array'
|
|
236
|
-
if (v instanceof Date) return 'date'
|
|
237
|
-
return typeof v
|
|
238
|
-
}
|
|
239
|
-
|
|
240
|
-
function fmt(v) {
|
|
241
|
-
if (typeof v === 'string') return `"${v}"`
|
|
242
|
-
if (v instanceof Date) return v.toISOString()
|
|
243
|
-
return String(v)
|
|
244
|
-
}
|
|
245
|
-
|
|
246
44
|
// --- the join: sections ↔ schemas -------------------------------------------
|
|
247
45
|
|
|
248
46
|
/**
|
|
@@ -631,14 +429,3 @@ function itemLabel(item, idx) {
|
|
|
631
429
|
}
|
|
632
430
|
return String(idx)
|
|
633
431
|
}
|
|
634
|
-
|
|
635
|
-
// Coverage guard — see KNOWN_SCALAR_KINDS. Every scalar kind the normalizer can
|
|
636
|
-
// emit must be one this checker handles, so the two never drift apart silently.
|
|
637
|
-
for (const kind of SCALAR_KINDS) {
|
|
638
|
-
if (!KNOWN_SCALAR_KINDS.has(kind)) {
|
|
639
|
-
throw new Error(
|
|
640
|
-
`validate-data: scalar kind '${kind}' is in the schema vocabulary but has ` +
|
|
641
|
-
'no conformance predicate. Add a case to isKind() and KNOWN_SCALAR_KINDS.'
|
|
642
|
-
)
|
|
643
|
-
}
|
|
644
|
-
}
|
|
@@ -466,6 +466,22 @@ async function buildEntrySSR(foundationRoot, entrySourcePath, outDir) {
|
|
|
466
466
|
}
|
|
467
467
|
}
|
|
468
468
|
|
|
469
|
+
/**
|
|
470
|
+
* Marks a foundation build as a DEV-server rebuild.
|
|
471
|
+
*
|
|
472
|
+
* The dev server runs a real Vite `build()` of the foundation on every watched
|
|
473
|
+
* change (`dev/plugin.js`), so `command`, `mode` and `isProduction` cannot tell
|
|
474
|
+
* a dev rebuild from a shipping build — all of them say "build". An injected
|
|
475
|
+
* marker plugin can, and it survives the foundation's own `vite.config.js`
|
|
476
|
+
* being merged in, which a plugin *option* would not (the dev server does not
|
|
477
|
+
* construct the foundation plugin — the foundation's config file does).
|
|
478
|
+
*
|
|
479
|
+
* Used to skip the `entry-ssr.js` sub-build in dev: it is a second full Vite
|
|
480
|
+
* pass per save, and nothing in the dev loop reads it (dev never ships, and the
|
|
481
|
+
* SSR lanes that run locally — SSG prerender and unipress — load `entry.js`).
|
|
482
|
+
*/
|
|
483
|
+
export const DEV_REBUILD_MARKER = 'uniweb:dev-foundation-rebuild'
|
|
484
|
+
|
|
469
485
|
/**
|
|
470
486
|
* Vite plugin for foundation builds
|
|
471
487
|
*/
|
|
@@ -481,6 +497,7 @@ export function foundationBuildPlugin(options = {}) {
|
|
|
481
497
|
let resolvedOutDir
|
|
482
498
|
let resolvedRoot
|
|
483
499
|
let isProduction
|
|
500
|
+
let isDevRebuild = false
|
|
484
501
|
|
|
485
502
|
return {
|
|
486
503
|
name: 'uniweb-foundation-build',
|
|
@@ -500,6 +517,7 @@ export function foundationBuildPlugin(options = {}) {
|
|
|
500
517
|
resolvedOutDir = config.build.outDir
|
|
501
518
|
resolvedRoot = config.root
|
|
502
519
|
isProduction = config.mode === 'production'
|
|
520
|
+
isDevRebuild = (config.plugins || []).some((p) => p?.name === DEV_REBUILD_MARKER)
|
|
503
521
|
},
|
|
504
522
|
|
|
505
523
|
async writeBundle() {
|
|
@@ -554,8 +572,20 @@ export function foundationBuildPlugin(options = {}) {
|
|
|
554
572
|
//
|
|
555
573
|
// (The legacy self-contained buildSSRBundle() — React + runtime INLINED,
|
|
556
574
|
// ~14 MB with Shiki — is retained below, unused, for reference only.)
|
|
557
|
-
|
|
558
|
-
|
|
575
|
+
// Skipped on a DEV rebuild: this is a second full Vite pass, it runs on
|
|
576
|
+
// every save, and nothing in the dev loop reads its output. The local SSR
|
|
577
|
+
// lanes load `entry.js` (SSG prerender via `import()`, unipress the same);
|
|
578
|
+
// only the edge isolate needs the single-file twin, and dev ships nothing
|
|
579
|
+
// to it.
|
|
580
|
+
//
|
|
581
|
+
// Shipping lanes are unaffected — `uniweb build`, and `register`/`publish`
|
|
582
|
+
// which build through it. `register`'s build-if-stale check also requires
|
|
583
|
+
// `entry-ssr.js`, so a dist left behind by a dev session is treated as
|
|
584
|
+
// stale and rebuilt rather than uploaded without one.
|
|
585
|
+
if (!isDevRebuild) {
|
|
586
|
+
const entrySourcePath = join(resolvedSrcDir, entryFileName)
|
|
587
|
+
await buildEntrySSR(resolvedRoot, entrySourcePath, outDir)
|
|
588
|
+
}
|
|
559
589
|
},
|
|
560
590
|
|
|
561
591
|
async closeBundle() {
|