@crombolo/schema 0.11.0 → 0.13.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/index.ts +1 -0
- package/src/load.ts +45 -6
- package/src/model.ts +11 -1
- package/src/parse-forms.ts +54 -21
- package/src/parse.ts +26 -2
- package/src/sync.ts +36 -1
- package/src/validate-forms.ts +78 -10
- package/src/validate.ts +8 -0
- package/src/workflows.ts +229 -0
- 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.13.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.13.1",
|
|
32
|
+
"@crombolo/data": "0.13.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/index.ts
CHANGED
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, SchemaWorkflow } 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,9 +25,10 @@ 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'
|
|
31
|
+
import { parseWorkflow, writeWorkflow } from './workflows.ts'
|
|
28
32
|
import {
|
|
29
33
|
writeContentType,
|
|
30
34
|
writeDataType,
|
|
@@ -49,22 +53,34 @@ function tomlFiles(dir: string): string[] {
|
|
|
49
53
|
.map((name) => join(dir, name))
|
|
50
54
|
}
|
|
51
55
|
|
|
52
|
-
export
|
|
56
|
+
export interface LoadSchemaOptions {
|
|
57
|
+
/** The field and workflow types forms may use: the built-ins, and the wired bundles'. */
|
|
58
|
+
formTypes?: FormTypes
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function loadSchemaDirectory(
|
|
62
|
+
schemaDir: string,
|
|
63
|
+
options: LoadSchemaOptions = {},
|
|
64
|
+
): LoadedSchema {
|
|
53
65
|
const problems: SchemaProblem[] = []
|
|
54
66
|
const files = new Map<string, string>()
|
|
55
67
|
const contentTypes: SchemaContentType[] = []
|
|
56
68
|
const dataTypes: SchemaDataType[] = []
|
|
57
69
|
let languages: SchemaLanguage[] = []
|
|
58
70
|
let version = '0'
|
|
71
|
+
let urls: SchemaUrls | undefined
|
|
59
72
|
|
|
60
73
|
const rel = (file: string) =>
|
|
61
74
|
file.startsWith(schemaDir) ? `schema${file.slice(schemaDir.length)}` : file
|
|
62
75
|
|
|
63
76
|
const versionFile = join(schemaDir, 'schema.toml')
|
|
64
77
|
if (existsSync(versionFile)) {
|
|
65
|
-
const parsed =
|
|
78
|
+
const parsed = parseSchemaSettings(rel(versionFile), readFileSync(versionFile, 'utf8'))
|
|
66
79
|
problems.push(...parsed.problems)
|
|
67
|
-
if (parsed.value)
|
|
80
|
+
if (parsed.value) {
|
|
81
|
+
version = parsed.value.version
|
|
82
|
+
urls = parsed.value.urls
|
|
83
|
+
}
|
|
68
84
|
}
|
|
69
85
|
for (const file of tomlFiles(join(schemaDir, TYPE_KIND_FILES.document.dir))) {
|
|
70
86
|
const parsed = parseContentType(rel(file), readFileSync(file, 'utf8'))
|
|
@@ -102,13 +118,22 @@ export function loadSchemaDirectory(schemaDir: string): LoadedSchema {
|
|
|
102
118
|
}
|
|
103
119
|
const forms: SchemaForm[] = []
|
|
104
120
|
for (const file of tomlFiles(join(schemaDir, 'forms'))) {
|
|
105
|
-
const parsed = parseForm(rel(file), readFileSync(file, 'utf8'))
|
|
121
|
+
const parsed = parseForm(rel(file), readFileSync(file, 'utf8'), options.formTypes)
|
|
106
122
|
problems.push(...parsed.problems)
|
|
107
123
|
if (parsed.value) {
|
|
108
124
|
forms.push(parsed.value)
|
|
109
125
|
files.set(`form:${parsed.value.alias}`, file)
|
|
110
126
|
}
|
|
111
127
|
}
|
|
128
|
+
const workflows: SchemaWorkflow[] = []
|
|
129
|
+
for (const file of tomlFiles(join(schemaDir, 'workflows'))) {
|
|
130
|
+
const parsed = parseWorkflow(rel(file), readFileSync(file, 'utf8'))
|
|
131
|
+
problems.push(...parsed.problems)
|
|
132
|
+
if (parsed.value) {
|
|
133
|
+
workflows.push(parsed.value)
|
|
134
|
+
files.set(`workflow:${parsed.value.alias}`, file)
|
|
135
|
+
}
|
|
136
|
+
}
|
|
112
137
|
const languagesFile = join(schemaDir, 'languages.toml')
|
|
113
138
|
if (existsSync(languagesFile)) {
|
|
114
139
|
const parsed = parseLanguages(rel(languagesFile), readFileSync(languagesFile, 'utf8'))
|
|
@@ -116,7 +141,17 @@ export function loadSchemaDirectory(schemaDir: string): LoadedSchema {
|
|
|
116
141
|
if (parsed.value) languages = parsed.value
|
|
117
142
|
}
|
|
118
143
|
return {
|
|
119
|
-
set: {
|
|
144
|
+
set: {
|
|
145
|
+
version,
|
|
146
|
+
contentTypes,
|
|
147
|
+
mediaTypes,
|
|
148
|
+
memberTypes,
|
|
149
|
+
dataTypes,
|
|
150
|
+
languages,
|
|
151
|
+
forms,
|
|
152
|
+
...(workflows.length > 0 ? { workflows } : {}),
|
|
153
|
+
...(urls ? { urls } : {}),
|
|
154
|
+
},
|
|
120
155
|
problems,
|
|
121
156
|
files,
|
|
122
157
|
}
|
|
@@ -126,12 +161,16 @@ export function loadSchemaDirectory(schemaDir: string): LoadedSchema {
|
|
|
126
161
|
export function hashSchemaSet(set: SchemaSet): string {
|
|
127
162
|
const parts = [
|
|
128
163
|
`version:${set.version}`,
|
|
164
|
+
// Only when not the default, so every site hashed before this kept its hash.
|
|
165
|
+
...(set.urls?.hideTopLevel === false ? ['urls:top-level-shown'] : []),
|
|
129
166
|
...set.contentTypes.map((t) => writeContentType(t)).sort(),
|
|
130
167
|
...(set.mediaTypes ?? []).map((t) => writeMediaType(t)).sort(),
|
|
131
168
|
...(set.memberTypes ?? []).map((t) => writeMemberType(t)).sort(),
|
|
132
169
|
...set.dataTypes.map((d) => writeDataType(d)).sort(),
|
|
133
170
|
writeLanguages([...set.languages].sort((a, b) => a.iso.localeCompare(b.iso))),
|
|
134
171
|
...(set.forms ?? []).map((f) => writeForm(f)).sort(),
|
|
172
|
+
// Absent from a set with none, so every hash taken before workflows stands.
|
|
173
|
+
...(set.workflows ?? []).map((w) => writeWorkflow(w)).sort(),
|
|
135
174
|
]
|
|
136
175
|
return createHash('sha256').update(parts.join('\n---\n')).digest('hex')
|
|
137
176
|
}
|
package/src/model.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { SchemaForm } from '@crombolo/core'
|
|
1
|
+
import type { SchemaForm, SchemaWorkflow } from '@crombolo/core'
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
4
|
* The schema-as-code model: what a site's `schema/*.toml` files describe.
|
|
@@ -112,6 +112,16 @@ 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
|
+
/** `schema/workflows/*.toml` — approval workflows; see `docs/workflows.md`. */
|
|
118
|
+
workflows?: SchemaWorkflow[]
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** How URLs are built, as Umbraco's `Umbraco:CMS:Global` settings set it. */
|
|
122
|
+
export interface SchemaUrls {
|
|
123
|
+
/** `HideTopLevelNodeFromPath`: true, the default, leaves the top-level page out of paths. */
|
|
124
|
+
hideTopLevel: boolean
|
|
115
125
|
}
|
|
116
126
|
|
|
117
127
|
/** A problem found while parsing or validating, always locatable. */
|