@crombolo/schema 0.11.0 → 0.12.1
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 +3 -3
- package/src/check.ts +53 -20
- package/src/export.ts +4 -1
- package/src/forms.ts +32 -326
- package/src/load.ts +32 -6
- package/src/model.ts +8 -0
- package/src/parse-forms.ts +54 -21
- package/src/parse.ts +26 -2
- package/src/sync.ts +21 -1
- package/src/validate-forms.ts +78 -10
- package/src/write-forms.ts +9 -0
- package/src/write.ts +5 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@crombolo/schema",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.12.1",
|
|
4
4
|
"description": "Schema-as-code for crombolo: TOML document, media and member type definitions.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Crombolo",
|
|
@@ -28,7 +28,7 @@
|
|
|
28
28
|
"access": "public"
|
|
29
29
|
},
|
|
30
30
|
"dependencies": {
|
|
31
|
-
"@crombolo/core": "0.
|
|
32
|
-
"@crombolo/data": "0.
|
|
31
|
+
"@crombolo/core": "0.12.1",
|
|
32
|
+
"@crombolo/data": "0.12.1"
|
|
33
33
|
}
|
|
34
34
|
}
|
package/src/check.ts
CHANGED
|
@@ -17,6 +17,9 @@ import {
|
|
|
17
17
|
latestPreparedState,
|
|
18
18
|
pendingMigrations,
|
|
19
19
|
readLedger,
|
|
20
|
+
SYSTEM_CONTENT_TYPES,
|
|
21
|
+
SYSTEM_MEDIA_TYPES,
|
|
22
|
+
SYSTEM_MEMBER_TYPE,
|
|
20
23
|
} from '@crombolo/data'
|
|
21
24
|
import { BUILTIN_DATA_TYPES, storageTypeFor } from './builtin.ts'
|
|
22
25
|
import type { LoadedSchema } from './load.ts'
|
|
@@ -213,26 +216,56 @@ export async function runCheck(
|
|
|
213
216
|
const ledgered = new Set(ledger.filter((r) => r.kind === 'value').map((r) => r.name))
|
|
214
217
|
const prepared = await latestPreparedState(db)
|
|
215
218
|
|
|
216
|
-
// ---- types removed while they have content
|
|
217
|
-
const
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
219
|
+
// ---- types removed while they have content; the shipped types need no file
|
|
220
|
+
const shipped = new Set([
|
|
221
|
+
...SYSTEM_MEDIA_TYPES.map((t) => t.key),
|
|
222
|
+
...SYSTEM_CONTENT_TYPES.map((t) => t.key),
|
|
223
|
+
SYSTEM_MEMBER_TYPE.key,
|
|
224
|
+
])
|
|
225
|
+
const kinds = [
|
|
226
|
+
{
|
|
227
|
+
kind: 'document' as const,
|
|
228
|
+
files: loaded.set.contentTypes,
|
|
229
|
+
noun: 'content type',
|
|
230
|
+
items: 'document(s)',
|
|
231
|
+
},
|
|
232
|
+
{
|
|
233
|
+
kind: 'media' as const,
|
|
234
|
+
files: loaded.set.mediaTypes ?? [],
|
|
235
|
+
noun: 'media type',
|
|
236
|
+
items: 'media item(s)',
|
|
237
|
+
},
|
|
238
|
+
{
|
|
239
|
+
kind: 'member' as const,
|
|
240
|
+
files: loaded.set.memberTypes ?? [],
|
|
241
|
+
noun: 'member type',
|
|
242
|
+
items: 'member(s)',
|
|
243
|
+
},
|
|
244
|
+
]
|
|
245
|
+
for (const { kind, files, noun, items } of kinds) {
|
|
246
|
+
const repo = kind === 'document' ? types : new ContentTypeRepository(db, { kind })
|
|
247
|
+
const inFiles = new Set(files.map((t) => t.alias.toLowerCase()))
|
|
248
|
+
const dbTypes = await db.query<{ unique_id: string; alias: string }>(
|
|
249
|
+
'SELECT n.unique_id, ct.alias FROM content_type ct JOIN node n ON n.id = ct.node_id WHERE ct.retired_at IS NULL AND n.node_object_type = ?',
|
|
250
|
+
[repo.objectType],
|
|
251
|
+
)
|
|
252
|
+
for (const row of dbTypes) {
|
|
253
|
+
const key = String(row.unique_id).toLowerCase()
|
|
254
|
+
if (inFiles.has(String(row.alias).toLowerCase()) || shipped.has(key)) continue
|
|
255
|
+
const count = await repo.contentCount(key)
|
|
256
|
+
if (count === 0) continue
|
|
257
|
+
findings.push({
|
|
258
|
+
kind: 'blocking',
|
|
259
|
+
code: 'type-removed-with-content',
|
|
260
|
+
subjectType: `${kind}-type` as const,
|
|
261
|
+
subjectKey: key,
|
|
262
|
+
subjectName: String(row.alias),
|
|
263
|
+
propertyAlias: null,
|
|
264
|
+
culture: null,
|
|
265
|
+
message: `${noun} "${row.alias}" was removed from schema/ but ${count} ${items} of it exist`,
|
|
266
|
+
link: null,
|
|
267
|
+
})
|
|
268
|
+
}
|
|
236
269
|
}
|
|
237
270
|
|
|
238
271
|
// ---- properties that need data: newly mandatory, or changing editor
|
package/src/export.ts
CHANGED
|
@@ -18,6 +18,7 @@ import {
|
|
|
18
18
|
type Db,
|
|
19
19
|
FolderRepository,
|
|
20
20
|
LanguageRepository,
|
|
21
|
+
readUrlOptions,
|
|
21
22
|
SYSTEM_CONTENT_TYPES,
|
|
22
23
|
SYSTEM_MEDIA_TYPES,
|
|
23
24
|
} from '@crombolo/data'
|
|
@@ -188,6 +189,7 @@ function isUnchangedBuiltin(dataType: SchemaDataType): boolean {
|
|
|
188
189
|
|
|
189
190
|
/** Every live definition in the database as a schema set; keys are always present. */
|
|
190
191
|
export async function exportSchemaSet(db: Db, version: string): Promise<SchemaSet> {
|
|
192
|
+
const urls = await readUrlOptions(db)
|
|
191
193
|
const types = new ContentTypeRepository(db)
|
|
192
194
|
const dataTypes = new DataTypeRepository(db)
|
|
193
195
|
const templates = new ComponentRepository(db)
|
|
@@ -330,6 +332,7 @@ export async function exportSchemaSet(db: Db, version: string): Promise<SchemaSe
|
|
|
330
332
|
memberTypes,
|
|
331
333
|
dataTypes: exportedDataTypes,
|
|
332
334
|
languages,
|
|
335
|
+
...(urls.hideTopLevel ? {} : { urls }),
|
|
333
336
|
}
|
|
334
337
|
}
|
|
335
338
|
|
|
@@ -342,7 +345,7 @@ export function writeSchemaFiles(schemaDir: string, set: SchemaSet): string[] {
|
|
|
342
345
|
writeFileSync(file, content)
|
|
343
346
|
written.push(file)
|
|
344
347
|
}
|
|
345
|
-
put('schema.toml', writeSchemaVersion(set.version))
|
|
348
|
+
put('schema.toml', writeSchemaVersion(set.version, set.urls))
|
|
346
349
|
for (const t of set.contentTypes)
|
|
347
350
|
put(join(TYPE_KIND_FILES.document.dir, fileNameFor(t.alias)), writeContentType(t))
|
|
348
351
|
for (const t of set.mediaTypes ?? [])
|
package/src/forms.ts
CHANGED
|
@@ -1,328 +1,34 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The form model
|
|
3
|
-
*
|
|
4
|
-
* A form definition is structure, so it is a file like every other type
|
|
5
|
-
* (`docs/forms.md`). Field types are an internal registry rather than an
|
|
6
|
-
* extension point: the TOML vocabulary, the validator, the renderer and the
|
|
7
|
-
* entry value shape all have to agree, and freezing that contract before
|
|
8
|
-
* anything has stressed it would be a promise made too early.
|
|
2
|
+
* The form model and its closed set of field types. It lives in `@crombolo/core`,
|
|
3
|
+
* which the renderer shares; re-exported here for the schema package's users.
|
|
9
4
|
*/
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
type
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
presentational: boolean
|
|
41
|
-
/** The settings this type accepts beyond the ones every field has. */
|
|
42
|
-
settings: readonly FormFieldSetting[]
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
export type FormFieldSetting =
|
|
46
|
-
| 'placeholder'
|
|
47
|
-
| 'maxlength'
|
|
48
|
-
| 'rows'
|
|
49
|
-
| 'min'
|
|
50
|
-
| 'max'
|
|
51
|
-
| 'step'
|
|
52
|
-
| 'accept'
|
|
53
|
-
| 'max-size-kb'
|
|
54
|
-
| 'content'
|
|
55
|
-
| 'default-value'
|
|
56
|
-
| 'multiple'
|
|
57
|
-
|
|
58
|
-
const TEXTUAL = ['placeholder', 'maxlength', 'default-value'] as const
|
|
59
|
-
|
|
60
|
-
export const FORM_FIELD_TYPES: readonly FormFieldTypeInfo[] = [
|
|
61
|
-
{
|
|
62
|
-
type: 'shortAnswer',
|
|
63
|
-
label: 'Short answer',
|
|
64
|
-
stores: 'string',
|
|
65
|
-
choices: false,
|
|
66
|
-
presentational: false,
|
|
67
|
-
settings: TEXTUAL,
|
|
68
|
-
},
|
|
69
|
-
{
|
|
70
|
-
type: 'longAnswer',
|
|
71
|
-
label: 'Long answer',
|
|
72
|
-
stores: 'string',
|
|
73
|
-
choices: false,
|
|
74
|
-
presentational: false,
|
|
75
|
-
settings: [...TEXTUAL, 'rows'],
|
|
76
|
-
},
|
|
77
|
-
{
|
|
78
|
-
type: 'email',
|
|
79
|
-
label: 'Email address',
|
|
80
|
-
stores: 'string',
|
|
81
|
-
choices: false,
|
|
82
|
-
presentational: false,
|
|
83
|
-
settings: TEXTUAL,
|
|
84
|
-
},
|
|
85
|
-
{
|
|
86
|
-
type: 'number',
|
|
87
|
-
label: 'Number',
|
|
88
|
-
stores: 'number',
|
|
89
|
-
choices: false,
|
|
90
|
-
presentational: false,
|
|
91
|
-
settings: ['placeholder', 'min', 'max', 'step', 'default-value'],
|
|
92
|
-
},
|
|
93
|
-
{
|
|
94
|
-
type: 'date',
|
|
95
|
-
label: 'Date',
|
|
96
|
-
stores: 'string',
|
|
97
|
-
choices: false,
|
|
98
|
-
presentational: false,
|
|
99
|
-
settings: ['min', 'max', 'default-value'],
|
|
100
|
-
},
|
|
101
|
-
{
|
|
102
|
-
type: 'checkbox',
|
|
103
|
-
label: 'Checkbox',
|
|
104
|
-
stores: 'boolean',
|
|
105
|
-
choices: false,
|
|
106
|
-
presentational: false,
|
|
107
|
-
settings: ['default-value'],
|
|
108
|
-
},
|
|
109
|
-
{
|
|
110
|
-
type: 'dropdown',
|
|
111
|
-
label: 'Dropdown',
|
|
112
|
-
stores: 'string',
|
|
113
|
-
choices: true,
|
|
114
|
-
presentational: false,
|
|
115
|
-
settings: ['default-value'],
|
|
116
|
-
},
|
|
117
|
-
{
|
|
118
|
-
type: 'singleChoice',
|
|
119
|
-
label: 'Single choice',
|
|
120
|
-
stores: 'string',
|
|
121
|
-
choices: true,
|
|
122
|
-
presentational: false,
|
|
123
|
-
settings: ['default-value'],
|
|
124
|
-
},
|
|
125
|
-
{
|
|
126
|
-
type: 'multipleChoice',
|
|
127
|
-
label: 'Multiple choice',
|
|
128
|
-
stores: 'strings',
|
|
129
|
-
choices: true,
|
|
130
|
-
presentational: false,
|
|
131
|
-
settings: [],
|
|
132
|
-
},
|
|
133
|
-
{
|
|
134
|
-
type: 'fileUpload',
|
|
135
|
-
label: 'File upload',
|
|
136
|
-
stores: 'files',
|
|
137
|
-
choices: false,
|
|
138
|
-
presentational: false,
|
|
139
|
-
settings: ['accept', 'max-size-kb', 'multiple'],
|
|
140
|
-
},
|
|
141
|
-
{
|
|
142
|
-
// Mandatory by default: a consent field that can be skipped is not consent.
|
|
143
|
-
type: 'dataConsent',
|
|
144
|
-
label: 'Data consent',
|
|
145
|
-
stores: 'boolean',
|
|
146
|
-
choices: false,
|
|
147
|
-
presentational: false,
|
|
148
|
-
settings: [],
|
|
149
|
-
},
|
|
150
|
-
{
|
|
151
|
-
type: 'titleAndDescription',
|
|
152
|
-
label: 'Title and description',
|
|
153
|
-
stores: 'none',
|
|
154
|
-
choices: false,
|
|
155
|
-
presentational: true,
|
|
156
|
-
settings: ['content'],
|
|
157
|
-
},
|
|
158
|
-
{
|
|
159
|
-
type: 'richText',
|
|
160
|
-
label: 'Rich text',
|
|
161
|
-
stores: 'none',
|
|
162
|
-
choices: false,
|
|
163
|
-
presentational: true,
|
|
164
|
-
settings: ['content'],
|
|
165
|
-
},
|
|
166
|
-
{
|
|
167
|
-
type: 'hidden',
|
|
168
|
-
label: 'Hidden',
|
|
169
|
-
stores: 'string',
|
|
170
|
-
choices: false,
|
|
171
|
-
presentational: false,
|
|
172
|
-
settings: ['default-value'],
|
|
173
|
-
},
|
|
174
|
-
]
|
|
175
|
-
|
|
176
|
-
const BY_TYPE = new Map(FORM_FIELD_TYPES.map((info) => [info.type, info]))
|
|
177
|
-
|
|
178
|
-
export function formFieldType(type: string): FormFieldTypeInfo | undefined {
|
|
179
|
-
return BY_TYPE.get(type as FormFieldType)
|
|
180
|
-
}
|
|
181
|
-
|
|
182
|
-
export const FORM_FIELD_TYPE_NAMES: readonly string[] = FORM_FIELD_TYPES.map((info) => info.type)
|
|
183
|
-
|
|
184
|
-
/** Password is absent on purpose: a form that collects one should not exist. */
|
|
185
|
-
export type FormConditionOperator =
|
|
186
|
-
| 'is'
|
|
187
|
-
| 'isNot'
|
|
188
|
-
| 'contains'
|
|
189
|
-
| 'doesNotContain'
|
|
190
|
-
| 'startsWith'
|
|
191
|
-
| 'endsWith'
|
|
192
|
-
| 'greaterThan'
|
|
193
|
-
| 'lessThan'
|
|
194
|
-
| 'isEmpty'
|
|
195
|
-
| 'isNotEmpty'
|
|
196
|
-
|
|
197
|
-
export const FORM_CONDITION_OPERATORS: readonly FormConditionOperator[] = [
|
|
198
|
-
'is',
|
|
199
|
-
'isNot',
|
|
200
|
-
'contains',
|
|
201
|
-
'doesNotContain',
|
|
202
|
-
'startsWith',
|
|
203
|
-
'endsWith',
|
|
204
|
-
'greaterThan',
|
|
205
|
-
'lessThan',
|
|
206
|
-
'isEmpty',
|
|
207
|
-
'isNotEmpty',
|
|
208
|
-
]
|
|
209
|
-
|
|
210
|
-
/** The two operators that take no value, because the value is the emptiness. */
|
|
211
|
-
export const VALUELESS_OPERATORS: readonly FormConditionOperator[] = ['isEmpty', 'isNotEmpty']
|
|
212
|
-
|
|
213
|
-
export interface FormConditionRule {
|
|
214
|
-
/** Another field's alias, which must appear before this one. */
|
|
215
|
-
field: string
|
|
216
|
-
operator: FormConditionOperator
|
|
217
|
-
value?: string
|
|
218
|
-
}
|
|
219
|
-
|
|
220
|
-
export interface FormCondition {
|
|
221
|
-
action: 'show' | 'hide'
|
|
222
|
-
match: 'all' | 'any'
|
|
223
|
-
rules: FormConditionRule[]
|
|
224
|
-
}
|
|
225
|
-
|
|
226
|
-
export interface SchemaFormField {
|
|
227
|
-
key?: string
|
|
228
|
-
alias: string
|
|
229
|
-
type: FormFieldType
|
|
230
|
-
caption: string
|
|
231
|
-
helpText?: string
|
|
232
|
-
mandatory: boolean
|
|
233
|
-
mandatoryMessage?: string
|
|
234
|
-
/** A regular expression the value must match, and what to say when it does not. */
|
|
235
|
-
pattern?: string
|
|
236
|
-
patternMessage?: string
|
|
237
|
-
/** Hidden from anyone without the sensitive-data permission, in the API and in exports. */
|
|
238
|
-
sensitive: boolean
|
|
239
|
-
cssClass?: string
|
|
240
|
-
autocomplete?: string
|
|
241
|
-
/** A choice list, for the types that take one. */
|
|
242
|
-
values: string[]
|
|
243
|
-
placeholder?: string
|
|
244
|
-
defaultValue?: string
|
|
245
|
-
content?: string
|
|
246
|
-
maxlength?: number
|
|
247
|
-
rows?: number
|
|
248
|
-
min?: number
|
|
249
|
-
max?: number
|
|
250
|
-
step?: number
|
|
251
|
-
accept: string[]
|
|
252
|
-
maxSizeKb?: number
|
|
253
|
-
multiple?: boolean
|
|
254
|
-
condition?: FormCondition
|
|
255
|
-
}
|
|
256
|
-
|
|
257
|
-
export interface SchemaFormGroup {
|
|
258
|
-
caption?: string
|
|
259
|
-
/** A simple column count; the renderer lays the fields out across it. */
|
|
260
|
-
columns: number
|
|
261
|
-
fields: SchemaFormField[]
|
|
262
|
-
condition?: FormCondition
|
|
263
|
-
}
|
|
264
|
-
|
|
265
|
-
export interface SchemaFormPage {
|
|
266
|
-
caption?: string
|
|
267
|
-
groups: SchemaFormGroup[]
|
|
268
|
-
condition?: FormCondition
|
|
269
|
-
}
|
|
270
|
-
|
|
271
|
-
export type FormWorkflowType = 'sendEmail' | 'saveAsContent' | 'sendToUrl'
|
|
272
|
-
|
|
273
|
-
export const FORM_WORKFLOW_TYPES: readonly FormWorkflowType[] = [
|
|
274
|
-
'sendEmail',
|
|
275
|
-
'saveAsContent',
|
|
276
|
-
'sendToUrl',
|
|
277
|
-
]
|
|
278
|
-
|
|
279
|
-
/** The state transition a workflow runs on. */
|
|
280
|
-
export type FormWorkflowTrigger = 'submit' | 'approve'
|
|
281
|
-
|
|
282
|
-
export interface SchemaFormWorkflow {
|
|
283
|
-
key?: string
|
|
284
|
-
type: FormWorkflowType
|
|
285
|
-
name: string
|
|
286
|
-
on: FormWorkflowTrigger
|
|
287
|
-
/** Per-type, so it is checked by the validator rather than by the shape. */
|
|
288
|
-
settings: Record<string, unknown>
|
|
289
|
-
}
|
|
290
|
-
|
|
291
|
-
export interface SchemaForm {
|
|
292
|
-
key?: string
|
|
293
|
-
alias: string
|
|
294
|
-
name: string
|
|
295
|
-
notes?: string
|
|
296
|
-
/** Whether a submission is kept. Off makes the form fire workflows and store nothing. */
|
|
297
|
-
storeEntries: boolean
|
|
298
|
-
/** Entries wait as `submitted` until someone approves them. */
|
|
299
|
-
requiresApproval: boolean
|
|
300
|
-
submitLabel?: string
|
|
301
|
-
nextLabel?: string
|
|
302
|
-
previousLabel?: string
|
|
303
|
-
/** Shown in place of the form after a submission, when there is no redirect. */
|
|
304
|
-
messageOnSubmit?: string
|
|
305
|
-
/** A document key to send the visitor to instead of showing a message. */
|
|
306
|
-
redirectTo?: string
|
|
307
|
-
/** A theme directory under `components/Forms/`. */
|
|
308
|
-
theme?: string
|
|
309
|
-
/** A field real visitors never fill; a filled one is marked as spam. */
|
|
310
|
-
honeypot: boolean
|
|
311
|
-
/** A submission faster than this is marked as spam. */
|
|
312
|
-
minimumSubmitSeconds?: number
|
|
313
|
-
/** Refused once this many entries exist, for a form with a hard cap. */
|
|
314
|
-
maxEntries?: number
|
|
315
|
-
since?: string
|
|
316
|
-
pages: SchemaFormPage[]
|
|
317
|
-
workflows: SchemaFormWorkflow[]
|
|
318
|
-
}
|
|
319
|
-
|
|
320
|
-
/** Every field in a form, in the order a visitor meets them. */
|
|
321
|
-
export function allFormFields(form: SchemaForm): SchemaFormField[] {
|
|
322
|
-
return form.pages.flatMap((page) => page.groups.flatMap((group) => group.fields))
|
|
323
|
-
}
|
|
324
|
-
|
|
325
|
-
/** Every field that stores something, which is what an entry and an export carry. */
|
|
326
|
-
export function storingFormFields(form: SchemaForm): SchemaFormField[] {
|
|
327
|
-
return allFormFields(form).filter((field) => formFieldType(field.type)?.stores !== 'none')
|
|
328
|
-
}
|
|
5
|
+
export {
|
|
6
|
+
allFormFields,
|
|
7
|
+
CHOICE_SOURCE_KINDS,
|
|
8
|
+
type ChoiceOption,
|
|
9
|
+
type ChoiceSource,
|
|
10
|
+
type ChoiceSourceKind,
|
|
11
|
+
choiceOptions,
|
|
12
|
+
FORM_CONDITION_OPERATORS,
|
|
13
|
+
FORM_FIELD_TYPE_NAMES,
|
|
14
|
+
FORM_FIELD_TYPES,
|
|
15
|
+
FORM_WORKFLOW_TYPES,
|
|
16
|
+
type FormCondition,
|
|
17
|
+
type FormConditionOperator,
|
|
18
|
+
type FormConditionRule,
|
|
19
|
+
type FormFieldSetting,
|
|
20
|
+
type FormFieldType,
|
|
21
|
+
type FormFieldTypeInfo,
|
|
22
|
+
type FormValueKind,
|
|
23
|
+
type FormWorkflowTrigger,
|
|
24
|
+
type FormWorkflowType,
|
|
25
|
+
formFieldType,
|
|
26
|
+
type SchemaForm,
|
|
27
|
+
type SchemaFormField,
|
|
28
|
+
type SchemaFormGroup,
|
|
29
|
+
type SchemaFormPage,
|
|
30
|
+
type SchemaFormWorkflow,
|
|
31
|
+
storingFormFields,
|
|
32
|
+
VALUELESS_OPERATORS,
|
|
33
|
+
withResolvedChoices,
|
|
34
|
+
} from '@crombolo/core'
|
package/src/load.ts
CHANGED
|
@@ -4,9 +4,11 @@
|
|
|
4
4
|
* The hash is over canonical serialisations, sorted — so it does not change
|
|
5
5
|
* with file order, whitespace or comments, only with meaning.
|
|
6
6
|
*/
|
|
7
|
+
|
|
7
8
|
import { createHash } from 'node:crypto'
|
|
8
9
|
import { existsSync, readdirSync, readFileSync } from 'node:fs'
|
|
9
10
|
import { join } from 'node:path'
|
|
11
|
+
import type { FormTypes } from '@crombolo/core'
|
|
10
12
|
import type { SchemaForm } from './forms.ts'
|
|
11
13
|
import type {
|
|
12
14
|
SchemaContentType,
|
|
@@ -14,6 +16,7 @@ import type {
|
|
|
14
16
|
SchemaLanguage,
|
|
15
17
|
SchemaProblem,
|
|
16
18
|
SchemaSet,
|
|
19
|
+
SchemaUrls,
|
|
17
20
|
} from './model.ts'
|
|
18
21
|
import { TYPE_KIND_FILES, typeFileKey } from './model.ts'
|
|
19
22
|
import {
|
|
@@ -22,7 +25,7 @@ import {
|
|
|
22
25
|
parseLanguages,
|
|
23
26
|
parseMediaType,
|
|
24
27
|
parseMemberType,
|
|
25
|
-
|
|
28
|
+
parseSchemaSettings,
|
|
26
29
|
} from './parse.ts'
|
|
27
30
|
import { parseForm } from './parse-forms.ts'
|
|
28
31
|
import {
|
|
@@ -49,22 +52,34 @@ function tomlFiles(dir: string): string[] {
|
|
|
49
52
|
.map((name) => join(dir, name))
|
|
50
53
|
}
|
|
51
54
|
|
|
52
|
-
export
|
|
55
|
+
export interface LoadSchemaOptions {
|
|
56
|
+
/** The field and workflow types forms may use: the built-ins, and the wired bundles'. */
|
|
57
|
+
formTypes?: FormTypes
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function loadSchemaDirectory(
|
|
61
|
+
schemaDir: string,
|
|
62
|
+
options: LoadSchemaOptions = {},
|
|
63
|
+
): LoadedSchema {
|
|
53
64
|
const problems: SchemaProblem[] = []
|
|
54
65
|
const files = new Map<string, string>()
|
|
55
66
|
const contentTypes: SchemaContentType[] = []
|
|
56
67
|
const dataTypes: SchemaDataType[] = []
|
|
57
68
|
let languages: SchemaLanguage[] = []
|
|
58
69
|
let version = '0'
|
|
70
|
+
let urls: SchemaUrls | undefined
|
|
59
71
|
|
|
60
72
|
const rel = (file: string) =>
|
|
61
73
|
file.startsWith(schemaDir) ? `schema${file.slice(schemaDir.length)}` : file
|
|
62
74
|
|
|
63
75
|
const versionFile = join(schemaDir, 'schema.toml')
|
|
64
76
|
if (existsSync(versionFile)) {
|
|
65
|
-
const parsed =
|
|
77
|
+
const parsed = parseSchemaSettings(rel(versionFile), readFileSync(versionFile, 'utf8'))
|
|
66
78
|
problems.push(...parsed.problems)
|
|
67
|
-
if (parsed.value)
|
|
79
|
+
if (parsed.value) {
|
|
80
|
+
version = parsed.value.version
|
|
81
|
+
urls = parsed.value.urls
|
|
82
|
+
}
|
|
68
83
|
}
|
|
69
84
|
for (const file of tomlFiles(join(schemaDir, TYPE_KIND_FILES.document.dir))) {
|
|
70
85
|
const parsed = parseContentType(rel(file), readFileSync(file, 'utf8'))
|
|
@@ -102,7 +117,7 @@ export function loadSchemaDirectory(schemaDir: string): LoadedSchema {
|
|
|
102
117
|
}
|
|
103
118
|
const forms: SchemaForm[] = []
|
|
104
119
|
for (const file of tomlFiles(join(schemaDir, 'forms'))) {
|
|
105
|
-
const parsed = parseForm(rel(file), readFileSync(file, 'utf8'))
|
|
120
|
+
const parsed = parseForm(rel(file), readFileSync(file, 'utf8'), options.formTypes)
|
|
106
121
|
problems.push(...parsed.problems)
|
|
107
122
|
if (parsed.value) {
|
|
108
123
|
forms.push(parsed.value)
|
|
@@ -116,7 +131,16 @@ export function loadSchemaDirectory(schemaDir: string): LoadedSchema {
|
|
|
116
131
|
if (parsed.value) languages = parsed.value
|
|
117
132
|
}
|
|
118
133
|
return {
|
|
119
|
-
set: {
|
|
134
|
+
set: {
|
|
135
|
+
version,
|
|
136
|
+
contentTypes,
|
|
137
|
+
mediaTypes,
|
|
138
|
+
memberTypes,
|
|
139
|
+
dataTypes,
|
|
140
|
+
languages,
|
|
141
|
+
forms,
|
|
142
|
+
...(urls ? { urls } : {}),
|
|
143
|
+
},
|
|
120
144
|
problems,
|
|
121
145
|
files,
|
|
122
146
|
}
|
|
@@ -126,6 +150,8 @@ export function loadSchemaDirectory(schemaDir: string): LoadedSchema {
|
|
|
126
150
|
export function hashSchemaSet(set: SchemaSet): string {
|
|
127
151
|
const parts = [
|
|
128
152
|
`version:${set.version}`,
|
|
153
|
+
// Only when not the default, so every site hashed before this kept its hash.
|
|
154
|
+
...(set.urls?.hideTopLevel === false ? ['urls:top-level-shown'] : []),
|
|
129
155
|
...set.contentTypes.map((t) => writeContentType(t)).sort(),
|
|
130
156
|
...(set.mediaTypes ?? []).map((t) => writeMediaType(t)).sort(),
|
|
131
157
|
...(set.memberTypes ?? []).map((t) => writeMemberType(t)).sort(),
|
package/src/model.ts
CHANGED
|
@@ -112,6 +112,14 @@ export interface SchemaSet {
|
|
|
112
112
|
languages: SchemaLanguage[]
|
|
113
113
|
/** `schema/forms/*.toml` — form definitions; see `docs/forms.md`. */
|
|
114
114
|
forms?: SchemaForm[]
|
|
115
|
+
/** `[urls]` in schema/schema.toml; Umbraco's defaults when absent. */
|
|
116
|
+
urls?: SchemaUrls
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** How URLs are built, as Umbraco's `Umbraco:CMS:Global` settings set it. */
|
|
120
|
+
export interface SchemaUrls {
|
|
121
|
+
/** `HideTopLevelNodeFromPath`: true, the default, leaves the top-level page out of paths. */
|
|
122
|
+
hideTopLevel: boolean
|
|
115
123
|
}
|
|
116
124
|
|
|
117
125
|
/** A problem found while parsing or validating, always locatable. */
|
package/src/parse-forms.ts
CHANGED
|
@@ -6,13 +6,16 @@
|
|
|
6
6
|
* type and the two share nothing but the reader.
|
|
7
7
|
*/
|
|
8
8
|
import {
|
|
9
|
+
BUILT_IN_FORM_TYPES,
|
|
10
|
+
type ChoiceSource,
|
|
11
|
+
type ChoiceSourceKind,
|
|
9
12
|
FORM_CONDITION_OPERATORS,
|
|
10
|
-
FORM_FIELD_TYPE_NAMES,
|
|
11
|
-
FORM_WORKFLOW_TYPES,
|
|
12
13
|
type FormCondition,
|
|
13
14
|
type FormConditionOperator,
|
|
14
15
|
type FormConditionRule,
|
|
15
16
|
type FormFieldType,
|
|
17
|
+
type FormSettingValue,
|
|
18
|
+
type FormTypes,
|
|
16
19
|
type FormWorkflowTrigger,
|
|
17
20
|
type FormWorkflowType,
|
|
18
21
|
formFieldType,
|
|
@@ -31,6 +34,7 @@ const FORM_KEYS = [
|
|
|
31
34
|
'notes',
|
|
32
35
|
'store-entries',
|
|
33
36
|
'requires-approval',
|
|
37
|
+
'step-pages',
|
|
34
38
|
'submit-label',
|
|
35
39
|
'next-label',
|
|
36
40
|
'previous-label',
|
|
@@ -59,6 +63,7 @@ const FIELD_KEYS = [
|
|
|
59
63
|
'css-class',
|
|
60
64
|
'autocomplete',
|
|
61
65
|
'values',
|
|
66
|
+
'source',
|
|
62
67
|
'placeholder',
|
|
63
68
|
'default-value',
|
|
64
69
|
'content',
|
|
@@ -70,10 +75,12 @@ const FIELD_KEYS = [
|
|
|
70
75
|
'accept',
|
|
71
76
|
'max-size-kb',
|
|
72
77
|
'multiple',
|
|
78
|
+
'settings',
|
|
73
79
|
'condition',
|
|
74
80
|
] as const
|
|
75
81
|
const CONDITION_KEYS = ['action', 'match', 'rule'] as const
|
|
76
82
|
const RULE_KEYS = ['field', 'operator', 'value'] as const
|
|
83
|
+
const SOURCE_KEYS = ['kind', 'parent'] as const
|
|
77
84
|
const WORKFLOW_KEYS = ['key', 'type', 'name', 'on', 'settings'] as const
|
|
78
85
|
|
|
79
86
|
function condition(r: Reader, value: unknown, path: string): FormCondition | undefined {
|
|
@@ -112,14 +119,30 @@ function condition(r: Reader, value: unknown, path: string): FormCondition | und
|
|
|
112
119
|
}
|
|
113
120
|
}
|
|
114
121
|
|
|
115
|
-
|
|
122
|
+
/** Kept as written: the validator names an unknown kind, as it does an unknown type. */
|
|
123
|
+
function source(r: Reader, value: unknown, path: string): ChoiceSource | undefined {
|
|
124
|
+
if (value === undefined) return undefined
|
|
125
|
+
const t = r.table(value, path)
|
|
126
|
+
if (!t) return undefined
|
|
127
|
+
r.unknown(t, SOURCE_KEYS, path)
|
|
128
|
+
return {
|
|
129
|
+
kind: (r.str(t, 'kind', path, true) ?? '') as ChoiceSourceKind,
|
|
130
|
+
parent: r.str(t, 'parent', path, true) ?? '',
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** Says a type is unknown by its name, and what is known: the built-ins, then each bundle's. */
|
|
135
|
+
function unknownType(kind: 'field' | 'workflow', type: string, known: Iterable<string>): string {
|
|
136
|
+
return `the ${kind} type "${type}" is not built in, and no wired bundle provides it; expected one of ${[...known].join(', ')}`
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function field(r: Reader, t: Toml, path: string, types: FormTypes): SchemaFormField {
|
|
116
140
|
r.unknown(t, FIELD_KEYS, path)
|
|
117
141
|
const type = r.str(t, 'type', path, true) ?? ''
|
|
118
|
-
if (type && !formFieldType(type))
|
|
119
|
-
r.problem(
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
)
|
|
142
|
+
if (type && !formFieldType(type, types))
|
|
143
|
+
r.problem(`${path}.type`, unknownType('field', type, types.fields.keys()))
|
|
144
|
+
const settings =
|
|
145
|
+
t.settings === undefined ? undefined : (r.table(t.settings, `${path}.settings`) ?? {})
|
|
123
146
|
// Consent is mandatory unless the form says otherwise: a consent box that can
|
|
124
147
|
// be skipped records agreement nobody gave.
|
|
125
148
|
const mandatoryByDefault = type === 'dataConsent'
|
|
@@ -137,6 +160,7 @@ function field(r: Reader, t: Toml, path: string): SchemaFormField {
|
|
|
137
160
|
cssClass: r.str(t, 'css-class', path),
|
|
138
161
|
autocomplete: r.str(t, 'autocomplete', path),
|
|
139
162
|
values: r.strs(t, 'values', path),
|
|
163
|
+
...(t.source !== undefined ? { source: source(r, t.source, `${path}.source`) } : {}),
|
|
140
164
|
placeholder: r.str(t, 'placeholder', path),
|
|
141
165
|
defaultValue: r.str(t, 'default-value', path),
|
|
142
166
|
content: r.str(t, 'content', path),
|
|
@@ -148,37 +172,39 @@ function field(r: Reader, t: Toml, path: string): SchemaFormField {
|
|
|
148
172
|
accept: r.strs(t, 'accept', path),
|
|
149
173
|
maxSizeKb: r.num(t, 'max-size-kb', path),
|
|
150
174
|
...(t.multiple !== undefined ? { multiple: r.bool(t, 'multiple', path, false) } : {}),
|
|
175
|
+
...(settings ? { settings: settings as Record<string, FormSettingValue> } : {}),
|
|
151
176
|
condition: condition(r, t.condition, `${path}.condition`),
|
|
152
177
|
}
|
|
153
178
|
}
|
|
154
179
|
|
|
155
|
-
function group(r: Reader, t: Toml, path: string): SchemaFormGroup {
|
|
180
|
+
function group(r: Reader, t: Toml, path: string, types: FormTypes): SchemaFormGroup {
|
|
156
181
|
r.unknown(t, GROUP_KEYS, path)
|
|
157
182
|
return {
|
|
158
183
|
caption: r.str(t, 'caption', path),
|
|
159
184
|
columns: r.num(t, 'columns', path) ?? 1,
|
|
160
|
-
fields: r
|
|
185
|
+
fields: r
|
|
186
|
+
.tables(t.field, `${path}.field`)
|
|
187
|
+
.map((f, i) => field(r, f, `${path}.field[${i}]`, types)),
|
|
161
188
|
condition: condition(r, t.condition, `${path}.condition`),
|
|
162
189
|
}
|
|
163
190
|
}
|
|
164
191
|
|
|
165
|
-
function page(r: Reader, t: Toml, path: string): SchemaFormPage {
|
|
192
|
+
function page(r: Reader, t: Toml, path: string, types: FormTypes): SchemaFormPage {
|
|
166
193
|
r.unknown(t, PAGE_KEYS, path)
|
|
167
194
|
return {
|
|
168
195
|
caption: r.str(t, 'caption', path),
|
|
169
|
-
groups: r
|
|
196
|
+
groups: r
|
|
197
|
+
.tables(t.group, `${path}.group`)
|
|
198
|
+
.map((g, i) => group(r, g, `${path}.group[${i}]`, types)),
|
|
170
199
|
condition: condition(r, t.condition, `${path}.condition`),
|
|
171
200
|
}
|
|
172
201
|
}
|
|
173
202
|
|
|
174
|
-
function workflow(r: Reader, t: Toml, path: string): SchemaFormWorkflow {
|
|
203
|
+
function workflow(r: Reader, t: Toml, path: string, types: FormTypes): SchemaFormWorkflow {
|
|
175
204
|
r.unknown(t, WORKFLOW_KEYS, path)
|
|
176
205
|
const type = r.str(t, 'type', path, true) ?? ''
|
|
177
|
-
if (type && !
|
|
178
|
-
r.problem(
|
|
179
|
-
`${path}.type`,
|
|
180
|
-
`unknown workflow type; expected one of ${FORM_WORKFLOW_TYPES.join(', ')}`,
|
|
181
|
-
)
|
|
206
|
+
if (type && !types.workflows.has(type))
|
|
207
|
+
r.problem(`${path}.type`, unknownType('workflow', type, types.workflows.keys()))
|
|
182
208
|
const on = r.str(t, 'on', path) ?? 'submit'
|
|
183
209
|
if (on !== 'submit' && on !== 'approve') r.problem(`${path}.on`, "expected 'submit' or 'approve'")
|
|
184
210
|
const settings = t.settings === undefined ? {} : r.table(t.settings, `${path}.settings`)
|
|
@@ -191,7 +217,11 @@ function workflow(r: Reader, t: Toml, path: string): SchemaFormWorkflow {
|
|
|
191
217
|
}
|
|
192
218
|
}
|
|
193
219
|
|
|
194
|
-
export function parseForm(
|
|
220
|
+
export function parseForm(
|
|
221
|
+
file: string,
|
|
222
|
+
source: string,
|
|
223
|
+
types: FormTypes = BUILT_IN_FORM_TYPES,
|
|
224
|
+
): ParseResult<SchemaForm> {
|
|
195
225
|
const r = new Reader(file)
|
|
196
226
|
const root = toml(source, r)
|
|
197
227
|
if (!root) return { value: undefined, problems: r.problems }
|
|
@@ -212,6 +242,7 @@ export function parseForm(file: string, source: string): ParseResult<SchemaForm>
|
|
|
212
242
|
// what people typed would be the worst possible default.
|
|
213
243
|
storeEntries: r.bool(t, 'store-entries', path, true),
|
|
214
244
|
requiresApproval: r.bool(t, 'requires-approval', path, false),
|
|
245
|
+
...(t['step-pages'] !== undefined ? { stepPages: r.bool(t, 'step-pages', path, false) } : {}),
|
|
215
246
|
submitLabel: r.str(t, 'submit-label', path),
|
|
216
247
|
nextLabel: r.str(t, 'next-label', path),
|
|
217
248
|
previousLabel: r.str(t, 'previous-label', path),
|
|
@@ -222,8 +253,10 @@ export function parseForm(file: string, source: string): ParseResult<SchemaForm>
|
|
|
222
253
|
minimumSubmitSeconds: r.num(t, 'minimum-submit-seconds', path),
|
|
223
254
|
maxEntries: r.num(t, 'max-entries', path),
|
|
224
255
|
since: r.str(t, 'since', path),
|
|
225
|
-
pages: r.tables(root.page, 'page').map((p, i) => page(r, p, `page[${i}]
|
|
226
|
-
workflows: r
|
|
256
|
+
pages: r.tables(root.page, 'page').map((p, i) => page(r, p, `page[${i}]`, types)),
|
|
257
|
+
workflows: r
|
|
258
|
+
.tables(root.workflow, 'workflow')
|
|
259
|
+
.map((w, i) => workflow(r, w, `workflow[${i}]`, types)),
|
|
227
260
|
}
|
|
228
261
|
return { value: r.problems.length === 0 ? value : undefined, problems: r.problems }
|
|
229
262
|
}
|
package/src/parse.ts
CHANGED
|
@@ -11,6 +11,7 @@ import {
|
|
|
11
11
|
type SchemaProperty,
|
|
12
12
|
type SchemaTab,
|
|
13
13
|
type SchemaTypeKind,
|
|
14
|
+
type SchemaUrls,
|
|
14
15
|
TYPE_KIND_FILES,
|
|
15
16
|
} from './model.ts'
|
|
16
17
|
import { type ParseResult, Reader, type Toml, toml } from './reader.ts'
|
|
@@ -251,12 +252,35 @@ export function parseLanguages(file: string, source: string): ParseResult<Schema
|
|
|
251
252
|
}
|
|
252
253
|
|
|
253
254
|
export function parseSchemaVersion(file: string, source: string): ParseResult<string> {
|
|
255
|
+
const parsed = parseSchemaSettings(file, source)
|
|
256
|
+
return { value: parsed.value?.version, problems: parsed.problems }
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/** `schema/schema.toml`: the schema version, and how the site's URLs are built. */
|
|
260
|
+
export function parseSchemaSettings(
|
|
261
|
+
file: string,
|
|
262
|
+
source: string,
|
|
263
|
+
): ParseResult<{ version: string; urls?: SchemaUrls }> {
|
|
254
264
|
const r = new Reader(file)
|
|
255
265
|
const root = toml(source, r)
|
|
256
266
|
if (!root) return { value: undefined, problems: r.problems }
|
|
257
|
-
r.unknown(root, ['schema'], '')
|
|
267
|
+
r.unknown(root, ['schema', 'urls'], '')
|
|
258
268
|
const t = r.table(root.schema, 'schema')
|
|
259
269
|
const version = t ? r.str(t, 'version', 'schema', true) : undefined
|
|
260
270
|
if (t) r.unknown(t, ['version'], 'schema')
|
|
261
|
-
|
|
271
|
+
let urls: SchemaUrls | undefined
|
|
272
|
+
if (root.urls !== undefined) {
|
|
273
|
+
const u = r.table(root.urls, 'urls')
|
|
274
|
+
if (u) {
|
|
275
|
+
r.unknown(u, ['hide-top-level'], 'urls')
|
|
276
|
+
urls = { hideTopLevel: r.bool(u, 'hide-top-level', 'urls', true) }
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
return {
|
|
280
|
+
value:
|
|
281
|
+
r.problems.length === 0 && version !== undefined
|
|
282
|
+
? { version, ...(urls ? { urls } : {}) }
|
|
283
|
+
: undefined,
|
|
284
|
+
problems: r.problems,
|
|
285
|
+
}
|
|
262
286
|
}
|
package/src/sync.ts
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
* retirement, and a type with content refuses to disappear. The algorithm and
|
|
4
4
|
* the multi-node rules are in docs/schema-as-code.md, "Synchronisation".
|
|
5
5
|
*/
|
|
6
|
+
import type { FormTypes } from '@crombolo/core'
|
|
6
7
|
import { type ContentTypeAggregate, ObjectTypes, type PropertyTypeModel } from '@crombolo/core'
|
|
7
8
|
import {
|
|
8
9
|
appendCacheInstruction,
|
|
@@ -15,6 +16,7 @@ import {
|
|
|
15
16
|
DataTypeRepository,
|
|
16
17
|
type Db,
|
|
17
18
|
DbDate,
|
|
19
|
+
DEFAULT_URL_OPTIONS,
|
|
18
20
|
FolderRepository,
|
|
19
21
|
LanguageRepository,
|
|
20
22
|
Locks,
|
|
@@ -25,7 +27,10 @@ import {
|
|
|
25
27
|
SYSTEM_CONTENT_TYPES,
|
|
26
28
|
SYSTEM_MEDIA_TYPES,
|
|
27
29
|
SYSTEM_MEMBER_TYPE,
|
|
30
|
+
sameUrlOptions,
|
|
31
|
+
type UrlOptions,
|
|
28
32
|
updateSchemaStateHash,
|
|
33
|
+
writeUrlOptions,
|
|
29
34
|
} from '@crombolo/data'
|
|
30
35
|
import { BUILTIN_DATA_TYPES, storageTypeFor, systemPropertyKeys } from './builtin.ts'
|
|
31
36
|
import { hashSchemaSet, type LoadedSchema } from './load.ts'
|
|
@@ -56,6 +61,8 @@ export interface SyncOptions {
|
|
|
56
61
|
* is pending everywhere until `upgrade` makes the state current. Default current.
|
|
57
62
|
*/
|
|
58
63
|
status?: 'prepared' | 'current'
|
|
64
|
+
/** The form field and workflow types the site's wired bundles add. */
|
|
65
|
+
formTypes?: FormTypes
|
|
59
66
|
}
|
|
60
67
|
|
|
61
68
|
export type SyncAction = 'applied' | 'would-apply' | 'skipped-older' | 'skipped-same' | 'refused'
|
|
@@ -88,6 +95,8 @@ export interface SyncReport {
|
|
|
88
95
|
revived: string[]
|
|
89
96
|
assignedKeys: AssignedKey[]
|
|
90
97
|
deferred: string[]
|
|
98
|
+
/** Set when the sync changed how URLs are built, so the old ones can be redirected. */
|
|
99
|
+
urls?: { before: UrlOptions; after: UrlOptions }
|
|
91
100
|
}
|
|
92
101
|
|
|
93
102
|
export const emptySyncReport = (
|
|
@@ -132,7 +141,10 @@ export async function syncSchema(
|
|
|
132
141
|
): Promise<SyncReport> {
|
|
133
142
|
const problems = [
|
|
134
143
|
...loaded.problems,
|
|
135
|
-
...validateSchemaSet(loaded.set, {
|
|
144
|
+
...validateSchemaSet(loaded.set, {
|
|
145
|
+
componentAliases: options.componentAliases,
|
|
146
|
+
types: options.formTypes,
|
|
147
|
+
}),
|
|
136
148
|
]
|
|
137
149
|
if (problems.length > 0)
|
|
138
150
|
return emptySyncReport('refused', { reason: 'the schema has problems', problems })
|
|
@@ -268,6 +280,14 @@ export async function syncSchema(
|
|
|
268
280
|
report.updated.languages += result.updated
|
|
269
281
|
}
|
|
270
282
|
|
|
283
|
+
// ---- how URLs are built: the files are the source, absent is the default.
|
|
284
|
+
// Only a current sync moves it; a prepared one serves no URLs yet.
|
|
285
|
+
if (status === 'current') {
|
|
286
|
+
const urls = { hideTopLevel: set.urls?.hideTopLevel ?? DEFAULT_URL_OPTIONS.hideTopLevel }
|
|
287
|
+
const before = await writeUrlOptions(tx, urls)
|
|
288
|
+
if (!sameUrlOptions(before, urls)) report.urls = { before, after: urls }
|
|
289
|
+
}
|
|
290
|
+
|
|
271
291
|
// ---- templates referenced by types must have a row
|
|
272
292
|
const componentKeyByAlias = new Map<string, string>()
|
|
273
293
|
for (const alias of new Set(set.contentTypes.flatMap((t) => t.components))) {
|
package/src/validate-forms.ts
CHANGED
|
@@ -8,14 +8,20 @@
|
|
|
8
8
|
*/
|
|
9
9
|
import {
|
|
10
10
|
allFormFields,
|
|
11
|
+
BUILT_IN_FORM_TYPES,
|
|
12
|
+
CHOICE_SOURCE_KINDS,
|
|
11
13
|
type FormCondition,
|
|
14
|
+
type FormTypes,
|
|
12
15
|
formFieldType,
|
|
13
16
|
type SchemaForm,
|
|
14
17
|
type SchemaFormField,
|
|
18
|
+
settingsProblems,
|
|
15
19
|
VALUELESS_OPERATORS,
|
|
16
20
|
} from '@crombolo/core'
|
|
17
21
|
import type { SchemaProblem } from './model.ts'
|
|
18
22
|
|
|
23
|
+
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
|
|
24
|
+
|
|
19
25
|
const VERSION = /^\d+(\.\d+)*$/
|
|
20
26
|
const ALIAS = /^[a-zA-Z][a-zA-Z0-9]*$/
|
|
21
27
|
|
|
@@ -45,6 +51,8 @@ export interface ValidateFormsOptions {
|
|
|
45
51
|
formThemes?: ReadonlySet<string>
|
|
46
52
|
/** Content type aliases, so `saveAsContent` can be held to a real one. */
|
|
47
53
|
contentTypeAliases?: ReadonlySet<string>
|
|
54
|
+
/** The field and workflow types in play: the built-ins, and the wired bundles'. */
|
|
55
|
+
types?: FormTypes
|
|
48
56
|
}
|
|
49
57
|
|
|
50
58
|
export function validateForms(
|
|
@@ -98,7 +106,7 @@ export function validateForms(
|
|
|
98
106
|
'requires store-entries, since there is no entry to approve',
|
|
99
107
|
)
|
|
100
108
|
|
|
101
|
-
validateFields(form, file, add)
|
|
109
|
+
validateFields(form, file, options.types ?? BUILT_IN_FORM_TYPES, add)
|
|
102
110
|
validateWorkflows(form, file, options, add)
|
|
103
111
|
}
|
|
104
112
|
return problems
|
|
@@ -106,7 +114,7 @@ export function validateForms(
|
|
|
106
114
|
|
|
107
115
|
type Add = (file: string, path: string, message: string) => void
|
|
108
116
|
|
|
109
|
-
function validateFields(form: SchemaForm, file: string, add: Add): void {
|
|
117
|
+
function validateFields(form: SchemaForm, file: string, types: FormTypes, add: Add): void {
|
|
110
118
|
const fields = allFormFields(form)
|
|
111
119
|
const seen = new Set<string>()
|
|
112
120
|
/** Aliases usable by a condition: a field may only look at earlier ones. */
|
|
@@ -121,7 +129,7 @@ function validateFields(form: SchemaForm, file: string, add: Add): void {
|
|
|
121
129
|
add(file, 'page.group.columns', `${group.columns} is not a column count between 1 and 4`)
|
|
122
130
|
for (const field of group.fields) {
|
|
123
131
|
const path = `page.group.field[${index}]`
|
|
124
|
-
const info = formFieldType(field.type)
|
|
132
|
+
const info = formFieldType(field.type, types)
|
|
125
133
|
|
|
126
134
|
if (!ALIAS.test(field.alias))
|
|
127
135
|
add(
|
|
@@ -148,13 +156,40 @@ function validateFields(form: SchemaForm, file: string, add: Add): void {
|
|
|
148
156
|
`a ${field.type} field collects nothing, so it cannot be sensitive`,
|
|
149
157
|
)
|
|
150
158
|
}
|
|
151
|
-
|
|
159
|
+
const { source } = field
|
|
160
|
+
if (source && field.values.length > 0)
|
|
161
|
+
add(
|
|
162
|
+
file,
|
|
163
|
+
`${path}.source`,
|
|
164
|
+
'a field takes its choices from values or from a source, not both',
|
|
165
|
+
)
|
|
166
|
+
if (source && !info.choices)
|
|
167
|
+
add(file, `${path}.source`, `a ${field.type} field has nothing to choose from`)
|
|
168
|
+
if (source && info.choices) {
|
|
169
|
+
if (!CHOICE_SOURCE_KINDS.includes(source.kind))
|
|
170
|
+
add(
|
|
171
|
+
file,
|
|
172
|
+
`${path}.source.kind`,
|
|
173
|
+
`"${source.kind}" is not a kind of source; expected one of ${CHOICE_SOURCE_KINDS.join(', ')}`,
|
|
174
|
+
)
|
|
175
|
+
if (source.kind === 'content' && !UUID.test(source.parent))
|
|
176
|
+
add(file, `${path}.source.parent`, 'a content source names its parent page by key')
|
|
177
|
+
if (source.kind === 'dictionary' && source.parent.trim() === '')
|
|
178
|
+
add(
|
|
179
|
+
file,
|
|
180
|
+
`${path}.source.parent`,
|
|
181
|
+
'a dictionary source names its parent dictionary item',
|
|
182
|
+
)
|
|
183
|
+
}
|
|
184
|
+
if (info.choices && !source && field.values.length === 0)
|
|
152
185
|
add(file, `${path}.values`, `a ${field.type} field needs values to choose from`)
|
|
153
186
|
if (!info.choices && field.values.length > 0)
|
|
154
187
|
add(file, `${path}.values`, `a ${field.type} field has nothing to choose from`)
|
|
155
|
-
// A default has to be one of the choices, or the form opens invalid.
|
|
188
|
+
// A default has to be one of the choices, or the form opens invalid. A
|
|
189
|
+
// source's choices are only known once it is resolved, so not checked here.
|
|
156
190
|
if (
|
|
157
191
|
info.choices &&
|
|
192
|
+
!source &&
|
|
158
193
|
field.defaultValue !== undefined &&
|
|
159
194
|
!field.values.includes(field.defaultValue)
|
|
160
195
|
)
|
|
@@ -165,6 +200,21 @@ function validateFields(form: SchemaForm, file: string, add: Add): void {
|
|
|
165
200
|
if (!info.settings.includes(setting as never))
|
|
166
201
|
add(file, `${path}.${setting}`, `does not apply to a ${field.type} field`)
|
|
167
202
|
}
|
|
203
|
+
// A built-in type's settings are keys of the field; only a bundle's
|
|
204
|
+
// type has a settings table, held to what it declares.
|
|
205
|
+
if (!info.bundle && field.settings !== undefined)
|
|
206
|
+
add(
|
|
207
|
+
file,
|
|
208
|
+
`${path}.settings`,
|
|
209
|
+
`a ${field.type} field is built in and takes no settings table; its settings are keys of the field`,
|
|
210
|
+
)
|
|
211
|
+
if (info.bundle)
|
|
212
|
+
for (const message of settingsProblems(
|
|
213
|
+
info.declared,
|
|
214
|
+
field.settings ?? {},
|
|
215
|
+
`a ${field.type} field`,
|
|
216
|
+
))
|
|
217
|
+
add(file, `${path}.settings`, message)
|
|
168
218
|
}
|
|
169
219
|
|
|
170
220
|
if (field.pattern !== undefined) {
|
|
@@ -184,19 +234,27 @@ function validateFields(form: SchemaForm, file: string, add: Add): void {
|
|
|
184
234
|
add(file, `${path}.max-size-kb`, 'must be at least 1')
|
|
185
235
|
|
|
186
236
|
if (field.condition)
|
|
187
|
-
validateCondition(field.condition, fields, earlier, file, `${path}.condition`, add)
|
|
237
|
+
validateCondition(field.condition, fields, earlier, file, `${path}.condition`, types, add)
|
|
188
238
|
earlier.add(field.alias.toLowerCase())
|
|
189
239
|
index++
|
|
190
240
|
}
|
|
191
241
|
if (group.condition)
|
|
192
|
-
validateCondition(
|
|
242
|
+
validateCondition(
|
|
243
|
+
group.condition,
|
|
244
|
+
fields,
|
|
245
|
+
earlier,
|
|
246
|
+
file,
|
|
247
|
+
'page.group.condition',
|
|
248
|
+
types,
|
|
249
|
+
add,
|
|
250
|
+
)
|
|
193
251
|
}
|
|
194
252
|
if (page.condition)
|
|
195
|
-
validateCondition(page.condition, fields, earlier, file, 'page.condition', add)
|
|
253
|
+
validateCondition(page.condition, fields, earlier, file, 'page.condition', types, add)
|
|
196
254
|
}
|
|
197
255
|
|
|
198
256
|
if (fields.length === 0) add(file, 'page.group.field', 'a form needs at least one field')
|
|
199
|
-
else if (fields.every((field) => formFieldType(field.type)?.presentational))
|
|
257
|
+
else if (fields.every((field) => formFieldType(field.type, types)?.presentational))
|
|
200
258
|
add(file, 'page.group.field', 'every field is presentational, so the form collects nothing')
|
|
201
259
|
}
|
|
202
260
|
|
|
@@ -206,6 +264,7 @@ function validateCondition(
|
|
|
206
264
|
earlier: ReadonlySet<string>,
|
|
207
265
|
file: string,
|
|
208
266
|
path: string,
|
|
267
|
+
types: FormTypes,
|
|
209
268
|
add: Add,
|
|
210
269
|
): void {
|
|
211
270
|
for (const [index, rule] of condition.rules.entries()) {
|
|
@@ -223,7 +282,7 @@ function validateCondition(
|
|
|
223
282
|
`${rulePath}.field`,
|
|
224
283
|
`"${rule.field}" comes later in the form, so it has no value to compare yet`,
|
|
225
284
|
)
|
|
226
|
-
if (formFieldType(target.type)?.presentational)
|
|
285
|
+
if (formFieldType(target.type, types)?.presentational)
|
|
227
286
|
add(file, `${rulePath}.field`, `"${rule.field}" collects nothing, so it cannot be compared`)
|
|
228
287
|
const valueless = VALUELESS_OPERATORS.includes(rule.operator)
|
|
229
288
|
if (valueless && rule.value !== undefined)
|
|
@@ -275,6 +334,15 @@ function validateWorkflows(
|
|
|
275
334
|
'runs on approval, but the form does not require approval so nothing ever approves it',
|
|
276
335
|
)
|
|
277
336
|
|
|
337
|
+
const bundled = (options.types ?? BUILT_IN_FORM_TYPES).workflows.get(workflow.type)
|
|
338
|
+
if (bundled?.bundle) {
|
|
339
|
+
const where = `a ${workflow.type} workflow`
|
|
340
|
+
const declared = settingsProblems(bundled.declared, workflow.settings, where)
|
|
341
|
+
for (const message of declared) add(file, `${path}.settings`, message)
|
|
342
|
+
const own = declared.length === 0 ? bundled.validate?.(workflow.settings) : undefined
|
|
343
|
+
if (own) add(file, `${path}.settings`, own)
|
|
344
|
+
continue
|
|
345
|
+
}
|
|
278
346
|
const rules = WORKFLOW_SETTINGS[workflow.type]
|
|
279
347
|
if (!rules) continue
|
|
280
348
|
const allowed = new Set([...rules.required, ...rules.optional])
|
package/src/write-forms.ts
CHANGED
|
@@ -73,6 +73,8 @@ function writeField(out: string[], header: string, field: SchemaFormField): void
|
|
|
73
73
|
line(out, 'css-class', field.cssClass)
|
|
74
74
|
line(out, 'autocomplete', field.autocomplete)
|
|
75
75
|
line(out, 'values', field.values, field.values.length > 0)
|
|
76
|
+
if (field.source)
|
|
77
|
+
out.push(`source = { kind = ${str(field.source.kind)}, parent = ${str(field.source.parent)} }`)
|
|
76
78
|
line(out, 'placeholder', field.placeholder)
|
|
77
79
|
line(out, 'default-value', field.defaultValue)
|
|
78
80
|
line(out, 'content', field.content)
|
|
@@ -84,6 +86,12 @@ function writeField(out: string[], header: string, field: SchemaFormField): void
|
|
|
84
86
|
line(out, 'accept', field.accept, field.accept.length > 0)
|
|
85
87
|
line(out, 'max-size-kb', field.maxSizeKb)
|
|
86
88
|
line(out, 'multiple', field.multiple)
|
|
89
|
+
const settings = Object.keys(field.settings ?? {}).sort()
|
|
90
|
+
// Inline, so a bundle type's settings stay inside the one field they belong to.
|
|
91
|
+
if (field.settings)
|
|
92
|
+
out.push(
|
|
93
|
+
`settings = { ${settings.map((key) => `${keyOf(key)} = ${scalar(field.settings?.[key])}`).join(', ')} }`,
|
|
94
|
+
)
|
|
87
95
|
if (field.condition) writeCondition(out, `${header}.condition`, field.condition)
|
|
88
96
|
}
|
|
89
97
|
|
|
@@ -123,6 +131,7 @@ export function writeForm(form: SchemaForm): string {
|
|
|
123
131
|
line(out, 'notes', form.notes)
|
|
124
132
|
line(out, 'store-entries', false, !form.storeEntries)
|
|
125
133
|
line(out, 'requires-approval', true, form.requiresApproval)
|
|
134
|
+
line(out, 'step-pages', true, form.stepPages === true)
|
|
126
135
|
line(out, 'submit-label', form.submitLabel)
|
|
127
136
|
line(out, 'next-label', form.nextLabel)
|
|
128
137
|
line(out, 'previous-label', form.previousLabel)
|
package/src/write.ts
CHANGED
|
@@ -12,6 +12,7 @@ import {
|
|
|
12
12
|
type SchemaLanguage,
|
|
13
13
|
type SchemaProperty,
|
|
14
14
|
type SchemaTypeKind,
|
|
15
|
+
type SchemaUrls,
|
|
15
16
|
TYPE_KIND_FILES,
|
|
16
17
|
} from './model.ts'
|
|
17
18
|
|
|
@@ -162,8 +163,10 @@ export function writeLanguages(languages: readonly SchemaLanguage[]): string {
|
|
|
162
163
|
return `${out.join('\n')}\n`
|
|
163
164
|
}
|
|
164
165
|
|
|
165
|
-
|
|
166
|
-
|
|
166
|
+
/** `schema/schema.toml`. `[urls]` is written only when it differs from Umbraco's defaults. */
|
|
167
|
+
export function writeSchemaVersion(version: string, urls?: SchemaUrls): string {
|
|
168
|
+
const out = `[schema]\nversion = ${str(version)}\n`
|
|
169
|
+
return urls && !urls.hideTopLevel ? `${out}\n[urls]\nhide-top-level = false\n` : out
|
|
167
170
|
}
|
|
168
171
|
|
|
169
172
|
/** The canonical filename for an alias: kebab-case. */
|