@crombolo/schema 0.0.0-stage → 0.10.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/LICENSE +21 -0
- package/NOTICE +69 -0
- package/package.json +32 -4
- package/src/builtin.ts +215 -0
- package/src/check.ts +502 -0
- package/src/export.ts +419 -0
- package/src/forms.ts +328 -0
- package/src/generate.ts +149 -0
- package/src/index.ts +19 -0
- package/src/load.ts +137 -0
- package/src/model.ts +156 -0
- package/src/parse-forms.ts +229 -0
- package/src/parse.ts +260 -0
- package/src/reader.ts +99 -0
- package/src/scaffold.ts +172 -0
- package/src/sync.ts +615 -0
- package/src/upgrade.ts +296 -0
- package/src/validate-forms.ts +346 -0
- package/src/validate.ts +242 -0
- package/src/value-migrations.ts +122 -0
- package/src/version.ts +53 -0
- package/src/write-forms.ts +139 -0
- package/src/write.ts +176 -0
- package/README.md +0 -3
package/src/model.ts
ADDED
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import type { SchemaForm } from '@crombolo/core'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The schema-as-code model: what a site's `schema/*.toml` files describe.
|
|
5
|
+
* The shapes mirror the TOML vocabulary in docs/schema-as-code.md, with
|
|
6
|
+
* defaults applied, so the rest of the system never sees a missing key.
|
|
7
|
+
*/
|
|
8
|
+
export interface SchemaProperty {
|
|
9
|
+
key?: string
|
|
10
|
+
alias: string
|
|
11
|
+
name: string
|
|
12
|
+
description?: string
|
|
13
|
+
notes?: string
|
|
14
|
+
/** A data type alias, built in or defined in `schema/data-types/`. */
|
|
15
|
+
type: string
|
|
16
|
+
mandatory: boolean
|
|
17
|
+
mandatoryMessage?: string
|
|
18
|
+
regex?: string
|
|
19
|
+
regexMessage?: string
|
|
20
|
+
variesByCulture: boolean
|
|
21
|
+
variesBySegment: boolean
|
|
22
|
+
labelOnTop: boolean
|
|
23
|
+
/** Member types only: shown to the member on their profile. */
|
|
24
|
+
memberCanView?: boolean
|
|
25
|
+
/** Member types only: editable by the member on their profile. */
|
|
26
|
+
memberCanEdit?: boolean
|
|
27
|
+
/** Member types only: hidden from users without sensitive-data access. */
|
|
28
|
+
sensitive?: boolean
|
|
29
|
+
/** Fills existing content when a required property is added to a type with pages. */
|
|
30
|
+
default?: unknown
|
|
31
|
+
since?: string
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface SchemaGroup {
|
|
35
|
+
name: string
|
|
36
|
+
alias?: string
|
|
37
|
+
properties: SchemaProperty[]
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface SchemaTab {
|
|
41
|
+
name: string
|
|
42
|
+
alias?: string
|
|
43
|
+
properties: SchemaProperty[]
|
|
44
|
+
groups: SchemaGroup[]
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export interface SchemaCleanup {
|
|
48
|
+
prevent: boolean
|
|
49
|
+
keepAllNewerThanDays?: number
|
|
50
|
+
keepLatestPerDayForDays?: number
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export interface SchemaContentType {
|
|
54
|
+
key?: string
|
|
55
|
+
alias: string
|
|
56
|
+
name: string
|
|
57
|
+
description?: string
|
|
58
|
+
notes?: string
|
|
59
|
+
icon: string
|
|
60
|
+
allowAtRoot: boolean
|
|
61
|
+
isElement: boolean
|
|
62
|
+
allowInLibrary: boolean
|
|
63
|
+
variesByCulture: boolean
|
|
64
|
+
variesBySegment: boolean
|
|
65
|
+
compositions: string[]
|
|
66
|
+
allowChildren: string[]
|
|
67
|
+
components: string[]
|
|
68
|
+
defaultComponent?: string
|
|
69
|
+
collection?: string
|
|
70
|
+
/** Tree placement in the backoffice, `A/B`; not part of the type's meaning. */
|
|
71
|
+
folder?: string
|
|
72
|
+
cleanup: SchemaCleanup
|
|
73
|
+
since?: string
|
|
74
|
+
/** Properties outside any tab. */
|
|
75
|
+
properties: SchemaProperty[]
|
|
76
|
+
/** Groups outside any tab — `[[group]]`; Umbraco's built-in media types use one. */
|
|
77
|
+
groups?: SchemaGroup[]
|
|
78
|
+
tabs: SchemaTab[]
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export interface SchemaDataType {
|
|
82
|
+
key?: string
|
|
83
|
+
alias: string
|
|
84
|
+
name: string
|
|
85
|
+
notes?: string
|
|
86
|
+
editor: string
|
|
87
|
+
editorUi?: string
|
|
88
|
+
folder?: string
|
|
89
|
+
config: Record<string, unknown>
|
|
90
|
+
since?: string
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export interface SchemaLanguage {
|
|
94
|
+
iso: string
|
|
95
|
+
name: string
|
|
96
|
+
default: boolean
|
|
97
|
+
mandatory: boolean
|
|
98
|
+
fallback?: string
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export interface SchemaSet {
|
|
102
|
+
/** From schema/schema.toml; "0" when the file is absent. */
|
|
103
|
+
version: string
|
|
104
|
+
contentTypes: SchemaContentType[]
|
|
105
|
+
/** `schema/media-types/*.toml`: the same vocabulary under `[media-type]`, without templates or cleanup. */
|
|
106
|
+
mediaTypes?: SchemaContentType[]
|
|
107
|
+
/** `schema/member-types/*.toml`: under `[member-type]`, no templates, cleanup or allowed children. */
|
|
108
|
+
memberTypes?: SchemaContentType[]
|
|
109
|
+
dataTypes: SchemaDataType[]
|
|
110
|
+
languages: SchemaLanguage[]
|
|
111
|
+
/** `schema/forms/*.toml` — form definitions; see `docs/forms.md`. */
|
|
112
|
+
forms?: SchemaForm[]
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** A problem found while parsing or validating, always locatable. */
|
|
116
|
+
export interface SchemaProblem {
|
|
117
|
+
file: string
|
|
118
|
+
/** A dotted path into the file, e.g. `tab[1].property[0].type`. */
|
|
119
|
+
path: string
|
|
120
|
+
message: string
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** Which kind of content type a file defines. */
|
|
124
|
+
export type SchemaTypeKind = 'document' | 'media' | 'member'
|
|
125
|
+
|
|
126
|
+
/** The table header and directory for each kind. */
|
|
127
|
+
export const TYPE_KIND_FILES: Record<SchemaTypeKind, { header: string; dir: string }> = {
|
|
128
|
+
document: { header: 'content-type', dir: 'content-types' },
|
|
129
|
+
media: { header: 'media-type', dir: 'media-types' },
|
|
130
|
+
member: { header: 'member-type', dir: 'member-types' },
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** The files-map key for a type: content types by bare alias, others prefixed. */
|
|
134
|
+
export function typeFileKey(kind: SchemaTypeKind, alias: string): string {
|
|
135
|
+
return kind === 'document' ? alias : `${TYPE_KIND_FILES[kind].header}:${alias}`
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export class SchemaError extends Error {
|
|
139
|
+
constructor(readonly problems: SchemaProblem[]) {
|
|
140
|
+
super(
|
|
141
|
+
`${problems.length} schema problem${problems.length === 1 ? '' : 's'}:\n` +
|
|
142
|
+
problems.map((p) => ` ${p.file}: ${p.path ? `${p.path}: ` : ''}${p.message}`).join('\n'),
|
|
143
|
+
)
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** Every property of a type, including tabbed and grouped ones, in editor order. */
|
|
148
|
+
export function allProperties(type: SchemaContentType): SchemaProperty[] {
|
|
149
|
+
const out = [...type.properties]
|
|
150
|
+
for (const group of type.groups ?? []) out.push(...group.properties)
|
|
151
|
+
for (const tab of type.tabs) {
|
|
152
|
+
out.push(...tab.properties)
|
|
153
|
+
for (const group of tab.groups) out.push(...group.properties)
|
|
154
|
+
}
|
|
155
|
+
return out
|
|
156
|
+
}
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TOML → a form definition. Strict in the same way as the type parser: an
|
|
3
|
+
* unknown key is an error, because a misspelt one hides what someone meant.
|
|
4
|
+
*
|
|
5
|
+
* Separate from `parse.ts` only because a form has a deeper tree than a content
|
|
6
|
+
* type and the two share nothing but the reader.
|
|
7
|
+
*/
|
|
8
|
+
import {
|
|
9
|
+
FORM_CONDITION_OPERATORS,
|
|
10
|
+
FORM_FIELD_TYPE_NAMES,
|
|
11
|
+
FORM_WORKFLOW_TYPES,
|
|
12
|
+
type FormCondition,
|
|
13
|
+
type FormConditionOperator,
|
|
14
|
+
type FormConditionRule,
|
|
15
|
+
type FormFieldType,
|
|
16
|
+
type FormWorkflowTrigger,
|
|
17
|
+
type FormWorkflowType,
|
|
18
|
+
formFieldType,
|
|
19
|
+
type SchemaForm,
|
|
20
|
+
type SchemaFormField,
|
|
21
|
+
type SchemaFormGroup,
|
|
22
|
+
type SchemaFormPage,
|
|
23
|
+
type SchemaFormWorkflow,
|
|
24
|
+
} from '@crombolo/core'
|
|
25
|
+
import { type ParseResult, Reader, type Toml, toml } from './reader.ts'
|
|
26
|
+
|
|
27
|
+
const FORM_KEYS = [
|
|
28
|
+
'key',
|
|
29
|
+
'alias',
|
|
30
|
+
'name',
|
|
31
|
+
'notes',
|
|
32
|
+
'store-entries',
|
|
33
|
+
'requires-approval',
|
|
34
|
+
'submit-label',
|
|
35
|
+
'next-label',
|
|
36
|
+
'previous-label',
|
|
37
|
+
'message-on-submit',
|
|
38
|
+
'redirect-to',
|
|
39
|
+
'theme',
|
|
40
|
+
'honeypot',
|
|
41
|
+
'minimum-submit-seconds',
|
|
42
|
+
'max-entries',
|
|
43
|
+
'since',
|
|
44
|
+
] as const
|
|
45
|
+
|
|
46
|
+
const PAGE_KEYS = ['caption', 'group', 'condition'] as const
|
|
47
|
+
const GROUP_KEYS = ['caption', 'columns', 'field', 'condition'] as const
|
|
48
|
+
const FIELD_KEYS = [
|
|
49
|
+
'key',
|
|
50
|
+
'alias',
|
|
51
|
+
'type',
|
|
52
|
+
'caption',
|
|
53
|
+
'help-text',
|
|
54
|
+
'mandatory',
|
|
55
|
+
'mandatory-message',
|
|
56
|
+
'pattern',
|
|
57
|
+
'pattern-message',
|
|
58
|
+
'sensitive',
|
|
59
|
+
'css-class',
|
|
60
|
+
'autocomplete',
|
|
61
|
+
'values',
|
|
62
|
+
'placeholder',
|
|
63
|
+
'default-value',
|
|
64
|
+
'content',
|
|
65
|
+
'maxlength',
|
|
66
|
+
'rows',
|
|
67
|
+
'min',
|
|
68
|
+
'max',
|
|
69
|
+
'step',
|
|
70
|
+
'accept',
|
|
71
|
+
'max-size-kb',
|
|
72
|
+
'multiple',
|
|
73
|
+
'condition',
|
|
74
|
+
] as const
|
|
75
|
+
const CONDITION_KEYS = ['action', 'match', 'rule'] as const
|
|
76
|
+
const RULE_KEYS = ['field', 'operator', 'value'] as const
|
|
77
|
+
const WORKFLOW_KEYS = ['key', 'type', 'name', 'on', 'settings'] as const
|
|
78
|
+
|
|
79
|
+
function condition(r: Reader, value: unknown, path: string): FormCondition | undefined {
|
|
80
|
+
if (value === undefined) return undefined
|
|
81
|
+
const t = r.table(value, path)
|
|
82
|
+
if (!t) return undefined
|
|
83
|
+
r.unknown(t, CONDITION_KEYS, path)
|
|
84
|
+
|
|
85
|
+
const action = r.str(t, 'action', path) ?? 'show'
|
|
86
|
+
if (action !== 'show' && action !== 'hide')
|
|
87
|
+
r.problem(`${path}.action`, "expected 'show' or 'hide'")
|
|
88
|
+
const match = r.str(t, 'match', path) ?? 'all'
|
|
89
|
+
if (match !== 'all' && match !== 'any') r.problem(`${path}.match`, "expected 'all' or 'any'")
|
|
90
|
+
|
|
91
|
+
const rules: FormConditionRule[] = r.tables(t.rule, `${path}.rule`).map((rule, index) => {
|
|
92
|
+
const rulePath = `${path}.rule[${index}]`
|
|
93
|
+
r.unknown(rule, RULE_KEYS, rulePath)
|
|
94
|
+
const operator = r.str(rule, 'operator', rulePath, true) ?? 'is'
|
|
95
|
+
if (!FORM_CONDITION_OPERATORS.includes(operator as FormConditionOperator))
|
|
96
|
+
r.problem(
|
|
97
|
+
`${rulePath}.operator`,
|
|
98
|
+
`unknown operator; expected one of ${FORM_CONDITION_OPERATORS.join(', ')}`,
|
|
99
|
+
)
|
|
100
|
+
return {
|
|
101
|
+
field: r.str(rule, 'field', rulePath, true) ?? '',
|
|
102
|
+
operator: operator as FormConditionOperator,
|
|
103
|
+
value: r.str(rule, 'value', rulePath),
|
|
104
|
+
}
|
|
105
|
+
})
|
|
106
|
+
if (rules.length === 0) r.problem(`${path}.rule`, 'a condition needs at least one rule')
|
|
107
|
+
|
|
108
|
+
return {
|
|
109
|
+
action: action === 'hide' ? 'hide' : 'show',
|
|
110
|
+
match: match === 'any' ? 'any' : 'all',
|
|
111
|
+
rules,
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function field(r: Reader, t: Toml, path: string): SchemaFormField {
|
|
116
|
+
r.unknown(t, FIELD_KEYS, path)
|
|
117
|
+
const type = r.str(t, 'type', path, true) ?? ''
|
|
118
|
+
if (type && !formFieldType(type))
|
|
119
|
+
r.problem(
|
|
120
|
+
`${path}.type`,
|
|
121
|
+
`unknown field type; expected one of ${FORM_FIELD_TYPE_NAMES.join(', ')}`,
|
|
122
|
+
)
|
|
123
|
+
// Consent is mandatory unless the form says otherwise: a consent box that can
|
|
124
|
+
// be skipped records agreement nobody gave.
|
|
125
|
+
const mandatoryByDefault = type === 'dataConsent'
|
|
126
|
+
return {
|
|
127
|
+
key: r.str(t, 'key', path),
|
|
128
|
+
alias: r.str(t, 'alias', path, true) ?? '',
|
|
129
|
+
type: type as FormFieldType,
|
|
130
|
+
caption: r.str(t, 'caption', path, true) ?? '',
|
|
131
|
+
helpText: r.str(t, 'help-text', path),
|
|
132
|
+
mandatory: r.bool(t, 'mandatory', path, mandatoryByDefault),
|
|
133
|
+
mandatoryMessage: r.str(t, 'mandatory-message', path),
|
|
134
|
+
pattern: r.str(t, 'pattern', path),
|
|
135
|
+
patternMessage: r.str(t, 'pattern-message', path),
|
|
136
|
+
sensitive: r.bool(t, 'sensitive', path, false),
|
|
137
|
+
cssClass: r.str(t, 'css-class', path),
|
|
138
|
+
autocomplete: r.str(t, 'autocomplete', path),
|
|
139
|
+
values: r.strs(t, 'values', path),
|
|
140
|
+
placeholder: r.str(t, 'placeholder', path),
|
|
141
|
+
defaultValue: r.str(t, 'default-value', path),
|
|
142
|
+
content: r.str(t, 'content', path),
|
|
143
|
+
maxlength: r.num(t, 'maxlength', path),
|
|
144
|
+
rows: r.num(t, 'rows', path),
|
|
145
|
+
min: r.num(t, 'min', path),
|
|
146
|
+
max: r.num(t, 'max', path),
|
|
147
|
+
step: r.num(t, 'step', path),
|
|
148
|
+
accept: r.strs(t, 'accept', path),
|
|
149
|
+
maxSizeKb: r.num(t, 'max-size-kb', path),
|
|
150
|
+
...(t.multiple !== undefined ? { multiple: r.bool(t, 'multiple', path, false) } : {}),
|
|
151
|
+
condition: condition(r, t.condition, `${path}.condition`),
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function group(r: Reader, t: Toml, path: string): SchemaFormGroup {
|
|
156
|
+
r.unknown(t, GROUP_KEYS, path)
|
|
157
|
+
return {
|
|
158
|
+
caption: r.str(t, 'caption', path),
|
|
159
|
+
columns: r.num(t, 'columns', path) ?? 1,
|
|
160
|
+
fields: r.tables(t.field, `${path}.field`).map((f, i) => field(r, f, `${path}.field[${i}]`)),
|
|
161
|
+
condition: condition(r, t.condition, `${path}.condition`),
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function page(r: Reader, t: Toml, path: string): SchemaFormPage {
|
|
166
|
+
r.unknown(t, PAGE_KEYS, path)
|
|
167
|
+
return {
|
|
168
|
+
caption: r.str(t, 'caption', path),
|
|
169
|
+
groups: r.tables(t.group, `${path}.group`).map((g, i) => group(r, g, `${path}.group[${i}]`)),
|
|
170
|
+
condition: condition(r, t.condition, `${path}.condition`),
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function workflow(r: Reader, t: Toml, path: string): SchemaFormWorkflow {
|
|
175
|
+
r.unknown(t, WORKFLOW_KEYS, path)
|
|
176
|
+
const type = r.str(t, 'type', path, true) ?? ''
|
|
177
|
+
if (type && !FORM_WORKFLOW_TYPES.includes(type as FormWorkflowType))
|
|
178
|
+
r.problem(
|
|
179
|
+
`${path}.type`,
|
|
180
|
+
`unknown workflow type; expected one of ${FORM_WORKFLOW_TYPES.join(', ')}`,
|
|
181
|
+
)
|
|
182
|
+
const on = r.str(t, 'on', path) ?? 'submit'
|
|
183
|
+
if (on !== 'submit' && on !== 'approve') r.problem(`${path}.on`, "expected 'submit' or 'approve'")
|
|
184
|
+
const settings = t.settings === undefined ? {} : r.table(t.settings, `${path}.settings`)
|
|
185
|
+
return {
|
|
186
|
+
key: r.str(t, 'key', path),
|
|
187
|
+
type: type as FormWorkflowType,
|
|
188
|
+
name: r.str(t, 'name', path, true) ?? '',
|
|
189
|
+
on: on as FormWorkflowTrigger,
|
|
190
|
+
settings: (settings ?? {}) as Record<string, unknown>,
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
export function parseForm(file: string, source: string): ParseResult<SchemaForm> {
|
|
195
|
+
const r = new Reader(file)
|
|
196
|
+
const root = toml(source, r)
|
|
197
|
+
if (!root) return { value: undefined, problems: r.problems }
|
|
198
|
+
r.unknown(root, ['form', 'page', 'workflow'], '')
|
|
199
|
+
const t = r.table(root.form, 'form')
|
|
200
|
+
if (!t) {
|
|
201
|
+
r.problem('form', 'a [form] table is required')
|
|
202
|
+
return { value: undefined, problems: r.problems }
|
|
203
|
+
}
|
|
204
|
+
const path = 'form'
|
|
205
|
+
r.unknown(t, FORM_KEYS, path)
|
|
206
|
+
const value: SchemaForm = {
|
|
207
|
+
key: r.str(t, 'key', path),
|
|
208
|
+
alias: r.str(t, 'alias', path, true) ?? '',
|
|
209
|
+
name: r.str(t, 'name', path, true) ?? '',
|
|
210
|
+
notes: r.str(t, 'notes', path),
|
|
211
|
+
// Entries are kept unless a form opts out: a form that quietly discarded
|
|
212
|
+
// what people typed would be the worst possible default.
|
|
213
|
+
storeEntries: r.bool(t, 'store-entries', path, true),
|
|
214
|
+
requiresApproval: r.bool(t, 'requires-approval', path, false),
|
|
215
|
+
submitLabel: r.str(t, 'submit-label', path),
|
|
216
|
+
nextLabel: r.str(t, 'next-label', path),
|
|
217
|
+
previousLabel: r.str(t, 'previous-label', path),
|
|
218
|
+
messageOnSubmit: r.str(t, 'message-on-submit', path),
|
|
219
|
+
redirectTo: r.str(t, 'redirect-to', path),
|
|
220
|
+
theme: r.str(t, 'theme', path),
|
|
221
|
+
honeypot: r.bool(t, 'honeypot', path, true),
|
|
222
|
+
minimumSubmitSeconds: r.num(t, 'minimum-submit-seconds', path),
|
|
223
|
+
maxEntries: r.num(t, 'max-entries', path),
|
|
224
|
+
since: r.str(t, 'since', path),
|
|
225
|
+
pages: r.tables(root.page, 'page').map((p, i) => page(r, p, `page[${i}]`)),
|
|
226
|
+
workflows: r.tables(root.workflow, 'workflow').map((w, i) => workflow(r, w, `workflow[${i}]`)),
|
|
227
|
+
}
|
|
228
|
+
return { value: r.problems.length === 0 ? value : undefined, problems: r.problems }
|
|
229
|
+
}
|
package/src/parse.ts
ADDED
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TOML → model. Strict: an unknown key is an error, because a misspelt key is
|
|
3
|
+
* the most common mistake a non-developer makes and silently ignoring it would
|
|
4
|
+
* hide the thing they were trying to do.
|
|
5
|
+
*/
|
|
6
|
+
import {
|
|
7
|
+
type SchemaContentType,
|
|
8
|
+
type SchemaDataType,
|
|
9
|
+
type SchemaGroup,
|
|
10
|
+
type SchemaLanguage,
|
|
11
|
+
type SchemaProperty,
|
|
12
|
+
type SchemaTab,
|
|
13
|
+
type SchemaTypeKind,
|
|
14
|
+
TYPE_KIND_FILES,
|
|
15
|
+
} from './model.ts'
|
|
16
|
+
import { type ParseResult, Reader, type Toml, toml } from './reader.ts'
|
|
17
|
+
|
|
18
|
+
export type { ParseResult }
|
|
19
|
+
|
|
20
|
+
const PROPERTY_KEYS = [
|
|
21
|
+
'member-can-view',
|
|
22
|
+
'member-can-edit',
|
|
23
|
+
'sensitive',
|
|
24
|
+
'key',
|
|
25
|
+
'alias',
|
|
26
|
+
'name',
|
|
27
|
+
'description',
|
|
28
|
+
'notes',
|
|
29
|
+
'type',
|
|
30
|
+
'mandatory',
|
|
31
|
+
'mandatory-message',
|
|
32
|
+
'regex',
|
|
33
|
+
'regex-message',
|
|
34
|
+
'varies-by-culture',
|
|
35
|
+
'varies-by-segment',
|
|
36
|
+
'label-on-top',
|
|
37
|
+
'default',
|
|
38
|
+
'since',
|
|
39
|
+
] as const
|
|
40
|
+
const GROUP_KEYS = ['name', 'alias', 'property'] as const
|
|
41
|
+
const TAB_KEYS = ['name', 'alias', 'property', 'group'] as const
|
|
42
|
+
const TYPE_KEYS = [
|
|
43
|
+
'key',
|
|
44
|
+
'alias',
|
|
45
|
+
'name',
|
|
46
|
+
'description',
|
|
47
|
+
'notes',
|
|
48
|
+
'icon',
|
|
49
|
+
'allow-at-root',
|
|
50
|
+
'is-element',
|
|
51
|
+
'allow-in-library',
|
|
52
|
+
'varies-by-culture',
|
|
53
|
+
'varies-by-segment',
|
|
54
|
+
'compositions',
|
|
55
|
+
'allow-children',
|
|
56
|
+
'components',
|
|
57
|
+
'default-component',
|
|
58
|
+
'collection',
|
|
59
|
+
'folder',
|
|
60
|
+
'cleanup',
|
|
61
|
+
'since',
|
|
62
|
+
] as const
|
|
63
|
+
const CLEANUP_KEYS = [
|
|
64
|
+
'prevent',
|
|
65
|
+
'keep-all-newer-than-days',
|
|
66
|
+
'keep-latest-per-day-for-days',
|
|
67
|
+
] as const
|
|
68
|
+
const DATA_TYPE_KEYS = [
|
|
69
|
+
'key',
|
|
70
|
+
'alias',
|
|
71
|
+
'name',
|
|
72
|
+
'notes',
|
|
73
|
+
'editor',
|
|
74
|
+
'editor-ui',
|
|
75
|
+
'folder',
|
|
76
|
+
'config',
|
|
77
|
+
'since',
|
|
78
|
+
] as const
|
|
79
|
+
const LANGUAGE_KEYS = ['iso', 'name', 'default', 'mandatory', 'fallback'] as const
|
|
80
|
+
|
|
81
|
+
function property(r: Reader, t: Toml, path: string): SchemaProperty {
|
|
82
|
+
r.unknown(t, PROPERTY_KEYS, path)
|
|
83
|
+
return {
|
|
84
|
+
key: r.str(t, 'key', path),
|
|
85
|
+
alias: r.str(t, 'alias', path, true) ?? '',
|
|
86
|
+
name: r.str(t, 'name', path, true) ?? '',
|
|
87
|
+
description: r.str(t, 'description', path),
|
|
88
|
+
notes: r.str(t, 'notes', path),
|
|
89
|
+
type: r.str(t, 'type', path, true) ?? '',
|
|
90
|
+
mandatory: r.bool(t, 'mandatory', path, false),
|
|
91
|
+
mandatoryMessage: r.str(t, 'mandatory-message', path),
|
|
92
|
+
regex: r.str(t, 'regex', path),
|
|
93
|
+
regexMessage: r.str(t, 'regex-message', path),
|
|
94
|
+
variesByCulture: r.bool(t, 'varies-by-culture', path, false),
|
|
95
|
+
variesBySegment: r.bool(t, 'varies-by-segment', path, false),
|
|
96
|
+
labelOnTop: r.bool(t, 'label-on-top', path, false),
|
|
97
|
+
...(t['member-can-view'] !== undefined
|
|
98
|
+
? { memberCanView: r.bool(t, 'member-can-view', path, false) }
|
|
99
|
+
: {}),
|
|
100
|
+
...(t['member-can-edit'] !== undefined
|
|
101
|
+
? { memberCanEdit: r.bool(t, 'member-can-edit', path, false) }
|
|
102
|
+
: {}),
|
|
103
|
+
...(t.sensitive !== undefined ? { sensitive: r.bool(t, 'sensitive', path, false) } : {}),
|
|
104
|
+
default: t.default,
|
|
105
|
+
since: r.str(t, 'since', path),
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function group(r: Reader, t: Toml, path: string): SchemaGroup {
|
|
110
|
+
r.unknown(t, GROUP_KEYS, path)
|
|
111
|
+
return {
|
|
112
|
+
name: r.str(t, 'name', path, true) ?? '',
|
|
113
|
+
alias: r.str(t, 'alias', path),
|
|
114
|
+
properties: r
|
|
115
|
+
.tables(t.property, `${path}.property`)
|
|
116
|
+
.map((p, i) => property(r, p, `${path}.property[${i}]`)),
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function tab(r: Reader, t: Toml, path: string): SchemaTab {
|
|
121
|
+
r.unknown(t, TAB_KEYS, path)
|
|
122
|
+
return {
|
|
123
|
+
name: r.str(t, 'name', path, true) ?? '',
|
|
124
|
+
alias: r.str(t, 'alias', path),
|
|
125
|
+
properties: r
|
|
126
|
+
.tables(t.property, `${path}.property`)
|
|
127
|
+
.map((p, i) => property(r, p, `${path}.property[${i}]`)),
|
|
128
|
+
groups: r.tables(t.group, `${path}.group`).map((g, i) => group(r, g, `${path}.group[${i}]`)),
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** Media types have no templates and no version cleanup policy. */
|
|
133
|
+
const MEDIA_TYPE_KEYS = TYPE_KEYS.filter(
|
|
134
|
+
(k) => k !== 'components' && k !== 'default-component' && k !== 'cleanup',
|
|
135
|
+
)
|
|
136
|
+
/** Member types, besides, allow no children: members are not a tree. */
|
|
137
|
+
const MEMBER_TYPE_KEYS = MEDIA_TYPE_KEYS.filter((k) => k !== 'allow-children')
|
|
138
|
+
|
|
139
|
+
export function parseContentType(
|
|
140
|
+
file: string,
|
|
141
|
+
source: string,
|
|
142
|
+
kind: SchemaTypeKind = 'document',
|
|
143
|
+
): ParseResult<SchemaContentType> {
|
|
144
|
+
const r = new Reader(file)
|
|
145
|
+
const root = toml(source, r)
|
|
146
|
+
if (!root) return { value: undefined, problems: r.problems }
|
|
147
|
+
const header = TYPE_KIND_FILES[kind].header
|
|
148
|
+
r.unknown(root, [header, 'tab', 'group', 'property'], '')
|
|
149
|
+
const t = r.table(root[header], header)
|
|
150
|
+
if (!t) {
|
|
151
|
+
r.problem(header, `a [${header}] table is required`)
|
|
152
|
+
return { value: undefined, problems: r.problems }
|
|
153
|
+
}
|
|
154
|
+
const path = header
|
|
155
|
+
r.unknown(
|
|
156
|
+
t,
|
|
157
|
+
kind === 'media' ? MEDIA_TYPE_KEYS : kind === 'member' ? MEMBER_TYPE_KEYS : TYPE_KEYS,
|
|
158
|
+
path,
|
|
159
|
+
)
|
|
160
|
+
const cleanupTable = t.cleanup === undefined ? undefined : r.table(t.cleanup, `${path}.cleanup`)
|
|
161
|
+
if (cleanupTable) r.unknown(cleanupTable, CLEANUP_KEYS, `${path}.cleanup`)
|
|
162
|
+
const value: SchemaContentType = {
|
|
163
|
+
key: r.str(t, 'key', path),
|
|
164
|
+
alias: r.str(t, 'alias', path, true) ?? '',
|
|
165
|
+
name: r.str(t, 'name', path, true) ?? '',
|
|
166
|
+
description: r.str(t, 'description', path),
|
|
167
|
+
notes: r.str(t, 'notes', path),
|
|
168
|
+
icon: r.str(t, 'icon', path) ?? 'icon-document',
|
|
169
|
+
allowAtRoot: r.bool(t, 'allow-at-root', path, false),
|
|
170
|
+
isElement: r.bool(t, 'is-element', path, false),
|
|
171
|
+
allowInLibrary: r.bool(t, 'allow-in-library', path, false),
|
|
172
|
+
variesByCulture: r.bool(t, 'varies-by-culture', path, false),
|
|
173
|
+
variesBySegment: r.bool(t, 'varies-by-segment', path, false),
|
|
174
|
+
compositions: r.strs(t, 'compositions', path),
|
|
175
|
+
allowChildren: r.strs(t, 'allow-children', path),
|
|
176
|
+
components: r.strs(t, 'components', path),
|
|
177
|
+
defaultComponent: r.str(t, 'default-component', path),
|
|
178
|
+
collection: r.str(t, 'collection', path),
|
|
179
|
+
folder: r.str(t, 'folder', path),
|
|
180
|
+
cleanup: {
|
|
181
|
+
prevent: cleanupTable ? r.bool(cleanupTable, 'prevent', `${path}.cleanup`, false) : false,
|
|
182
|
+
keepAllNewerThanDays: cleanupTable
|
|
183
|
+
? r.num(cleanupTable, 'keep-all-newer-than-days', `${path}.cleanup`)
|
|
184
|
+
: undefined,
|
|
185
|
+
keepLatestPerDayForDays: cleanupTable
|
|
186
|
+
? r.num(cleanupTable, 'keep-latest-per-day-for-days', `${path}.cleanup`)
|
|
187
|
+
: undefined,
|
|
188
|
+
},
|
|
189
|
+
since: r.str(t, 'since', path),
|
|
190
|
+
properties: r.tables(root.property, 'property').map((p, i) => property(r, p, `property[${i}]`)),
|
|
191
|
+
tabs: r.tables(root.tab, 'tab').map((x, i) => tab(r, x, `tab[${i}]`)),
|
|
192
|
+
groups: r.tables(root.group, 'group').map((g, i) => group(r, g, `group[${i}]`)),
|
|
193
|
+
}
|
|
194
|
+
return { value: r.problems.length === 0 ? value : undefined, problems: r.problems }
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
export function parseMediaType(file: string, source: string): ParseResult<SchemaContentType> {
|
|
198
|
+
return parseContentType(file, source, 'media')
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
export function parseMemberType(file: string, source: string): ParseResult<SchemaContentType> {
|
|
202
|
+
return parseContentType(file, source, 'member')
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
export function parseDataType(file: string, source: string): ParseResult<SchemaDataType> {
|
|
206
|
+
const r = new Reader(file)
|
|
207
|
+
const root = toml(source, r)
|
|
208
|
+
if (!root) return { value: undefined, problems: r.problems }
|
|
209
|
+
r.unknown(root, ['data-type'], '')
|
|
210
|
+
const t = r.table(root['data-type'], 'data-type')
|
|
211
|
+
if (!t) {
|
|
212
|
+
r.problem('data-type', 'a [data-type] table is required')
|
|
213
|
+
return { value: undefined, problems: r.problems }
|
|
214
|
+
}
|
|
215
|
+
const path = 'data-type'
|
|
216
|
+
r.unknown(t, DATA_TYPE_KEYS, path)
|
|
217
|
+
const config = t.config === undefined ? {} : r.table(t.config, `${path}.config`)
|
|
218
|
+
const value: SchemaDataType = {
|
|
219
|
+
key: r.str(t, 'key', path),
|
|
220
|
+
alias: r.str(t, 'alias', path, true) ?? '',
|
|
221
|
+
name: r.str(t, 'name', path, true) ?? '',
|
|
222
|
+
notes: r.str(t, 'notes', path),
|
|
223
|
+
editor: r.str(t, 'editor', path, true) ?? '',
|
|
224
|
+
editorUi: r.str(t, 'editor-ui', path),
|
|
225
|
+
folder: r.str(t, 'folder', path),
|
|
226
|
+
config: (config ?? {}) as Record<string, unknown>,
|
|
227
|
+
since: r.str(t, 'since', path),
|
|
228
|
+
}
|
|
229
|
+
return { value: r.problems.length === 0 ? value : undefined, problems: r.problems }
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
export function parseLanguages(file: string, source: string): ParseResult<SchemaLanguage[]> {
|
|
233
|
+
const r = new Reader(file)
|
|
234
|
+
const root = toml(source, r)
|
|
235
|
+
if (!root) return { value: undefined, problems: r.problems }
|
|
236
|
+
r.unknown(root, ['language'], '')
|
|
237
|
+
const value = r.tables(root.language, 'language').map((t, i) => {
|
|
238
|
+
const path = `language[${i}]`
|
|
239
|
+
r.unknown(t, LANGUAGE_KEYS, path)
|
|
240
|
+
return {
|
|
241
|
+
iso: r.str(t, 'iso', path, true) ?? '',
|
|
242
|
+
name: r.str(t, 'name', path, true) ?? '',
|
|
243
|
+
default: r.bool(t, 'default', path, false),
|
|
244
|
+
mandatory: r.bool(t, 'mandatory', path, false),
|
|
245
|
+
fallback: r.str(t, 'fallback', path),
|
|
246
|
+
} satisfies SchemaLanguage
|
|
247
|
+
})
|
|
248
|
+
return { value: r.problems.length === 0 ? value : undefined, problems: r.problems }
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
export function parseSchemaVersion(file: string, source: string): ParseResult<string> {
|
|
252
|
+
const r = new Reader(file)
|
|
253
|
+
const root = toml(source, r)
|
|
254
|
+
if (!root) return { value: undefined, problems: r.problems }
|
|
255
|
+
r.unknown(root, ['schema'], '')
|
|
256
|
+
const t = r.table(root.schema, 'schema')
|
|
257
|
+
const version = t ? r.str(t, 'version', 'schema', true) : undefined
|
|
258
|
+
if (t) r.unknown(t, ['version'], 'schema')
|
|
259
|
+
return { value: r.problems.length === 0 ? version : undefined, problems: r.problems }
|
|
260
|
+
}
|