@uniweb/build 0.14.19 → 0.14.21

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.14.19",
3
+ "version": "0.14.21",
4
4
  "description": "Build tooling for the Uniweb Component Web Platform",
5
5
  "type": "module",
6
6
  "exports": {
@@ -65,7 +65,7 @@
65
65
  "optionalDependencies": {
66
66
  "@uniweb/runtime": "0.8.20",
67
67
  "@uniweb/content-reader": "1.1.12",
68
- "@uniweb/schemas": "0.2.3"
68
+ "@uniweb/schemas": "0.2.4"
69
69
  },
70
70
  "peerDependencies": {
71
71
  "vite": "^5.0.0 || ^6.0.0 || ^7.0.0",
@@ -57,21 +57,21 @@ const TYPE_ALIASES = {
57
57
  }
58
58
  // Friendly type aliases that lower to a base kind + a carried `format` marker.
59
59
  // `url`/`email` → `string` (server-validated value subtypes). `markdown`/`html` →
60
- // `text` (the file-based rich-content body — round-trips as the raw source string,
61
- // what the retired `richtext` kind used to be: 2026-06-02 / uwx-format.md).
60
+ // `text` (a file-based rich-content body — round-trips as the raw source string).
62
61
  const FORMAT_TYPE_ALIASES = {
63
62
  url: { type: 'string', format: 'url' },
64
63
  email: { type: 'string', format: 'email' },
65
64
  markdown: { type: 'text', format: 'markdown' },
66
65
  html: { type: 'text', format: 'html' },
67
- // Deprecated back-compat alias: `richtext` was a kind before 2026-06-02; it is
68
- // now just sugar for the canonical `text` + `format: markdown`. Not a kind, and
69
- // intentionally left out of the advertised set below.
70
- richtext: { type: 'text', format: 'markdown' },
66
+ // `richtext` a ProseMirror rich document (`json` + `format: prosemirror`): the
67
+ // framework's standard way to represent rich text the structured, lossless form
68
+ // the visual app edits (text, media, tables, code, data blocks, icons, and inline
69
+ // components). Synced to file mode as enhanced markdown via content-writer. Contrast
70
+ // `markdown`/`html`, which are source-string bodies (raw text, no structured editor).
71
+ richtext: { type: 'json', format: 'prosemirror' },
71
72
  }
72
73
  // The advertised format-aliasing type words (drives the "Known types" hint).
73
- // `richtext` is accepted (above) but omitted here — no longer recommended.
74
- export const FORMAT_TYPES = new Set(['url', 'email', 'markdown', 'html'])
74
+ export const FORMAT_TYPES = new Set(['url', 'email', 'markdown', 'html', 'richtext'])
75
75
  export const SECTION_KINDS = new Set(['single', 'multi', 'binder'])
76
76
 
77
77
  // Scope → schema package resolution. The shared standard schemas are referenced
@@ -324,7 +324,7 @@ export function validateAndNormalizeSchema(schema, ref) {
324
324
  }
325
325
 
326
326
  const out = {}
327
- for (const k of ['name', 'version', 'description', 'sortDate']) {
327
+ for (const k of ['name', 'version', 'description', 'sort_date', 'sortDate']) {
328
328
  if (schema[k] !== undefined) out[k] = schema[k]
329
329
  }
330
330
 
@@ -361,7 +361,18 @@ function normalizeSection(section, ref, path, briefState) {
361
361
  if (!section || typeof section !== 'object' || Array.isArray(section)) {
362
362
  throw new Error(`Data schema '${ref}': section '${path}' must be an object.`)
363
363
  }
364
- const kind = section.kind ?? 'single'
364
+ if (section.many !== undefined && typeof section.many !== 'boolean') {
365
+ throw new Error(`Data schema '${ref}': section '${path}' 'many' must be a boolean.`)
366
+ }
367
+ // Cardinality. Friendly sugar: `many: true` → a list of records; a section with
368
+ // only child `sections:` (no `fields:`) is a binder — inferred, never written.
369
+ // Explicit `kind:` is still honored (the lower-level form it normalizes to).
370
+ let kind = section.kind
371
+ if (kind === undefined) {
372
+ if (section.many === true) kind = 'multi'
373
+ else if (section.fields === undefined && section.sections !== undefined) kind = 'binder'
374
+ else kind = 'single'
375
+ }
365
376
  if (!SECTION_KINDS.has(kind)) {
366
377
  throw new Error(`Data schema '${ref}': section '${path}' has invalid kind '${kind}' (expected single | multi | binder).`)
367
378
  }
@@ -369,7 +380,7 @@ function normalizeSection(section, ref, path, briefState) {
369
380
 
370
381
  if (section.brief === true) {
371
382
  if (kind !== 'single') {
372
- throw new Error(`Data schema '${ref}': brief section '${path}' must be kind 'single', not '${kind}'.`)
383
+ throw new Error(`Data schema '${ref}': brief section '${path}' must be a single record (drop 'many').`)
373
384
  }
374
385
  if (++briefState.count > 1) {
375
386
  throw new Error(`Data schema '${ref}': more than one section marked 'brief: true' (at most one).`)
@@ -395,18 +406,19 @@ function normalizeSection(section, ref, path, briefState) {
395
406
  }
396
407
  if (section.constraints !== undefined) out.constraints = section.constraints
397
408
 
398
- // `nestable` a self-nesting multi whose items form a tree among themselves.
399
- // Carried into the IR so the submission lowering can map it to the model's
400
- // `self_nesting`. The item parent/child link is internal to the backend
401
- // (`parent_item_id`); no explicit field expresses it.
402
- if (section.nestable !== undefined) {
403
- if (typeof section.nestable !== 'boolean') {
404
- throw new Error(`Data schema '${ref}': section '${path}' 'nestable' must be a boolean.`)
409
+ // `tree: true` (friendly) / `nestable: true` (lower-level) a list section whose
410
+ // records form a tree among themselves. Carried into the IR so the lowering maps
411
+ // it to the model's `self_nesting`. The parent/child link is internal to the
412
+ // backend (`parent_item_id`); no explicit field expresses it.
413
+ const treeFlag = section.tree ?? section.nestable
414
+ if (treeFlag !== undefined) {
415
+ if (typeof treeFlag !== 'boolean') {
416
+ throw new Error(`Data schema '${ref}': section '${path}' 'tree' must be a boolean.`)
405
417
  }
406
- if (section.nestable && kind !== 'multi') {
407
- throw new Error(`Data schema '${ref}': section '${path}' is 'nestable: true' but kind '${kind}' — only a 'multi' section can self-nest.`)
418
+ if (treeFlag && kind !== 'multi') {
419
+ throw new Error(`Data schema '${ref}': section '${path}' is 'tree: true' but not a list — only a 'many: true' section can form a tree.`)
408
420
  }
409
- if (section.nestable) out.nestable = true
421
+ if (treeFlag) out.nestable = true
410
422
  }
411
423
 
412
424
  // `append_only` — a multi whose records are insert-only: the backend accepts
@@ -419,7 +431,7 @@ function normalizeSection(section, ref, path, briefState) {
419
431
  throw new Error(`Data schema '${ref}': section '${path}' 'append_only' must be a boolean.`)
420
432
  }
421
433
  if (section.append_only && kind !== 'multi') {
422
- throw new Error(`Data schema '${ref}': section '${path}' is 'append_only: true' but kind '${kind}' — only a 'multi' section can be append-only.`)
434
+ 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.`)
423
435
  }
424
436
  if (section.append_only) out.append_only = true
425
437
  }
@@ -444,6 +456,40 @@ function normalizeField(field, ref, path) {
444
456
  if (!field || typeof field !== 'object' || Array.isArray(field)) {
445
457
  throw new Error(`Data schema '${ref}': field '${path}' must be an object or a type string.`)
446
458
  }
459
+
460
+ // Sugar: `many: true` → a list. Wrap the field-minus-`many` as the array's item
461
+ // type (lowers to the canonical `multiple`). The common cases —
462
+ // `{ ref: '@/x', many: true }`, `{ type: string, many: true }` — read as "a list
463
+ // of X" with no `array`/`items` ceremony.
464
+ if (field.many !== undefined) {
465
+ if (typeof field.many !== 'boolean') {
466
+ throw new Error(`Data schema '${ref}': field '${path}' 'many' must be a boolean.`)
467
+ }
468
+ if (field.many) {
469
+ // Collection-level metadata (required, default, label, help, description)
470
+ // rides on the array; the type-bearing attributes describe each item.
471
+ const ITEM_KEYS = new Set(['type', 'ref', 'options', 'enum', 'fields', 'items', 'format'])
472
+ const out = { type: 'array' }
473
+ const item = {}
474
+ for (const [k, v] of Object.entries(field)) {
475
+ if (k === 'many') continue
476
+ if (ITEM_KEYS.has(k)) item[k] = v
477
+ else out[k] = v
478
+ }
479
+ out.items = normalizeField(item, ref, `${path}[]`)
480
+ return out
481
+ }
482
+ const { many, ...rest } = field // many: false → a single value
483
+ field = rest
484
+ }
485
+
486
+ // Sugar: infer `type` from `ref:`/`options:` when omitted — `{ ref: '@/x' }` is a
487
+ // reference; `{ options: '@/x' }` is a curated picklist value.
488
+ if (field.type === undefined) {
489
+ if (typeof field.ref === 'string') field = { ...field, type: 'ref' }
490
+ else if (typeof field.options === 'string') field = { ...field, type: 'string' }
491
+ }
492
+
447
493
  const rawType = field.type
448
494
  if (typeof rawType !== 'string') {
449
495
  throw new Error(`Data schema '${ref}': field '${path}' has no 'type'.`)
@@ -455,8 +501,8 @@ function normalizeField(field, ref, path) {
455
501
  if (field[k] !== undefined) out[k] = field[k]
456
502
  }
457
503
 
458
- // Resolve the type: format-aliases (url/email → string; markdown/html/richtext
459
- // text) carry a `format` marker; else the plain alias map; else verbatim.
504
+ // Resolve the type: format-aliases (url/email → string; markdown/html → text;
505
+ // richtext → json) carry a `format` marker; else the plain alias map; else verbatim.
460
506
  const formatAlias = FORMAT_TYPE_ALIASES[rawType]
461
507
  if (formatAlias) {
462
508
  out.type = formatAlias.type
@@ -295,6 +295,16 @@ const CONTENT_PROFILES = {
295
295
  'document.yml': { contentDir: 'content', defaultMode: 'pages', orderField: 'content' }
296
296
  }
297
297
 
298
+ // Short profile aliases, so a caller that knows its own context can select
299
+ // the profile explicitly (collectSiteContent's `profile` option) instead of
300
+ // inferring it from the config filename. unipress — always a document tool —
301
+ // uses this so an author can name a variant config anything (book.yml,
302
+ // print.yml) without it being misread as a site.
303
+ const PROFILE_ALIASES = {
304
+ document: CONTENT_PROFILES['document.yml'],
305
+ site: CONTENT_PROFILES['site.yml']
306
+ }
307
+
298
308
  function getContentProfile(configFile) {
299
309
  return CONTENT_PROFILES[configFile] || CONTENT_PROFILES['site.yml']
300
310
  }
@@ -1974,17 +1984,18 @@ async function collectLayouts(layoutDir, siteRoot, layoutNames = new Set()) {
1974
1984
  * @param {Object} options - Collection options
1975
1985
  * @param {string} options.foundationPath - Path to foundation directory (for theme vars)
1976
1986
  * @param {string} [options.configFile='site.yml'] - Name of the top-level config file inside sitePath. Defaults to 'site.yml'. Document tools (unipress) pass 'document.yml'.
1987
+ * @param {'document'|'site'} [options.profile] - Explicit content profile, authoritative over the filename. Lets a tool that knows its context (unipress → 'document') read an arbitrarily named config with the right directory/mode/ordering. Falls back to the configFile-derived profile when omitted.
1977
1988
  * @returns {Promise<Object>} Site content object with assets manifest
1978
1989
  */
1979
1990
  export async function collectSiteContent(sitePath, options = {}) {
1980
- const { foundationPath, configFile = 'site.yml' } = options
1991
+ const { foundationPath, configFile = 'site.yml', profile: profileName } = options
1981
1992
 
1982
1993
  // Read site config and raw theme config
1983
1994
  const siteConfig = await readYamlFile(join(sitePath, configFile))
1984
1995
 
1985
1996
  // Profile selects workspace-root defaults: site.yml → pages/ + page mode +
1986
1997
  // pages: ordering; document.yml → content/ + folder mode + content: ordering.
1987
- const profile = getContentProfile(configFile)
1998
+ const profile = (profileName && PROFILE_ALIASES[profileName]) || getContentProfile(configFile)
1988
1999
 
1989
2000
  // Resolve content paths from <config>.paths: group, defaulting per profile.
1990
2001
  // Backward compatibility: when the document profile's `content/` is missing
@@ -194,16 +194,21 @@ function lowerField(rawField, resolve, optResolve) {
194
194
  return out
195
195
  }
196
196
 
197
- // A leaf (scalar) kind. `richtext` is retired (2026-06-02 / uwx-format.md): a
198
- // leftover `richtext` in the IR (e.g. a foundation built before the migration)
199
- // lowers to the file-based body shape `text` + `format: markdown` so the wire
200
- // never carries the kind the backend now rejects.
201
- let leafType = type
202
- let leafFormat = field.format
203
- if (leafType === 'richtext') {
204
- leafType = 'text'
205
- leafFormat = leafFormat ?? 'markdown'
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') {
205
+ throw new Error(
206
+ 'This foundation carries the retired `richtext` kind in its built schema — ' +
207
+ 'rebuild it (`richtext` is now json + format: prosemirror).'
208
+ )
206
209
  }
210
+ const leafType = type
211
+ const leafFormat = field.format
207
212
 
208
213
  const out = { type: leafType }
209
214
  if (field.label) out.label = field.label