@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/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
+ }