@uniweb/build 1.2.0 → 1.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +8 -8
- package/src/resolve-data-schema.js +34 -4
- package/src/uwx/data-schema.js +196 -14
- package/src/uwx/registry-package.js +19 -21
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@uniweb/build",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.3.0",
|
|
4
4
|
"description": "Build tooling for the Uniweb Component Web Platform",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
@@ -56,12 +56,12 @@
|
|
|
56
56
|
"js-yaml": "^4.1.0",
|
|
57
57
|
"sharp": "^0.35.3",
|
|
58
58
|
"yaml": "^2.5.0",
|
|
59
|
-
"@uniweb/semantic-parser": "~1.6.0",
|
|
60
|
-
"@uniweb/theming": "~1.2.0",
|
|
61
|
-
"@uniweb/schemas": "~1.1.0",
|
|
62
59
|
"@uniweb/projections": "~1.1.1",
|
|
63
|
-
"@uniweb/content-
|
|
64
|
-
"@uniweb/
|
|
60
|
+
"@uniweb/content-writer": "~1.1.0",
|
|
61
|
+
"@uniweb/theming": "~1.2.0",
|
|
62
|
+
"@uniweb/semantic-parser": "~1.6.0",
|
|
63
|
+
"@uniweb/schemas": "~1.2.0",
|
|
64
|
+
"@uniweb/content-reader": "~1.3.0"
|
|
65
65
|
},
|
|
66
66
|
"optionalDependencies": {
|
|
67
67
|
"@uniweb/runtime": "~1.1.1"
|
|
@@ -73,8 +73,8 @@
|
|
|
73
73
|
"@tailwindcss/vite": "^4.0.0",
|
|
74
74
|
"@vitejs/plugin-react": "^4.0.0 || ^5.0.0",
|
|
75
75
|
"vite-plugin-svgr": "^4.0.0",
|
|
76
|
-
"@uniweb/
|
|
77
|
-
"@uniweb/
|
|
76
|
+
"@uniweb/runtime": "~1.1.1",
|
|
77
|
+
"@uniweb/core": "~1.2.0"
|
|
78
78
|
},
|
|
79
79
|
"peerDependenciesMeta": {
|
|
80
80
|
"vite": {
|
|
@@ -40,6 +40,7 @@ import { existsSync, readdirSync, statSync } from 'node:fs'
|
|
|
40
40
|
import { join, resolve, isAbsolute, extname, basename } from 'node:path'
|
|
41
41
|
import { dataRefOf } from '@uniweb/core/data-keys'
|
|
42
42
|
import { SYSTEM_RECORD_SCHEMAS } from '@uniweb/schemas/system'
|
|
43
|
+
import { assertOptionsTargets } from './uwx/data-schema.js'
|
|
43
44
|
import { pathToFileURL } from 'node:url'
|
|
44
45
|
import { createRequire } from 'node:module'
|
|
45
46
|
import yaml from 'js-yaml'
|
|
@@ -101,7 +102,7 @@ const packageForScope = (scope) => SCOPE_PACKAGE[scope] ?? `@${scope}/schemas`
|
|
|
101
102
|
*/
|
|
102
103
|
function normalizeNamedSchema(schema, ref) {
|
|
103
104
|
const normalized = validateAndNormalizeSchema(schema, ref)
|
|
104
|
-
const paths =
|
|
105
|
+
const paths = fieldsWhere(normalized, (spec) => spec.default !== undefined)
|
|
105
106
|
if (paths.length > 0) {
|
|
106
107
|
throw new Error(
|
|
107
108
|
`Data schema '${ref}': a named data schema declares no default — remove \`default:\` from ` +
|
|
@@ -109,11 +110,37 @@ function normalizeNamedSchema(schema, ref) {
|
|
|
109
110
|
`renders as; an editor pre-fills from a component's own inline field map or form in \`data:\`.`
|
|
110
111
|
)
|
|
111
112
|
}
|
|
113
|
+
// ⛔ The same reason, two more keys (2026-10-15): nothing keeps them for a named schema. A
|
|
114
|
+
// registry has no slot for a field's `help` or for a leaf's `constraints` — it refuses the
|
|
115
|
+
// first, and a section key cannot sit on a leaf — and no reader of the build reads either.
|
|
116
|
+
// Until then both were dropped when the schema was registered, without a word.
|
|
117
|
+
const helped = fieldsWhere(normalized, (spec) => spec.help !== undefined)
|
|
118
|
+
if (helped.length > 0) {
|
|
119
|
+
throw new Error(
|
|
120
|
+
`Data schema '${ref}': a named data schema declares no \`help\` — write it as the field's ` +
|
|
121
|
+
`\`description\`, which a registry keeps: ${helped.map((p) => `'${p}'`).join(', ')}.`
|
|
122
|
+
)
|
|
123
|
+
}
|
|
124
|
+
const ruled = fieldsWhere(normalized, (spec) => spec.constraints !== undefined && !isSectionShaped(spec))
|
|
125
|
+
if (ruled.length > 0) {
|
|
126
|
+
throw new Error(
|
|
127
|
+
`Data schema '${ref}': ${ruled.map((p) => `'${p}'`).join(', ')} declare${ruled.length === 1 ? 's' : ''} ` +
|
|
128
|
+
`\`constraints\` on a value — they are a section's rules, naming its fields: declare them in the ` +
|
|
129
|
+
`\`constraints:\` of the section that holds the field (beside \`fields:\` in a \`fields:\` schema).`
|
|
130
|
+
)
|
|
131
|
+
}
|
|
112
132
|
return normalized
|
|
113
133
|
}
|
|
114
134
|
|
|
115
|
-
|
|
116
|
-
|
|
135
|
+
// A field that lowers into a section — a nested object, an open map, a list of records — and so
|
|
136
|
+
// may carry a section's `constraints`.
|
|
137
|
+
function isSectionShaped(spec) {
|
|
138
|
+
if (spec.type === 'object') return true
|
|
139
|
+
return spec.type === 'array' && spec.items?.type === 'object'
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** The dotted paths of every field in a normalized schema that `test` holds for. */
|
|
143
|
+
function fieldsWhere(schema, test) {
|
|
117
144
|
const found = []
|
|
118
145
|
const walkFields = (fields, prefix) => {
|
|
119
146
|
if (!fields || typeof fields !== 'object') return
|
|
@@ -121,7 +148,7 @@ function fieldsWithDefaults(schema) {
|
|
|
121
148
|
}
|
|
122
149
|
const walkSpec = (spec, path) => {
|
|
123
150
|
if (!spec || typeof spec !== 'object') return
|
|
124
|
-
if (spec
|
|
151
|
+
if (test(spec)) found.push(path)
|
|
125
152
|
walkFields(spec.fields, `${path}.`)
|
|
126
153
|
if (spec.items) walkSpec(spec.items, `${path}[]`)
|
|
127
154
|
if (spec.values) walkSpec(spec.values, `${path}{}`)
|
|
@@ -292,6 +319,9 @@ export async function buildDataSchemaMap(refs, { srcDir }) {
|
|
|
292
319
|
if (!map[target]) queue.push(target)
|
|
293
320
|
}
|
|
294
321
|
}
|
|
322
|
+
// A picklist names a section of the Model holding its choices — refused here, at the author's
|
|
323
|
+
// screen, when it names none (`optionsSectionPath`), rather than when the schema is registered.
|
|
324
|
+
assertOptionsTargets(map)
|
|
295
325
|
return map
|
|
296
326
|
}
|
|
297
327
|
|
package/src/uwx/data-schema.js
CHANGED
|
@@ -21,6 +21,8 @@
|
|
|
21
21
|
* Pure — IR in, declaration out; no I/O, no uuids.
|
|
22
22
|
*/
|
|
23
23
|
|
|
24
|
+
import { parseOptionsRef, FIELD_RULES } from '@uniweb/schemas/format'
|
|
25
|
+
|
|
24
26
|
// Text kinds carry per-locale values when localized.
|
|
25
27
|
const TEXT_KINDS = new Set(['string', 'text'])
|
|
26
28
|
|
|
@@ -110,9 +112,10 @@ export function isContentBodyField(field) {
|
|
|
110
112
|
* @param {string} opts.name - the schema's registry name (`@org/x` or `@/x`).
|
|
111
113
|
* @param {(ref: string) => string} [opts.resolveName] - maps a `ref` target name.
|
|
112
114
|
* Defaults to resolving `@/x` into the schema's own org, passing others through.
|
|
113
|
-
* @param {(
|
|
114
|
-
* (item_ref)
|
|
115
|
-
* `
|
|
115
|
+
* @param {(options: string, fieldPath: string) => string} [opts.resolveOptions] - maps an
|
|
116
|
+
* `options` (item_ref) value to the `@org/model/<section-path>` the registry takes — a
|
|
117
|
+
* register supplies it (`registry-package.js`), naming the section a short `'@/x'` means.
|
|
118
|
+
* Falls back to `resolveName`, which resolves the scope and keeps the rest as written.
|
|
116
119
|
* @returns {Object} the declaration (`{ name, label?, plural?, icon?, description?,
|
|
117
120
|
* source_locale?, linkable?, grantable?, sections }`).
|
|
118
121
|
*/
|
|
@@ -122,11 +125,33 @@ export function toDataSchemaDeclaration(normalized, { name, resolveName, resolve
|
|
|
122
125
|
throw new Error('toDataSchemaDeclaration: a normalized schema is required')
|
|
123
126
|
}
|
|
124
127
|
const resolve = resolveName || defaultResolver(name)
|
|
125
|
-
const
|
|
128
|
+
const resolveChoices = resolveOptions || resolve
|
|
129
|
+
// A picklist that names no list is refused naming the schema and the field — and so is one
|
|
130
|
+
// that picks from a section of its own schema (`OWN_SECTION`).
|
|
131
|
+
const optResolve = (options, fieldPath) => {
|
|
132
|
+
let resolved
|
|
133
|
+
try {
|
|
134
|
+
resolved = resolveChoices(options, fieldPath)
|
|
135
|
+
} catch (err) {
|
|
136
|
+
throw new Error(`Data schema '${name}': field '${fieldPath}' ${err.message}`)
|
|
137
|
+
}
|
|
138
|
+
if (typeof resolved === 'string' && resolved.startsWith(`${name}/`)) {
|
|
139
|
+
throw new Error(`Data schema '${name}': field '${fieldPath}' ${OWN_SECTION}`)
|
|
140
|
+
}
|
|
141
|
+
return resolved
|
|
142
|
+
}
|
|
126
143
|
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
144
|
+
let lowered
|
|
145
|
+
try {
|
|
146
|
+
lowered = normalized.sections
|
|
147
|
+
? lowerSectionsForm(normalized.sections, resolve, optResolve)
|
|
148
|
+
: lowerFieldsForm(normalized.fields || {}, resolve, optResolve, normalized.constraints)
|
|
149
|
+
} catch (err) {
|
|
150
|
+
// A field the lowering refuses is named with its schema.
|
|
151
|
+
if (err.fieldPath !== undefined) throw new Error(`Data schema '${name}': ${err.message}`)
|
|
152
|
+
throw err
|
|
153
|
+
}
|
|
154
|
+
const { sections, brief } = lowered
|
|
130
155
|
|
|
131
156
|
// The model-level sort axis is inline: `sort_date: true` on the brief's named
|
|
132
157
|
// date field (replaces the old schema-level `sort_date_field` back-reference).
|
|
@@ -181,9 +206,10 @@ export function toDataSchemaDeclaration(normalized, { name, resolveName, resolve
|
|
|
181
206
|
// only the recommended name for its card.
|
|
182
207
|
export const SHORTHAND_SECTION = 'brief'
|
|
183
208
|
|
|
184
|
-
|
|
209
|
+
// A `fields:` schema's top-level `constraints:` are its one section's rules (2026-10-15).
|
|
210
|
+
function lowerFieldsForm(fields, resolve, optResolve, constraints) {
|
|
185
211
|
return {
|
|
186
|
-
sections: { [SHORTHAND_SECTION]: lowerSection({ kind: 'single', brief: true, fields }, resolve, optResolve) },
|
|
212
|
+
sections: { [SHORTHAND_SECTION]: lowerSection({ kind: 'single', brief: true, fields, constraints }, resolve, optResolve) },
|
|
187
213
|
brief: SHORTHAND_SECTION,
|
|
188
214
|
}
|
|
189
215
|
}
|
|
@@ -386,17 +412,17 @@ function lowerField(rawField, resolve, optResolve, path = '') {
|
|
|
386
412
|
{ ...rest, ...items, type: items ? items.type : 'json' },
|
|
387
413
|
resolve,
|
|
388
414
|
optResolve,
|
|
389
|
-
{ multiple: true }
|
|
415
|
+
{ multiple: true, path }
|
|
390
416
|
)
|
|
391
417
|
}
|
|
392
418
|
|
|
393
|
-
return lowerLeaf(field, resolve, optResolve)
|
|
419
|
+
return lowerLeaf(field, resolve, optResolve, { path })
|
|
394
420
|
}
|
|
395
421
|
|
|
396
422
|
// Lower a LEAF-SHAPED field — a scalar, a reference (`entity_ref`), or a curated
|
|
397
423
|
// picklist (`item_ref`) — with or without `multiple`. The single place field
|
|
398
424
|
// attributes are emitted, so every leaf-shaped kind carries the same set.
|
|
399
|
-
function lowerLeaf(field, resolve, optResolve, { multiple = false } = {}) {
|
|
425
|
+
function lowerLeaf(field, resolve, optResolve, { multiple = false, path = '' } = {}) {
|
|
400
426
|
const leafType = field.type
|
|
401
427
|
|
|
402
428
|
// `richtext` is NOT a kind — it is the author alias for a ProseMirror document
|
|
@@ -416,11 +442,30 @@ function lowerLeaf(field, resolve, optResolve, { multiple = false } = {}) {
|
|
|
416
442
|
}
|
|
417
443
|
const leafFormat = field.format
|
|
418
444
|
|
|
445
|
+
// A section's rules on a value have nowhere to go: the wire's `constraints` is a section key.
|
|
446
|
+
// A named schema refuses them before this (`resolve-data-schema.js`); refused here too, rather
|
|
447
|
+
// than dropped, which they were until 2026-10-15.
|
|
448
|
+
if (field.constraints !== undefined) {
|
|
449
|
+
throw Object.assign(
|
|
450
|
+
new Error(
|
|
451
|
+
`field '${path}' declares \`constraints\` on a value — they are a section's rules: declare them ` +
|
|
452
|
+
`in the \`constraints:\` of the section that holds it.`
|
|
453
|
+
),
|
|
454
|
+
{ fieldPath: path }
|
|
455
|
+
)
|
|
456
|
+
}
|
|
457
|
+
|
|
419
458
|
const out = { type: leafType }
|
|
420
459
|
if (multiple) out.multiple = true
|
|
421
460
|
if (field.label) out.label = field.label
|
|
422
461
|
if (field.description) out.description = field.description
|
|
423
462
|
if (field.required) out.required = true
|
|
463
|
+
// A field's rules, as written — `max_length`, `min`, `max`, `mime_types`, `max_size`
|
|
464
|
+
// (`FIELD_RULES`): a registry keeps each as the field's own key and checks it on every
|
|
465
|
+
// write. ⛔ Until 2026-10-15 the normalizer dropped them before this saw them.
|
|
466
|
+
for (const key of Object.keys(FIELD_RULES)) {
|
|
467
|
+
if (field[key] !== undefined) out[key] = field[key]
|
|
468
|
+
}
|
|
424
469
|
|
|
425
470
|
// A reference to a whole entity, hydrating to the target's brief. It is a
|
|
426
471
|
// FIELD, so it carries field attributes — the same ones `item_ref` below has
|
|
@@ -435,10 +480,11 @@ function lowerLeaf(field, resolve, optResolve, { multiple = false } = {}) {
|
|
|
435
480
|
return out
|
|
436
481
|
}
|
|
437
482
|
|
|
438
|
-
// A curated picklist is an item_ref (machine-ish — never localized)
|
|
483
|
+
// A curated picklist is an item_ref (machine-ish — never localized), naming the section whose
|
|
484
|
+
// items are its choices (`optionsSectionPath`).
|
|
439
485
|
if (field.options !== undefined) {
|
|
440
486
|
out.type = 'item_ref'
|
|
441
|
-
out.options = optResolve(field.options)
|
|
487
|
+
out.options = optResolve(field.options, path)
|
|
442
488
|
return out
|
|
443
489
|
}
|
|
444
490
|
|
|
@@ -469,6 +515,142 @@ function lowerLeaf(field, resolve, optResolve, { multiple = false } = {}) {
|
|
|
469
515
|
return out
|
|
470
516
|
}
|
|
471
517
|
|
|
518
|
+
// --- picklists ----------------------------------------------------------------
|
|
519
|
+
|
|
520
|
+
// ⛔ A picklist cannot take its choices from a section of its own schema: a registry refuses it on
|
|
521
|
+
// every register, whatever order the package lists its schemas in (measured 2026-10-15). Refused
|
|
522
|
+
// here, at the author's screen, until a registry takes it.
|
|
523
|
+
const OWN_SECTION =
|
|
524
|
+
'takes its choices from a section of its own schema, which a registry does not take — keep the list in a Model of its own.'
|
|
525
|
+
|
|
526
|
+
/**
|
|
527
|
+
* ⭐ THE SECTION A PICKLIST'S CHOICES ARE IN — its path in the options Model, as the registry
|
|
528
|
+
* names it (2026-10-15). A field that picks from a list names the section holding the list:
|
|
529
|
+
* `options: '@/lists/countries'`, a nested one by its path (`'@/lists/countries/subdivisions'`).
|
|
530
|
+
* A Model whose top holds ONE list may be named alone — `'@/country'`, the `fields:` shorthand's
|
|
531
|
+
* one section, is the common case — and that list is the one meant.
|
|
532
|
+
*
|
|
533
|
+
* A register sends `@scope/name/<this path>` (`registry-package.js`): a registry takes an
|
|
534
|
+
* `item_ref`'s `options` as `@org/model/section-path` and refuses one that names no section
|
|
535
|
+
* (2026-10-15).
|
|
536
|
+
*
|
|
537
|
+
* ⛔ A Model with several lists at its top is refused when named alone: which one the author meant
|
|
538
|
+
* is not ours to guess. Until 2026-10-15 the first single-or-multi section was taken, and a path
|
|
539
|
+
* could not be written at all, so a Model's second list could never be a field's choices.
|
|
540
|
+
*
|
|
541
|
+
* @param {string} options - the field's `options:` as authored (`@/lists/countries`)
|
|
542
|
+
* @param {Object} target - the options Model, normalized
|
|
543
|
+
* @returns {string} the section's path in the Model (`countries`, `brief` for the shorthand)
|
|
544
|
+
* @throws when the path names no section, or a section that holds only sections, or is left out
|
|
545
|
+
* of a Model whose top holds more than one list — naming the lists it has
|
|
546
|
+
*/
|
|
547
|
+
export function optionsSectionPath(options, target) {
|
|
548
|
+
const { model, path } = parseOptionsRef(options)
|
|
549
|
+
const sections = sectionPathsOf(target)
|
|
550
|
+
const lists = sections.filter((s) => s.holdsItems).map((s) => s.path)
|
|
551
|
+
const named = (paths) => paths.map((p) => `'${model}/${p}'`).join(', ')
|
|
552
|
+
if (path == null) {
|
|
553
|
+
const top = lists.filter((p) => !p.includes('/'))
|
|
554
|
+
if (top.length === 1) return top[0]
|
|
555
|
+
throw new Error(
|
|
556
|
+
top.length === 0
|
|
557
|
+
? `takes its choices from '${options}', which holds no list at its top — name the section that holds them: ${named(lists) || '(it has none)'}.`
|
|
558
|
+
: `takes its choices from '${options}', which holds ${top.length} lists at its top — name the one: ${named(top)}.`
|
|
559
|
+
)
|
|
560
|
+
}
|
|
561
|
+
const found = sections.find((s) => s.path === path)
|
|
562
|
+
if (!found) {
|
|
563
|
+
throw new Error(`takes its choices from '${options}', but '${model}' has no section '${path}' — its lists: ${named(lists) || '(none)'}.`)
|
|
564
|
+
}
|
|
565
|
+
if (!found.holdsItems) {
|
|
566
|
+
const under = lists.filter((p) => p.startsWith(`${path}/`))
|
|
567
|
+
throw new Error(
|
|
568
|
+
`takes its choices from '${options}', a section that holds only sections, so it has no items to choose — ` +
|
|
569
|
+
`name a list under it: ${named(under) || '(it has none)'}.`
|
|
570
|
+
)
|
|
571
|
+
}
|
|
572
|
+
return path
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
/**
|
|
576
|
+
* Check every picklist in a closed map of normalized schemas against the Model it names, throwing
|
|
577
|
+
* for the first whose `options:` names no list (`optionsSectionPath`). A Model the map does not
|
|
578
|
+
* hold is skipped: resolving the graph fails on it first.
|
|
579
|
+
*
|
|
580
|
+
* @param {Object<string, Object>} dataSchemas - `{ [ref]: normalized }`, closed under references
|
|
581
|
+
*/
|
|
582
|
+
export function assertOptionsTargets(dataSchemas) {
|
|
583
|
+
for (const [ref, schema] of Object.entries(dataSchemas || {})) {
|
|
584
|
+
forEachOptionsField(schema, (fieldPath, options) => {
|
|
585
|
+
const { model } = parseOptionsRef(options)
|
|
586
|
+
if (model === ref) throw new Error(`Data schema '${ref}': field '${fieldPath}' ${OWN_SECTION}`)
|
|
587
|
+
const target = dataSchemas[model]
|
|
588
|
+
if (!target) return
|
|
589
|
+
try {
|
|
590
|
+
optionsSectionPath(options, target)
|
|
591
|
+
} catch (err) {
|
|
592
|
+
throw new Error(`Data schema '${ref}': field '${fieldPath}' ${err.message}`)
|
|
593
|
+
}
|
|
594
|
+
})
|
|
595
|
+
}
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
// Every field of a normalized schema that declares `options:`, with its dotted path — in any
|
|
599
|
+
// section, nested object, list of records or open map.
|
|
600
|
+
function forEachOptionsField(schema, fn) {
|
|
601
|
+
const visitField = (raw, path) => {
|
|
602
|
+
const field = asField(raw)
|
|
603
|
+
if (typeof field.options === 'string') fn(path, field.options)
|
|
604
|
+
if (field.items) visitField(field.items, path)
|
|
605
|
+
if (field.values) visitField(field.values, path)
|
|
606
|
+
for (const [key, child] of Object.entries(field.fields || {})) visitField(child, `${path}.${key}`)
|
|
607
|
+
}
|
|
608
|
+
const visitSection = (section, prefix) => {
|
|
609
|
+
for (const [key, field] of Object.entries(section?.fields || {})) visitField(field, `${prefix}${key}`)
|
|
610
|
+
for (const [name, child] of Object.entries(section?.sections || {})) visitSection(child, `${prefix}${name}.`)
|
|
611
|
+
}
|
|
612
|
+
if (schema?.fields) visitSection(schema, '')
|
|
613
|
+
for (const [name, section] of Object.entries(schema?.sections || {})) visitSection(section, `${name}.`)
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
// The sections of a normalized Model by the path the registry names each one by, and whether each
|
|
617
|
+
// holds items — the lowering's own shapes (`lowerField`): a child in `sections:`, a nested object
|
|
618
|
+
// (one record), a list of records, an open map (rows). A section holding only sections — a binder —
|
|
619
|
+
// holds none.
|
|
620
|
+
function sectionPathsOf(normalized) {
|
|
621
|
+
const out = []
|
|
622
|
+
const visit = (path, node) => {
|
|
623
|
+
out.push({ path, holdsItems: holdsItems(node) })
|
|
624
|
+
for (const [name, child] of Object.entries(node.sections || {})) visit(`${path}/${name}`, child)
|
|
625
|
+
for (const [name, field] of Object.entries(node.fields || {})) {
|
|
626
|
+
const nested = sectionOfField(field)
|
|
627
|
+
if (nested) visit(`${path}/${name}`, nested)
|
|
628
|
+
}
|
|
629
|
+
}
|
|
630
|
+
if (normalized?.fields) visit(SHORTHAND_SECTION, { kind: 'single', fields: normalized.fields })
|
|
631
|
+
for (const [name, section] of Object.entries(normalized?.sections || {})) visit(name, section)
|
|
632
|
+
return out
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
// The section a section-shaped FIELD lowers into (`lowerField`), or null for a leaf.
|
|
636
|
+
function sectionOfField(raw) {
|
|
637
|
+
const field = asField(raw)
|
|
638
|
+
if (field.type === 'object') {
|
|
639
|
+
if (field.values !== undefined) return { kind: 'multi', fields: { [OPEN_MAP_KEY]: { type: 'string' }, ...asField(field.values).fields } }
|
|
640
|
+
return { kind: 'single', fields: field.fields }
|
|
641
|
+
}
|
|
642
|
+
if (field.type === 'array' && field.items && asField(field.items).type === 'object') {
|
|
643
|
+
return { kind: 'multi', fields: asField(field.items).fields }
|
|
644
|
+
}
|
|
645
|
+
return null
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
function holdsItems(section) {
|
|
649
|
+
if ((section.kind || 'single') === 'multi') return true
|
|
650
|
+
if (section.kind === 'binder') return false
|
|
651
|
+
return Object.values(section.fields || {}).some((field) => !sectionOfField(field))
|
|
652
|
+
}
|
|
653
|
+
|
|
472
654
|
// --- helpers -----------------------------------------------------------------
|
|
473
655
|
|
|
474
656
|
function asField(def) {
|
|
@@ -27,7 +27,8 @@ import { existsSync, readFileSync, readdirSync } from 'node:fs'
|
|
|
27
27
|
import { join } from 'node:path'
|
|
28
28
|
|
|
29
29
|
import { normalizeFoundationSchema } from '@uniweb/schemas/foundation'
|
|
30
|
-
import {
|
|
30
|
+
import { parseOptionsRef } from '@uniweb/schemas/format'
|
|
31
|
+
import { toDataSchemaDeclaration, optionsSectionPath } from './data-schema.js'
|
|
31
32
|
import { checkFoundationName, splitFoundationName } from '../foundation-name.js'
|
|
32
33
|
|
|
33
34
|
const FOUNDATION_SCHEMA = '@uniweb/foundation-schema'
|
|
@@ -155,7 +156,8 @@ function buildDataSchemaEntities(dataSchemas, scope) {
|
|
|
155
156
|
.map(([ref, normalized]) => ({
|
|
156
157
|
model: DATA_SCHEMA,
|
|
157
158
|
// `name` resolved to the concrete scope; entity_ref `models` resolve the same
|
|
158
|
-
// way; item_ref `options`
|
|
159
|
+
// way; item_ref `options` name the section whose items are the choices
|
|
160
|
+
// (`@org/x/<section-path>`, `makeOptionsResolver`).
|
|
159
161
|
...toDataSchemaDeclaration(normalized, { name: scoped(ref), resolveName: scoped, resolveOptions }),
|
|
160
162
|
}))
|
|
161
163
|
return { entities: referencedFirst(entities), scoped, org }
|
|
@@ -279,27 +281,23 @@ function buildRefs(dataSchemas, scoped) {
|
|
|
279
281
|
return Object.keys(dataSchemas).sort().map((ref) => ({ name: scoped(ref) }))
|
|
280
282
|
}
|
|
281
283
|
|
|
282
|
-
//
|
|
283
|
-
//
|
|
284
|
-
//
|
|
284
|
+
// An item_ref's `options` as a registry takes it: `@scope/name/<section-path>`. A registry
|
|
285
|
+
// refuses a path that names no section, and picks none for us (2026-10-15). The section is
|
|
286
|
+
// the one the author named, or — for `'@/x'` alone — the options Model's one list
|
|
287
|
+
// (`optionsSectionPath`, which refuses a Model with several).
|
|
288
|
+
// ⛔ Until 2026-10-15 this took the Model's FIRST single-or-multi section, and this comment said
|
|
289
|
+
// the registry "splits on the last `/`" — it does not, and a two-segment path is refused.
|
|
285
290
|
function makeOptionsResolver(dataSchemas, scoped) {
|
|
286
|
-
return (
|
|
287
|
-
const model =
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
}
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
// fields-form → the shorthand's one section, always named `brief`
|
|
296
|
-
if (normalized.fields) return SHORTHAND_SECTION
|
|
297
|
-
// sections-form → the first item-bearing (single/multi) section
|
|
298
|
-
for (const [name, sec] of Object.entries(normalized.sections || {})) {
|
|
299
|
-
const kind = sec.kind || 'single'
|
|
300
|
-
if (kind === 'single' || kind === 'multi') return name
|
|
291
|
+
return (options) => {
|
|
292
|
+
const { model, path } = parseOptionsRef(options)
|
|
293
|
+
const target = dataSchemas[model]
|
|
294
|
+
if (target) return `${scoped(model)}/${optionsSectionPath(options, target)}`
|
|
295
|
+
if (path) return `${scoped(model)}/${path}`
|
|
296
|
+
throw new Error(
|
|
297
|
+
`takes its choices from '${options}', a Model this package does not define — name the section ` +
|
|
298
|
+
`that holds them: '${options}/<section>'.`
|
|
299
|
+
)
|
|
301
300
|
}
|
|
302
|
-
return null
|
|
303
301
|
}
|
|
304
302
|
|
|
305
303
|
// Per-locale sidecar map from `<foundationDir>/i18n/<locale>.json`. `{}` when none.
|