@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@uniweb/build",
3
- "version": "1.2.0",
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-reader": "~1.3.0",
64
- "@uniweb/content-writer": "~1.1.0"
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/core": "~1.2.0",
77
- "@uniweb/runtime": "~1.1.1"
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 = fieldsWithDefaults(normalized)
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
- /** The dotted paths of every field in a normalized schema that declares a `default`. */
116
- function fieldsWithDefaults(schema) {
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.default !== undefined) found.push(path)
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
 
@@ -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 {(ref: string) => string} [opts.resolveOptions] - maps an `options`
114
- * (item_ref) ref to its full `@org/model/<section>` path. Falls back to
115
- * `resolveName` (model only) when not supplied.
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 optResolve = resolveOptions || resolve
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
- const { sections, brief } = normalized.sections
128
- ? lowerSectionsForm(normalized.sections, resolve, optResolve)
129
- : lowerFieldsForm(normalized.fields || {}, resolve, optResolve)
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
- function lowerFieldsForm(fields, resolve, optResolve) {
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 { toDataSchemaDeclaration, SHORTHAND_SECTION } from './data-schema.js'
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` get the full `@org/x/<section>` path (§10.1).
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
- // Resolve an item_ref `options: '@/x'` to the full `@/x/<section>` path the
283
- // backend needs (it splits on the last `/`; a bare model mis-resolves — §10.1).
284
- // The section is the options model's single item-bearing (single/multi) section.
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 (ref) => {
287
- const model = dataSchemas[ref]
288
- if (!model) return scoped(ref)
289
- const section = itemBearingSectionName(model, ref)
290
- return section ? `${scoped(ref)}/${section}` : scoped(ref)
291
- }
292
- }
293
-
294
- function itemBearingSectionName(normalized, ref) {
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.