@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@uniweb/build",
3
- "version": "0.16.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.5",
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 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) {
@@ -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
- fields[childName] = { type: 'section', ...lowerSection(childDef, resolve, optResolve) }
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
- if (Object.keys(fields).length) out.fields = fields
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. Leaves carry their kind + attributes;
163
- // structural kinds become sections or multi-valued leaves:
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
- function lowerField(rawField, resolve, optResolve) {
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
- return { type: 'section', ...lowerSection({ kind: 'single', fields: field.fields }, resolve, optResolve) }
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 { type: 'section', ...lowerSection({ kind: 'multi', fields: items.fields }, resolve, optResolve) }
180
- }
181
- if (items && items.type === 'ref') {
182
- // Multi-valued reference — the per-field `multiple` flag (the `array` Kind
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
- // Array of scalars a multi-valued leaf.
189
- return { type: items ? items.type : 'string', multiple: true }
190
- }
191
- if (type === 'ref') {
192
- const out = { type: 'entity_ref' }
193
- if (field.ref) out.model = resolve(field.ref)
194
- return out
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
- // A leaf (scalar) kind. `richtext` is NOT a kind — it is the author alias for a
198
- // ProseMirror document (`json` + `format: prosemirror`), normalized upstream in
199
- // resolve-data-schema.js, so normalized IR never carries a raw `richtext` kind. The
200
- // only way one could reach here is a STALE prebuilt schema.json (a foundation built
201
- // before the 2026-06-02 kind retirement and loaded from dist/meta/schema.json without
202
- // re-resolving). Fail locally rebuild the foundation rather than ship a kind the
203
- // backend rejects.
204
- if (type === 'richtext') {
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'
@@ -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
- }
@@ -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
- const entrySourcePath = join(resolvedSrcDir, entryFileName)
558
- await buildEntrySSR(resolvedRoot, entrySourcePath, outDir)
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() {