@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 +10 -10
- package/src/i18n/freeform.js +12 -3
- package/src/resolve-data-schema.js +34 -4
- package/src/site/content-collector.js +7 -1
- package/src/site/query-processor.js +6 -2
- package/src/site/vocabulary-warnings.js +33 -0
- package/src/uwx/data-schema.js +206 -16
- package/src/uwx/registry-package.js +19 -21
- package/src/uwx/site-project.js +58 -1
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,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.
|
|
60
|
-
"@uniweb/content-writer": "~1.0
|
|
61
|
-
"@uniweb/
|
|
62
|
-
"@uniweb/
|
|
63
|
-
"@uniweb/
|
|
64
|
-
"@uniweb/
|
|
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.
|
|
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/
|
|
77
|
-
"@uniweb/
|
|
76
|
+
"@uniweb/runtime": "~1.1.1",
|
|
77
|
+
"@uniweb/core": "~1.2.0"
|
|
78
78
|
},
|
|
79
79
|
"peerDependenciesMeta": {
|
|
80
80
|
"vite": {
|
package/src/i18n/freeform.js
CHANGED
|
@@ -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
|
-
|
|
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 =
|
|
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
|
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
+
}
|
package/src/uwx/data-schema.js
CHANGED
|
@@ -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
|
-
|
|
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 {(
|
|
113
|
-
* (item_ref)
|
|
114
|
-
* `
|
|
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
|
|
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
|
-
|
|
127
|
-
|
|
128
|
-
|
|
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
|
-
|
|
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 {
|
|
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.
|
package/src/uwx/site-project.js
CHANGED
|
@@ -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;
|