@uniweb/build 0.16.17 → 0.16.19

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.17",
3
+ "version": "0.16.19",
4
4
  "description": "Build tooling for the Uniweb Component Web Platform",
5
5
  "type": "module",
6
6
  "exports": {
@@ -59,15 +59,16 @@
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
63
  "@uniweb/theming": "0.1.15",
63
- "@uniweb/projections": "0.2.5",
64
- "@uniweb/content-writer": "0.3.3"
64
+ "@uniweb/content-writer": "0.3.3",
65
+ "@uniweb/projections": "0.2.5"
65
66
  },
66
67
  "optionalDependencies": {
67
- "@uniweb/content-reader": "1.2.2",
68
+ "@uniweb/schemas": "0.2.7",
69
+ "@uniweb/runtime": "0.9.8",
68
70
  "@uniweb/semantic-parser": "1.2.1",
69
- "@uniweb/schemas": "0.2.5",
70
- "@uniweb/runtime": "0.9.7"
71
+ "@uniweb/content-reader": "1.2.2"
71
72
  },
72
73
  "peerDependencies": {
73
74
  "vite": "^5.0.0 || ^6.0.0 || ^7.0.0",
@@ -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 and its canonical type vocabulary are documented in
29
- * `data-schema-format.md`. This module validates that format and normalizes the
30
- * friendly type aliases to the canonical kinds. Normalization is the only
31
- * transformation it performs it does not lower the structure to any storage
32
- * model.
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
- // Extensions a foundation-local schema file may use, in resolution order.
43
- const SCHEMA_EXTENSIONS = ['.js', '.json', '.yml', '.yaml']
44
-
45
- // The authoring type vocabulary. Scalars + structural; aliases fold in below.
46
- // Exported so a conformance checker can speak the same definition of each kind
47
- // that normalization produces — "normalizes" and "conforms" stay in lockstep.
48
- export const SCALAR_KINDS = new Set([
49
- 'string', 'text', 'int', 'decimal', 'bool',
50
- 'date', 'datetime', 'file', 'json',
51
- ])
52
- export const STRUCTURAL_KINDS = new Set(['object', 'array', 'ref'])
53
- // Friendly aliases → canonical kind.
54
- const TYPE_ALIASES = {
55
- number: 'decimal',
56
- integer: 'int',
57
- boolean: 'bool',
58
- image: 'file',
59
- }
60
- // Friendly type aliases that lower to a base kind + a carried `format` marker.
61
- // `url`/`email` → `string` (server-validated value subtypes). `markdown`/`html` →
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) {
@@ -0,0 +1,129 @@
1
+ /**
2
+ * Emit a workspace extension's built code into the site's own output.
3
+ *
4
+ * ── The gap this closes ──
5
+ *
6
+ * A site declares an extension by URL. The site-relative form —
7
+ * `extensions: ['/effects/entry.js']` — means "served from this site's own
8
+ * origin", and it is what the `extensions` template ships. But nothing ever put
9
+ * the file there.
10
+ *
11
+ * The result was a build that succeeds and a site that is wrong: prerender
12
+ * loads the extension from the workspace (via `resolveExtensionPath`) and
13
+ * renders its sections into the static HTML, then the browser fetches
14
+ * `/effects/entry.js`, gets a 404, `loadExtensions()` drops it, and hydration
15
+ * REPLACES the correct markup with `Component not found`. A visitor watches a
16
+ * working section break. Measured on the `extensions` template, 2026-08-05.
17
+ *
18
+ * ── Why here ──
19
+ *
20
+ * The build is the only party that knows both the declared URL and where the
21
+ * extension's `dist/` actually is, and the site's output is the only place the
22
+ * two can meet. This is the emission half of "site-hosted linked" — the shape
23
+ * the model doc lists as producible only by hand.
24
+ *
25
+ * ── What is emitted, and what is not ──
26
+ *
27
+ * The BROWSER delivery set. A foundation's `dist/` also carries things only
28
+ * other consumers want, and a static host should not serve them:
29
+ *
30
+ * entry.js, assets/** → emitted; the browser loads these
31
+ * entry-ssr.js → skipped; the single-file SSR twin, for an
32
+ * isolate that loads one module. Nothing on a
33
+ * static host reads it.
34
+ * meta/** → skipped; the editor schema. Authoring-time, and
35
+ * not something to publish to visitors.
36
+ * runtime-pin.json → skipped; build provenance, read by no browser.
37
+ * *.map → skipped; dev-only.
38
+ *
39
+ * Same browser/internal split the runtime's distribution channel draws, for the
40
+ * same reason: what a visitor fetches and what a renderer needs are different
41
+ * sets, and only one of them belongs on a public origin.
42
+ */
43
+
44
+ import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs'
45
+ import { join, relative, resolve } from 'node:path'
46
+
47
+ /** True for the `/effects/entry.js` form — the only one this site can serve. */
48
+ export function isSiteRelative(decl) {
49
+ const url = typeof decl === 'string' ? decl : decl?.url
50
+ return typeof url === 'string' && url.startsWith('/') && !url.startsWith('//')
51
+ }
52
+
53
+ /** Every file under `dir`, relative to it. */
54
+ function walk(dir, base = dir) {
55
+ if (!existsSync(dir)) return []
56
+ return readdirSync(dir).flatMap((entry) => {
57
+ const full = join(dir, entry)
58
+ return statSync(full).isDirectory() ? walk(full, base) : [relative(base, full)]
59
+ })
60
+ }
61
+
62
+ /** Is this file part of what a browser fetches? */
63
+ function isBrowserAsset(rel) {
64
+ if (rel.endsWith('.map')) return false
65
+ if (rel === 'runtime-pin.json') return false
66
+ if (rel.startsWith('meta/') || rel.startsWith(`meta\\`)) return false
67
+ if (/(^|[/\\])entry-ssr\.js$/.test(rel)) return false
68
+ return true
69
+ }
70
+
71
+ /**
72
+ * Locate the built `dist/` behind a site-relative extension URL.
73
+ *
74
+ * The same candidates `resolveExtensionPath` walks for prerender, kept in step
75
+ * deliberately: if prerender can load an extension from the workspace but the
76
+ * build cannot find it to emit, that is exactly the split that produced the
77
+ * bug — one lane resolving it and the other not.
78
+ *
79
+ * @returns {{ distDir: string, urlBase: string }|null}
80
+ */
81
+ export function resolveExtensionDist(url, siteDir) {
82
+ const parts = url.replace(/^\//, '').split('/')
83
+ if (parts.length < 2) return null
84
+ const pkgName = parts[0]
85
+ const projectRoot = resolve(siteDir, '..')
86
+
87
+ for (const candidate of [
88
+ join(projectRoot, pkgName, 'dist'),
89
+ join(projectRoot, 'extensions', pkgName, 'dist')
90
+ ]) {
91
+ if (existsSync(candidate)) return { distDir: candidate, urlBase: pkgName }
92
+ }
93
+ return null
94
+ }
95
+
96
+ /**
97
+ * Files to emit for a site's declared extensions.
98
+ *
99
+ * Returns `{ fileName, source }` pairs for Rollup's `emitFile`, plus the
100
+ * declarations that could not be resolved — the caller warns about those rather
101
+ * than failing, because an absolute-URL extension is legitimately not ours to
102
+ * emit and a missing workspace build is a warning the developer can act on.
103
+ *
104
+ * @param {Array} extensions - `site.yml::extensions`, as declared.
105
+ * @param {string} siteDir - the site package directory.
106
+ */
107
+ export function collectExtensionAssets(extensions, siteDir) {
108
+ const emit = []
109
+ const unresolved = []
110
+ if (!Array.isArray(extensions)) return { emit, unresolved }
111
+
112
+ for (const decl of extensions) {
113
+ if (!isSiteRelative(decl)) continue // absolute URL or a ref — someone else serves it
114
+ const url = typeof decl === 'string' ? decl : decl.url
115
+ const found = resolveExtensionDist(url, siteDir)
116
+ if (!found) {
117
+ unresolved.push(url)
118
+ continue
119
+ }
120
+ for (const rel of walk(found.distDir)) {
121
+ if (!isBrowserAsset(rel)) continue
122
+ emit.push({
123
+ fileName: `${found.urlBase}/${rel.split('\\').join('/')}`,
124
+ source: readFileSync(join(found.distDir, rel))
125
+ })
126
+ }
127
+ }
128
+ return { emit, unresolved }
129
+ }
@@ -52,6 +52,7 @@ import { processCollections, writeCollectionFiles } from './collection-processor
52
52
  import { executeFetch, mergeDataIntoContent } from './data-fetcher.js'
53
53
  import { shouldSplitContent } from './split-content.js'
54
54
  import { FONT_LINKS_MARKER } from './head-markers.js'
55
+ import { collectExtensionAssets } from './emit-extensions.js'
55
56
 
56
57
  // BCP 47 locale code pattern: en, zh-CN, zh-Hant, pt-BR, fr-CA, sr-Latn, etc.
57
58
  const LOCALE_RE = '[a-z]{2,3}(?:-[A-Za-z]{2,4})?'
@@ -1464,6 +1465,25 @@ export function siteContentPlugin(options = {}) {
1464
1465
  // markdown (retrieval). Free and on by default; a site opts out under
1465
1466
  // `agents:` in site.yml.
1466
1467
  emitProjections.call(this, finalContent)
1468
+
1469
+ // A site-relative extension (`/effects/entry.js`) is served from the
1470
+ // site's OWN origin, so the site's build is what has to put it there.
1471
+ // Without this the build succeeds, prerender renders the extension's
1472
+ // sections from the workspace, and the browser then 404s and replaces
1473
+ // them with `Component not found` on hydration.
1474
+ const { emit, unresolved } = collectExtensionAssets(
1475
+ finalContent.config?.extensions,
1476
+ resolve(sitePath)
1477
+ )
1478
+ for (const asset of emit) {
1479
+ this.emitFile({ type: 'asset', fileName: asset.fileName, source: asset.source })
1480
+ }
1481
+ for (const url of unresolved) {
1482
+ this.warn(
1483
+ `Extension '${url}' is site-relative but no built extension was found for it. ` +
1484
+ `The site will 404 on it at runtime — build the extension, or reference it by URL.`
1485
+ )
1486
+ }
1467
1487
  },
1468
1488
 
1469
1489
  closeBundle() {
@@ -150,6 +150,15 @@ 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
+ // 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.
160
+ if (def.label) out.label = def.label
161
+ if (def.description) out.description = def.description
153
162
  if (def.nestable) out.self_nesting = true
154
163
  if (def.append_only) out.append_only = true
155
164
 
@@ -182,13 +191,32 @@ function lowerSection(def, resolve, optResolve, path = '') {
182
191
  return out
183
192
  }
184
193
 
185
- // Lower one field to its declaration value. Leaves carry their kind + attributes;
186
- // structural kinds become sections or multi-valued leaves:
187
- // object → a single nested section
194
+ // Lower one field to its declaration value:
195
+ // object → a single nested section
188
196
  // array of object → a multi nested section
189
- // array of ref → entity_ref + multiple
190
- // array of scalar → the scalar kind + multiple
191
- // ref → entity_ref (model by name)
197
+ // array of ref → entity_ref + multiple
198
+ // array of scalar → the scalar kind + multiple ├─ leaf-shaped: lowerLeaf
199
+ // ref → entity_ref (model by name)
200
+ // scalar → the kind + its attributes ┘
201
+ //
202
+ // Every LEAF-SHAPED output goes through `lowerLeaf`, which is the whole point of
203
+ // the split: the field attributes (`label` / `description` / `required`, plus
204
+ // `enum` / `format` / `localized`) are emitted in exactly one place, so a kind
205
+ // cannot quietly miss them. They used to be emitted inline at the end of this
206
+ // function, which each structural branch returned before reaching — so a
207
+ // multi-valued leaf reached the wire as a bare `{ type, multiple }`, losing its
208
+ // closed set, its format validation and its localization, and an `entity_ref`
209
+ // lost its label/description/required while `item_ref` (which fell through)
210
+ // kept them. That asymmetry between the two reference kinds is what gave the
211
+ // bug away: nothing had decided it, the control flow had.
212
+ //
213
+ // SECTION-shaped outputs (`object`, `array of object`) still carry none of
214
+ // those attributes. That is NOT settled — a section body's documented shape is
215
+ // `multiple` / `brief` / `self_nesting` / `append_only` / `constraints` /
216
+ // `fields`, so whether the wire accepts `label` / `description` / `required` on
217
+ // a section is the consumer's contract to state, not ours to assume. Until it
218
+ // does, an authored `required: true` on a nested object or a list of records is
219
+ // still dropped here. `@std/publication`'s `authors` is exactly that case.
192
220
  function lowerField(rawField, resolve, optResolve, path = '') {
193
221
  const field = asField(rawField)
194
222
  const type = field.type
@@ -224,6 +252,7 @@ function lowerField(rawField, resolve, optResolve, path = '') {
224
252
  ...lowerSection(
225
253
  {
226
254
  kind: 'multi',
255
+ ...sectionProse(field),
227
256
  // `translatable: false` is load-bearing, not tidiness: a string field is
228
257
  // localized by default, and a localized key could differ per locale —
229
258
  // which would destroy the identity the key exists to carry. The key is an
@@ -242,7 +271,7 @@ function lowerField(rawField, resolve, optResolve, path = '') {
242
271
  }
243
272
  return {
244
273
  type: 'section',
245
- ...lowerSection({ kind: 'single', fields: field.fields }, resolve, optResolve, path)
274
+ ...lowerSection({ kind: 'single', ...sectionProse(field), fields: field.fields }, resolve, optResolve, path)
246
275
  }
247
276
  }
248
277
  if (type === 'array') {
@@ -250,46 +279,67 @@ function lowerField(rawField, resolve, optResolve, path = '') {
250
279
  if (items && items.type === 'object') {
251
280
  return {
252
281
  type: 'section',
253
- ...lowerSection({ kind: 'multi', fields: items.fields }, resolve, optResolve, path)
282
+ ...lowerSection({ kind: 'multi', ...sectionProse(field), fields: items.fields }, resolve, optResolve, path)
254
283
  }
255
284
  }
256
- if (items && items.type === 'ref') {
257
- // Multi-valued referencethe per-field `multiple` flag (the `array` Kind
258
- // that once forced a child multi section is retired).
259
- const out = { type: 'entity_ref', multiple: true }
260
- if (items.ref) out.model = resolve(items.ref)
261
- return out
262
- }
263
- // Array of scalars a multi-valued leaf.
264
- return { type: items ? items.type : 'string', multiple: true }
265
- }
266
- if (type === 'ref') {
267
- const out = { type: 'entity_ref' }
268
- if (field.ref) out.model = resolve(field.ref)
269
- return out
285
+ // A multi-valued LEAF or REFERENCE. `normalizeField` split this field in two
286
+ // when it expanded `many: true` collection-level metadata (`required`,
287
+ // `label`, `description`, `translatable`) stayed on the array, the
288
+ // type-bearing attributes (`type`, `ref`, `options`, `enum`, `format`) moved
289
+ // to `items` so rejoin the halves and lower them as one leaf carrying
290
+ // `multiple: true`. Reading only `items.type` here is what silently dropped
291
+ // both halves' attributes; the rejoin is the exact inverse of the split.
292
+ const { items: _items, ...collection } = field
293
+ return lowerLeaf(
294
+ { ...collection, ...items, type: items ? items.type : 'string' },
295
+ resolve,
296
+ optResolve,
297
+ { multiple: true }
298
+ )
270
299
  }
271
300
 
272
- // A leaf (scalar) kind. `richtext` is NOT a kind — it is the author alias for a
273
- // ProseMirror document (`json` + `format: prosemirror`), normalized upstream in
274
- // resolve-data-schema.js, so normalized IR never carries a raw `richtext` kind. The
275
- // only way one could reach here is a STALE prebuilt schema.json (a foundation built
276
- // before the 2026-06-02 kind retirement and loaded from dist/meta/schema.json without
277
- // re-resolving). Fail locally rebuild the foundation rather than ship a kind the
278
- // backend rejects.
279
- if (type === 'richtext') {
301
+ return lowerLeaf(field, resolve, optResolve)
302
+ }
303
+
304
+ // Lower a LEAF-SHAPED field a scalar, a reference (`entity_ref`), or a curated
305
+ // picklist (`item_ref`) with or without `multiple`. The single place field
306
+ // attributes are emitted, so every leaf-shaped kind carries the same set.
307
+ function lowerLeaf(field, resolve, optResolve, { multiple = false } = {}) {
308
+ const leafType = field.type
309
+
310
+ // `richtext` is NOT a kind — it is the author alias for a ProseMirror document
311
+ // (`json` + `format: prosemirror`), normalized upstream in resolve-data-schema.js,
312
+ // so normalized IR never carries a raw `richtext` kind. The only way one reaches
313
+ // here is a STALE prebuilt schema.json (a foundation built before the 2026-06-02
314
+ // kind retirement and loaded from dist/meta/schema.json without re-resolving).
315
+ // Fail locally — rebuild the foundation — rather than ship a kind the backend
316
+ // rejects. (Routing multi-valued leaves through here closes a hole: the old
317
+ // array branch never consulted this guard, so `richtext` could reach the wire
318
+ // as long as it was a list.)
319
+ if (leafType === 'richtext') {
280
320
  throw new Error(
281
321
  'This foundation carries the retired `richtext` kind in its built schema — ' +
282
322
  'rebuild it (`richtext` is now json + format: prosemirror).'
283
323
  )
284
324
  }
285
- const leafType = type
286
325
  const leafFormat = field.format
287
326
 
288
327
  const out = { type: leafType }
328
+ if (multiple) out.multiple = true
289
329
  if (field.label) out.label = field.label
290
330
  if (field.description) out.description = field.description
291
331
  if (field.required) out.required = true
292
332
 
333
+ // A reference to a whole entity, hydrating to the target's brief. It is a
334
+ // FIELD, so it carries field attributes — the same ones `item_ref` below has
335
+ // always carried. Never localized: the text a reader sees belongs to the
336
+ // referenced entity, which localizes on its own.
337
+ if (leafType === 'ref') {
338
+ out.type = 'entity_ref'
339
+ if (field.ref) out.model = resolve(field.ref)
340
+ return out
341
+ }
342
+
293
343
  // A curated picklist is an item_ref (machine-ish — never localized).
294
344
  if (field.options !== undefined) {
295
345
  out.type = 'item_ref'
@@ -328,6 +378,18 @@ function asField(def) {
328
378
  return typeof def === 'string' ? { type: def } : (def && typeof def === 'object' ? def : {})
329
379
  }
330
380
 
381
+ // 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) {
387
+ const out = {}
388
+ if (field.label) out.label = field.label
389
+ if (field.description) out.description = field.description
390
+ return out
391
+ }
392
+
331
393
  function shortName(name) {
332
394
  return String(name).split('/').pop()
333
395
  }
@@ -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 { SCALAR_KINDS, FORMAT_TYPES, validateAndNormalizeSchema } from './resolve-data-schema.js'
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
- }