@uniweb/build 1.1.1 → 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.1.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,15 +56,15 @@
56
56
  "js-yaml": "^4.1.0",
57
57
  "sharp": "^0.35.3",
58
58
  "yaml": "^2.5.0",
59
- "@uniweb/projections": "~1.1.0",
60
- "@uniweb/content-writer": "~1.0.1",
61
- "@uniweb/semantic-parser": "~1.5.1",
62
- "@uniweb/schemas": "~1.0.1",
63
- "@uniweb/content-reader": "~1.2.8",
64
- "@uniweb/theming": "~1.1.0"
59
+ "@uniweb/projections": "~1.1.1",
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
- "@uniweb/runtime": "~1.1.0"
67
+ "@uniweb/runtime": "~1.1.1"
68
68
  },
69
69
  "peerDependencies": {
70
70
  "vite": "^5.0.0 || ^6.0.0 || ^7.0.0",
@@ -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.1.0",
77
- "@uniweb/runtime": "~1.1.0"
76
+ "@uniweb/runtime": "~1.1.1",
77
+ "@uniweb/core": "~1.2.0"
78
78
  },
79
79
  "peerDependenciesMeta": {
80
80
  "vite": {
@@ -26,6 +26,7 @@ import { poolDirsForSchema, schemaForPoolDirs, RECORDS_DIR } from '../site/entit
26
26
  import { parseFrontmatter } from '../utils/frontmatter.js'
27
27
  import { computeSourceHash } from './freeform-manifest.js'
28
28
  import { layoutAreaRoute } from '../site/layout-folder.js'
29
+ import { warnOutsideVocabulary } from '../site/vocabulary-warnings.js'
29
30
 
30
31
  // Try to import content-reader for markdown → ProseMirror conversion
31
32
  let markdownToProseMirror
@@ -46,6 +47,11 @@ try {
46
47
  }
47
48
 
48
49
 
50
+ /** Report what a translation's markdown could not honour, naming the file. */
51
+ function warnFor(filePath) {
52
+ return (message) => console.warn(`[i18n] ${relative(process.cwd(), filePath)}: ${message}`)
53
+ }
54
+
49
55
  /**
50
56
  * Normalize route for filesystem path
51
57
  * Removes leading slash, replaces remaining slashes with path separators
@@ -83,8 +89,10 @@ export async function loadFreeformTranslation(section, page, locale, localesDir)
83
89
  const content = await readFile(filePath, 'utf-8')
84
90
  const { frontmatter, body } = parseFrontmatter(content, filePath)
85
91
 
86
- // Convert markdown body to ProseMirror
87
- const proseMirrorContent = markdownToProseMirror(body)
92
+ // Convert markdown body to ProseMirror — HTML in it shows as written,
93
+ // except `<br>`, and the translator is told which file.
94
+ const proseMirrorContent = markdownToProseMirror(body, { onWarning: warnFor(filePath) })
95
+ warnOutsideVocabulary(proseMirrorContent, warnFor(filePath))
88
96
 
89
97
  return {
90
98
  content: proseMirrorContent,
@@ -138,7 +146,8 @@ export async function loadFreeformRecord(item, schema, locale, localesDir) {
138
146
  const { frontmatter, body } = parseFrontmatter(content, filePath)
139
147
 
140
148
  // Convert markdown body to ProseMirror (if body exists)
141
- const proseMirrorContent = body.trim() ? markdownToProseMirror(body) : null
149
+ const proseMirrorContent = body.trim() ? markdownToProseMirror(body, { onWarning: warnFor(filePath) }) : null
150
+ if (proseMirrorContent) warnOutsideVocabulary(proseMirrorContent, warnFor(filePath))
142
151
 
143
152
  return {
144
153
  frontmatter: Object.keys(frontmatter).length > 0 ? frontmatter : null,
@@ -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
 
@@ -42,6 +42,7 @@ import { authoredRedirectTarget } from '@uniweb/core/resolve-route'
42
42
  import { parseFrontmatter } from '../utils/frontmatter.js'
43
43
  import { parseGrid } from '@uniweb/schemas/grid'
44
44
  import { runtimeServicesConfig, refuseRetiredServiceKeys, refuseUnreadableServices } from '../uwx/services-request.js'
45
+ import { warnOutsideVocabulary } from './vocabulary-warnings.js'
45
46
 
46
47
  // Try to import content-reader, fall back to simplified parser
47
48
  let markdownToProseMirror
@@ -1068,7 +1069,12 @@ async function processMarkdownFile(filePath, id, siteRoot, defaultStableId = nul
1068
1069
  // come through here (a free-form translation, a record body) kept raw references
1069
1070
  // nothing resolved, and the same section's content differed between a build and a
1070
1071
  // document stored as written.
1071
- const proseMirrorContent = markdownToProseMirror(markdown)
1072
+ //
1073
+ // HTML in the markdown is not content: it shows as written, except `<br>`
1074
+ // (content-reader, since 2026-10-14). Say so with the file in hand.
1075
+ const warnHere = (message) => console.warn(`[content-collector] ${relative(siteRoot, filePath)}: ${message}`)
1076
+ const proseMirrorContent = markdownToProseMirror(markdown, { onWarning: warnHere })
1077
+ warnOutsideVocabulary(proseMirrorContent, warnHere)
1072
1078
 
1073
1079
  // `query: team` → `fetch: { query: team }`; a list, one config per name, each under
1074
1080
  // its own `as` — the one helper every level uses. Unrelated to a section type's
@@ -72,6 +72,7 @@ import { toDeliveredRecord, toStoredRecord, storedPath, briefFieldMap, contentBo
72
72
  import { collectNestedRefs } from '@uniweb/schemas/format'
73
73
  import { labelIn } from './entry-label.js'
74
74
  import { fileRecordValue, makeFilePreview } from './file-records.js'
75
+ import { warnOutsideVocabulary } from './vocabulary-warnings.js'
75
76
 
76
77
  // Try to import content-reader for markdown parsing
77
78
  let markdownToProseMirror
@@ -563,8 +564,11 @@ async function processContentItem(dir, filename, config, siteRoot, basePath, rec
563
564
  // record was published.
564
565
  await processDataItemAssets(frontmatter, filepath, siteRoot, recordsRoot, basePath)
565
566
 
566
- // Parse markdown body to ProseMirror
567
- const content = markdownToProseMirror(body)
567
+ // Parse markdown body to ProseMirror — HTML in it shows as written, except
568
+ // `<br>`, and the author is told which file.
569
+ const warnHere = (message) => console.warn(`[records] ${relative(siteRoot, filepath)}: ${message}`)
570
+ const content = markdownToProseMirror(body, { onWarning: warnHere })
571
+ warnOutsideVocabulary(content, warnHere)
568
572
 
569
573
  // Process assets (resolve paths, copy co-located files)
570
574
  // This modifies content in place, updating paths to site-root-relative
@@ -0,0 +1,33 @@
1
+ /**
2
+ * Say, with the file in hand, what in a document a page leaves out.
3
+ *
4
+ * A document holds the rich-text vocabulary (`@uniweb/content-reader/vocabulary`):
5
+ * its types and attributes, and the values that reach a page's markup. What is
6
+ * outside it — a link to `javascript:`, an inert `{.name}`, MathML outside the
7
+ * allowlist — the renderer leaves out of the page, and a backend that cleans the
8
+ * content it stores removes, so the next pull would write the file without it.
9
+ * The author hears it here first, once per thing, naming the file.
10
+ */
11
+ import { outsideVocabulary } from '@uniweb/content-reader/vocabulary'
12
+
13
+ const SAYS = {
14
+ url: (f) => `a link to \`${f.found}\` is never followed`,
15
+ target: (f) => `\`target=${f.found}\` is not a link target`,
16
+ 'style-name': (f) => `\`{${f.found}}\` is not a style name`,
17
+ color: (f) => `\`${f.found}\` is not a colour`,
18
+ mathml: (f) => `a formula's MathML holds what a page cannot show (${f.found})`,
19
+ 'ref-id': (f) => `\`${f.found}\` is not an inset reference`,
20
+ }
21
+
22
+ /**
23
+ * Warn, for each thing in `doc` outside the vocabulary, with the file it came from.
24
+ *
25
+ * @param {Object} doc - a ProseMirror document
26
+ * @param {(message: string) => void} warn - prints one message, prefixed with the file
27
+ */
28
+ export function warnOutsideVocabulary(doc, warn) {
29
+ for (const finding of outsideVocabulary(doc)) {
30
+ const says = SAYS[finding.rule]?.(finding) ?? `\`${finding.found}\` is not part of a document (${finding.rule})`
31
+ warn(`${says} — a page leaves it out, and a backend that cleans what it stores removes it`)
32
+ }
33
+ }
@@ -21,13 +21,16 @@
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
 
27
29
  // Rich-content `format` markers on a `text` field — the file-based body shape that
28
30
  // replaced the retired `richtext` kind (2026-06-02 / uwx-format.md). A `text` field
29
31
  // carrying one round-trips as the RAW source string (no ProseMirror conversion).
30
- const CONTENT_TEXT_FORMATS = new Set(['markdown', 'html'])
32
+ // ⛔ `html` was one until 2026-10-14; `@uniweb/schemas` refuses it by name since.
33
+ const CONTENT_TEXT_FORMATS = new Set(['markdown'])
31
34
 
32
35
  // The field an open map's key lowers into. `name` matches the idiom already in use
33
36
  // for this shape (the backend's site-content `collections` section), so an open map
@@ -109,11 +112,12 @@ export function isContentBodyField(field) {
109
112
  * @param {string} opts.name - the schema's registry name (`@org/x` or `@/x`).
110
113
  * @param {(ref: string) => string} [opts.resolveName] - maps a `ref` target name.
111
114
  * Defaults to resolving `@/x` into the schema's own org, passing others through.
112
- * @param {(ref: string) => string} [opts.resolveOptions] - maps an `options`
113
- * (item_ref) ref to its full `@org/model/<section>` path. Falls back to
114
- * `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.
115
119
  * @returns {Object} the declaration (`{ name, label?, plural?, icon?, description?,
116
- * source_locale?, linkable?, sections }`).
120
+ * source_locale?, linkable?, grantable?, sections }`).
117
121
  */
118
122
  export function toDataSchemaDeclaration(normalized, { name, resolveName, resolveOptions } = {}) {
119
123
  if (!name) throw new Error('toDataSchemaDeclaration: a registry name is required')
@@ -121,11 +125,33 @@ export function toDataSchemaDeclaration(normalized, { name, resolveName, resolve
121
125
  throw new Error('toDataSchemaDeclaration: a normalized schema is required')
122
126
  }
123
127
  const resolve = resolveName || defaultResolver(name)
124
- 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
+ }
125
143
 
126
- const { sections, brief } = normalized.sections
127
- ? lowerSectionsForm(normalized.sections, resolve, optResolve)
128
- : 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
129
155
 
130
156
  // The model-level sort axis is inline: `sort_date: true` on the brief's named
131
157
  // date field (replaces the old schema-level `sort_date_field` back-reference).
@@ -158,6 +184,10 @@ export function toDataSchemaDeclaration(normalized, { name, resolveName, resolve
158
184
  // reference, which read as a promise that one is accepted. A backend refuses it.
159
185
  if (normalized.linkable === false) decl.linkable = false
160
186
 
187
+ // `grantable` — AUTHORED ONLY, like `linkable`: a model's entries may be shared one at a
188
+ // time unless it says `grantable: false` (omitted ⇒ true) [Diego, 2026-10-14].
189
+ if (normalized.grantable === false) decl.grantable = false
190
+
161
191
  decl.sections = sections
162
192
  return decl
163
193
  }
@@ -176,9 +206,10 @@ export function toDataSchemaDeclaration(normalized, { name, resolveName, resolve
176
206
  // only the recommended name for its card.
177
207
  export const SHORTHAND_SECTION = 'brief'
178
208
 
179
- 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) {
180
211
  return {
181
- sections: { [SHORTHAND_SECTION]: lowerSection({ kind: 'single', brief: true, fields }, resolve, optResolve) },
212
+ sections: { [SHORTHAND_SECTION]: lowerSection({ kind: 'single', brief: true, fields, constraints }, resolve, optResolve) },
182
213
  brief: SHORTHAND_SECTION,
183
214
  }
184
215
  }
@@ -381,17 +412,17 @@ function lowerField(rawField, resolve, optResolve, path = '') {
381
412
  { ...rest, ...items, type: items ? items.type : 'json' },
382
413
  resolve,
383
414
  optResolve,
384
- { multiple: true }
415
+ { multiple: true, path }
385
416
  )
386
417
  }
387
418
 
388
- return lowerLeaf(field, resolve, optResolve)
419
+ return lowerLeaf(field, resolve, optResolve, { path })
389
420
  }
390
421
 
391
422
  // Lower a LEAF-SHAPED field — a scalar, a reference (`entity_ref`), or a curated
392
423
  // picklist (`item_ref`) — with or without `multiple`. The single place field
393
424
  // attributes are emitted, so every leaf-shaped kind carries the same set.
394
- function lowerLeaf(field, resolve, optResolve, { multiple = false } = {}) {
425
+ function lowerLeaf(field, resolve, optResolve, { multiple = false, path = '' } = {}) {
395
426
  const leafType = field.type
396
427
 
397
428
  // `richtext` is NOT a kind — it is the author alias for a ProseMirror document
@@ -411,11 +442,30 @@ function lowerLeaf(field, resolve, optResolve, { multiple = false } = {}) {
411
442
  }
412
443
  const leafFormat = field.format
413
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
+
414
458
  const out = { type: leafType }
415
459
  if (multiple) out.multiple = true
416
460
  if (field.label) out.label = field.label
417
461
  if (field.description) out.description = field.description
418
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
+ }
419
469
 
420
470
  // A reference to a whole entity, hydrating to the target's brief. It is a
421
471
  // FIELD, so it carries field attributes — the same ones `item_ref` below has
@@ -424,13 +474,17 @@ function lowerLeaf(field, resolve, optResolve, { multiple = false } = {}) {
424
474
  if (leafType === 'ref') {
425
475
  out.type = 'entity_ref'
426
476
  if (field.ref) out.model = resolve(field.ref)
477
+ // The level on the entry it points to that opens this entry, for `read` — by link
478
+ // [Diego, 2026-10-14]. `@uniweb/schemas` admits it only here, as read, edit or admin.
479
+ if (field.readable_with) out.readable_with = field.readable_with
427
480
  return out
428
481
  }
429
482
 
430
- // 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`).
431
485
  if (field.options !== undefined) {
432
486
  out.type = 'item_ref'
433
- out.options = optResolve(field.options)
487
+ out.options = optResolve(field.options, path)
434
488
  return out
435
489
  }
436
490
 
@@ -461,6 +515,142 @@ function lowerLeaf(field, resolve, optResolve, { multiple = false } = {}) {
461
515
  return out
462
516
  }
463
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
+
464
654
  // --- helpers -----------------------------------------------------------------
465
655
 
466
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.
@@ -47,7 +47,8 @@ import { declarationsToQueriesYml } from './records-project.js'
47
47
  import { FILLED_SECTION_TYPE, buildRouteOf } from './site.js'
48
48
  import { translationContext, SITE_META_CONTEXT } from '../i18n/extract.js'
49
49
  import { authorableDeclaration, DECLARATION_KEYS } from '../site/fetch-shapes.js'
50
- import { createTranslationCollector, writeLocaleTranslations, writeFreeformTranslations, unwrapLocalizedContent } from './locale-sync.js'
50
+ import { createTranslationCollector, writeLocaleTranslations, writeFreeformTranslations, unwrapLocalizedContent, isProseMirrorDoc } from './locale-sync.js'
51
+ import { unwritableNodes } from '@uniweb/content-writer'
51
52
  import { buildFreeformPath, freeformPathsFor } from '../i18n/freeform.js'
52
53
  import { unwrapLocalized, unwrapLocalizedList } from './backfill.js'
53
54
  import { LOCALIZED_FIELD_ASSUMPTION } from './localize.js'
@@ -1201,6 +1202,59 @@ function projectLayout(layoutSections, layoutBaseDir, report, prune, ctx) {
1201
1202
  if (prune && written.length > 0) pruneOrphanLayout(layoutBaseDir, new Set(written), report)
1202
1203
  }
1203
1204
 
1205
+ /**
1206
+ * ⛔ A PULL THAT WOULD DROP CONTENT WRITES NOTHING.
1207
+ *
1208
+ * A node a markdown file cannot hold is left out of the file it is written to,
1209
+ * and the push after the pull then deletes it from the site — with nobody told
1210
+ * but a log line. So every section document the projection would write is asked
1211
+ * first (`unwritableNodes`, `@uniweb/content-writer`), every locale's included,
1212
+ * and the pull stops, naming each section and what it holds.
1213
+ * ⛔ Until 2026-10-14 the writer logged one line per type and the pull went on:
1214
+ * measured with a button an editor saved as a node, which a pull and a push then
1215
+ * deleted.
1216
+ *
1217
+ * @param {object} document - the `@uniweb/site-content` document
1218
+ * @param {string} sourceLocale
1219
+ */
1220
+ function refuseContentFilesCannotHold(document, sourceLocale) {
1221
+ const found = []
1222
+ const name = (record, where) => {
1223
+ const own = record?.slug !== undefined ? pageDirName(record, sourceLocale) : record?.stable_id || record?.$uuid
1224
+ return own ? (where ? `${where}/${own}` : String(own)) : where
1225
+ }
1226
+ const visit = (value, where) => {
1227
+ if (Array.isArray(value)) {
1228
+ for (const item of value) visit(item, where)
1229
+ return
1230
+ }
1231
+ if (!value || typeof value !== 'object') return
1232
+ const here = name(value, where)
1233
+ for (const [key, field] of Object.entries(value)) {
1234
+ if (key === 'content') {
1235
+ const docs = isProseMirrorDoc(field)
1236
+ ? [field]
1237
+ : isPlainRecord(field)
1238
+ ? Object.values(field).filter(isProseMirrorDoc)
1239
+ : []
1240
+ const types = [...new Set(docs.flatMap((doc) => unwritableNodes(doc)))]
1241
+ if (types.length) found.push(`${here || 'a section'}: ${types.join(', ')}`)
1242
+ if (docs.length) continue
1243
+ }
1244
+ visit(field, here)
1245
+ }
1246
+ }
1247
+ visit(document?.pages, '')
1248
+ visit(document?.layout_sections, 'layout')
1249
+ if (found.length === 0) return
1250
+ throw new Error(
1251
+ 'This pull would leave content out of your files — a file cannot hold it, so your next push would ' +
1252
+ 'delete it from the site. Nothing was written.\n' +
1253
+ found.map((entry) => ` ${entry}`).join('\n') +
1254
+ '\nChange or remove it where it was made, then pull again.'
1255
+ )
1256
+ }
1257
+
1204
1258
  /**
1205
1259
  * Project a whole `@uniweb/site-content` document to a site's files: `info` →
1206
1260
  * config (siteInfoToConfig), `collections[]` declarations →
@@ -1226,6 +1280,9 @@ function projectLayout(layoutSections, layoutBaseDir, report, prune, ctx) {
1226
1280
  * @returns {{ config: object, collections: object, locales: object, pages: string[], sections: string[], layout: string[], deleted: string[], renamed: object[] }}
1227
1281
  */
1228
1282
  export function siteContentDocumentToProject({ document, siteRoot, backend = null, sourceLocale = LOCALIZED_FIELD_ASSUMPTION.defaultSourceLocale, prune = false, keepAuthoredFoundation = false, scope, dataModel = null }) {
1283
+ // Before anything is written: a pull that would leave content out of the files stops here.
1284
+ refuseContentFilesCannotHold(document, sourceLocale)
1285
+
1229
1286
  const report = { config: null, collections: null, locales: null, assets: null, pages: [], sections: [], layout: [], deleted: [], renamed: [] }
1230
1287
 
1231
1288
  // Collects target-locale translations of localized scalars as they're projected;