@crombolo/schema 0.0.0-stage → 0.9.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/LICENSE +21 -0
- package/NOTICE +69 -0
- package/package.json +32 -4
- package/src/builtin.ts +215 -0
- package/src/check.ts +502 -0
- package/src/export.ts +419 -0
- package/src/forms.ts +328 -0
- package/src/generate.ts +149 -0
- package/src/index.ts +19 -0
- package/src/load.ts +137 -0
- package/src/model.ts +156 -0
- package/src/parse-forms.ts +229 -0
- package/src/parse.ts +260 -0
- package/src/reader.ts +99 -0
- package/src/scaffold.ts +172 -0
- package/src/sync.ts +615 -0
- package/src/upgrade.ts +296 -0
- package/src/validate-forms.ts +346 -0
- package/src/validate.ts +242 -0
- package/src/value-migrations.ts +122 -0
- package/src/version.ts +53 -0
- package/src/write-forms.ts +139 -0
- package/src/write.ts +176 -0
- package/README.md +0 -3
package/src/forms.ts
ADDED
|
@@ -0,0 +1,328 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The form model, and the closed set of field types it may use.
|
|
3
|
+
*
|
|
4
|
+
* A form definition is structure, so it is a file like every other type
|
|
5
|
+
* (`docs/18-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.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
export type FormFieldType =
|
|
12
|
+
| 'shortAnswer'
|
|
13
|
+
| 'longAnswer'
|
|
14
|
+
| 'email'
|
|
15
|
+
| 'number'
|
|
16
|
+
| 'date'
|
|
17
|
+
| 'checkbox'
|
|
18
|
+
| 'dropdown'
|
|
19
|
+
| 'singleChoice'
|
|
20
|
+
| 'multipleChoice'
|
|
21
|
+
| 'fileUpload'
|
|
22
|
+
| 'dataConsent'
|
|
23
|
+
| 'titleAndDescription'
|
|
24
|
+
| 'richText'
|
|
25
|
+
| 'hidden'
|
|
26
|
+
|
|
27
|
+
/** How a submitted value is stored, which decides how an entry reads it back. */
|
|
28
|
+
export type FormValueKind = 'string' | 'strings' | 'number' | 'boolean' | 'files' | 'none'
|
|
29
|
+
|
|
30
|
+
export interface FormFieldTypeInfo {
|
|
31
|
+
type: FormFieldType
|
|
32
|
+
label: string
|
|
33
|
+
stores: FormValueKind
|
|
34
|
+
/** Whether a choice list (`values`) applies, and is required. */
|
|
35
|
+
choices: boolean
|
|
36
|
+
/**
|
|
37
|
+
* Collects nothing: a caption and some text on the page. Never mandatory,
|
|
38
|
+
* never in an export, and never a condition's subject.
|
|
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
|
+
}
|
package/src/generate.ts
ADDED
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `crombolo generate`: TypeScript for the content types in a schema set, so a
|
|
3
|
+
* view can name a property and have the compiler check it. Umbraco's
|
|
4
|
+
* ModelsBuilder, minus the DLL.
|
|
5
|
+
*/
|
|
6
|
+
import { SYSTEM_CONTENT_TYPES } from '@crombolo/data'
|
|
7
|
+
import { BUILTIN_DATA_TYPES, builtInContentTypeSchema, storageTypeFor } from './builtin.ts'
|
|
8
|
+
import { allProperties, type SchemaContentType, type SchemaSet } from './model.ts'
|
|
9
|
+
|
|
10
|
+
function pascal(alias: string): string {
|
|
11
|
+
const cleaned = alias.replace(/[^A-Za-z0-9]+(.)?/g, (_, next: string | undefined) =>
|
|
12
|
+
next ? next.toUpperCase() : '',
|
|
13
|
+
)
|
|
14
|
+
return cleaned.charAt(0).toUpperCase() + cleaned.slice(1)
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** The element types a block data type allows, by alias, for its content and its settings. */
|
|
18
|
+
function blockElements(set: SchemaSet, config: Record<string, unknown>) {
|
|
19
|
+
const blocks = Array.isArray(config.blocks)
|
|
20
|
+
? (config.blocks as Array<Record<string, unknown>>)
|
|
21
|
+
: []
|
|
22
|
+
const aliasOf = (key: unknown) =>
|
|
23
|
+
typeof key === 'string'
|
|
24
|
+
? set.contentTypes.find((t) => t.key?.toLowerCase() === key.toLowerCase())?.alias
|
|
25
|
+
: undefined
|
|
26
|
+
const content = blocks.flatMap((b) => aliasOf(b.contentElementTypeKey) ?? [])
|
|
27
|
+
const settings = blocks.flatMap((b) => aliasOf(b.settingsElementTypeKey) ?? [])
|
|
28
|
+
return { content: [...new Set(content)], settings: [...new Set(settings)] }
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const elementUnion = (aliases: string[]) =>
|
|
32
|
+
aliases.length === 0
|
|
33
|
+
? 'PublishedElement'
|
|
34
|
+
: aliases.map((a) => `TypedElement<${pascal(a)}>`).join(' | ')
|
|
35
|
+
|
|
36
|
+
/** What a template receives for a property of this data type (see `@crombolo/render` values). */
|
|
37
|
+
function tsTypeFor(set: SchemaSet, dataTypeAlias: string): string {
|
|
38
|
+
const custom = set.dataTypes.find((d) => d.alias === dataTypeAlias)
|
|
39
|
+
const editor = custom?.editor ?? BUILTIN_DATA_TYPES.find((b) => b.alias === dataTypeAlias)?.editor
|
|
40
|
+
const config =
|
|
41
|
+
custom?.config ?? BUILTIN_DATA_TYPES.find((b) => b.alias === dataTypeAlias)?.config ?? {}
|
|
42
|
+
if (!editor) return 'unknown'
|
|
43
|
+
switch (editor) {
|
|
44
|
+
case 'Umbraco.TrueFalse':
|
|
45
|
+
return 'boolean'
|
|
46
|
+
case 'Umbraco.ContentPicker':
|
|
47
|
+
return 'PublishedContent | null'
|
|
48
|
+
case 'Umbraco.MultiNodeTreePicker':
|
|
49
|
+
return 'PublishedContent[]'
|
|
50
|
+
case 'Umbraco.MultiUrlPicker':
|
|
51
|
+
return 'Link[]'
|
|
52
|
+
case 'Umbraco.ElementPicker': {
|
|
53
|
+
// `allowedContentTypes` is a comma-separated list of element type keys, as
|
|
54
|
+
// Umbraco's AllowedContentTypeKeysParser reads it. Narrowing to those types
|
|
55
|
+
// is what makes `hero.value('body')` typed rather than `unknown`.
|
|
56
|
+
const allowed =
|
|
57
|
+
typeof config.allowedContentTypes === 'string' ? config.allowedContentTypes : ''
|
|
58
|
+
const aliases = allowed
|
|
59
|
+
.split(',')
|
|
60
|
+
.map((key) => key.trim())
|
|
61
|
+
.filter(Boolean)
|
|
62
|
+
.flatMap(
|
|
63
|
+
(key) =>
|
|
64
|
+
set.contentTypes.find((t) => t.key?.toLowerCase() === key.toLowerCase())?.alias ?? [],
|
|
65
|
+
)
|
|
66
|
+
// `Array<>` not `[]`: a union of two element types would otherwise bind as
|
|
67
|
+
// `A | (B[])`.
|
|
68
|
+
return `Array<${elementUnion([...new Set(aliases)])}>`
|
|
69
|
+
}
|
|
70
|
+
case 'Umbraco.Tags':
|
|
71
|
+
case 'Umbraco.CheckBoxList':
|
|
72
|
+
case 'Umbraco.DropDown.Flexible':
|
|
73
|
+
case 'Umbraco.MultipleTextstring':
|
|
74
|
+
return 'string[]'
|
|
75
|
+
case 'Umbraco.UploadField':
|
|
76
|
+
return 'string'
|
|
77
|
+
case 'Umbraco.BlockList': {
|
|
78
|
+
const { content, settings } = blockElements(set, config)
|
|
79
|
+
return `Array<BlockItem & { content: ${elementUnion(content)}; settings: ${settings.length ? `${elementUnion(settings)} | null` : 'PublishedElement | null'} }>`
|
|
80
|
+
}
|
|
81
|
+
case 'Umbraco.BlockGrid': {
|
|
82
|
+
const { content } = blockElements(set, config)
|
|
83
|
+
return `Array<BlockGridItem & { content: ${elementUnion(content)} }>`
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
switch (storageTypeFor(editor)) {
|
|
87
|
+
case 'Integer':
|
|
88
|
+
case 'Decimal':
|
|
89
|
+
return 'number'
|
|
90
|
+
case 'Date':
|
|
91
|
+
case 'Nvarchar':
|
|
92
|
+
return 'string'
|
|
93
|
+
default:
|
|
94
|
+
return editor === 'Umbraco.TextArea' || editor === 'Umbraco.RichText' ? 'string' : 'unknown'
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Every property a type carries, including those from its compositions. */
|
|
99
|
+
function effectiveProperties(set: SchemaSet, type: SchemaContentType, seen = new Set<string>()) {
|
|
100
|
+
if (seen.has(type.alias)) return []
|
|
101
|
+
seen.add(type.alias)
|
|
102
|
+
const out = [...allProperties(type)]
|
|
103
|
+
for (const alias of type.compositions) {
|
|
104
|
+
// A built-in type such as `seoFields` has no file until a site extends it.
|
|
105
|
+
const shipped = SYSTEM_CONTENT_TYPES.find((t) => t.alias === alias)
|
|
106
|
+
const composed =
|
|
107
|
+
set.contentTypes.find((t) => t.alias === alias) ??
|
|
108
|
+
(shipped ? builtInContentTypeSchema(shipped) : undefined)
|
|
109
|
+
if (composed) out.push(...effectiveProperties(set, composed, seen))
|
|
110
|
+
}
|
|
111
|
+
return out
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export function generateTypes(set: SchemaSet): string {
|
|
115
|
+
const out: string[] = [
|
|
116
|
+
'// Generated by `crombolo generate` from schema/*.toml. Do not edit.',
|
|
117
|
+
'',
|
|
118
|
+
'export interface ContentTypeProperties {',
|
|
119
|
+
]
|
|
120
|
+
for (const t of [...set.contentTypes].sort((a, b) => a.alias.localeCompare(b.alias))) {
|
|
121
|
+
out.push(` ${JSON.stringify(t.alias)}: ${pascal(t.alias)}`)
|
|
122
|
+
}
|
|
123
|
+
out.push('}', '')
|
|
124
|
+
out.push('export type ContentTypeAlias = keyof ContentTypeProperties', '')
|
|
125
|
+
for (const t of [...set.contentTypes].sort((a, b) => a.alias.localeCompare(b.alias))) {
|
|
126
|
+
out.push(`/** ${t.name}${t.description ? ` — ${t.description}` : ''} */`)
|
|
127
|
+
out.push(`export interface ${pascal(t.alias)} {`)
|
|
128
|
+
const properties = effectiveProperties(set, t)
|
|
129
|
+
const byAlias = new Map(properties.map((p) => [p.alias, p]))
|
|
130
|
+
for (const p of [...byAlias.values()].sort((a, b) => a.alias.localeCompare(b.alias))) {
|
|
131
|
+
if (p.description) out.push(` /** ${p.description} */`)
|
|
132
|
+
out.push(` ${JSON.stringify(p.alias)}${p.mandatory ? '' : '?'}: ${tsTypeFor(set, p.type)}`)
|
|
133
|
+
}
|
|
134
|
+
out.push('}', '')
|
|
135
|
+
}
|
|
136
|
+
// Import only the value types the generated interfaces use.
|
|
137
|
+
const body = out.join('\n')
|
|
138
|
+
const used = [
|
|
139
|
+
'BlockGridItem',
|
|
140
|
+
'BlockItem',
|
|
141
|
+
'Link',
|
|
142
|
+
'PublishedContent',
|
|
143
|
+
'PublishedElement',
|
|
144
|
+
'TypedElement',
|
|
145
|
+
].filter((name) => new RegExp(`\\b${name}\\b`).test(body.split('\n').slice(2).join('\n')))
|
|
146
|
+
if (used.length > 0)
|
|
147
|
+
out.splice(2, 0, `import type { ${used.join(', ')} } from '@crombolo/crombolo'`, '')
|
|
148
|
+
return `${out.join('\n').trimEnd()}\n`
|
|
149
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
export * from './builtin.ts'
|
|
2
|
+
export * from './check.ts'
|
|
3
|
+
export * from './export.ts'
|
|
4
|
+
export * from './forms.ts'
|
|
5
|
+
export * from './generate.ts'
|
|
6
|
+
export * from './load.ts'
|
|
7
|
+
export * from './model.ts'
|
|
8
|
+
export * from './parse.ts'
|
|
9
|
+
export * from './parse-forms.ts'
|
|
10
|
+
export * from './reader.ts'
|
|
11
|
+
export * from './scaffold.ts'
|
|
12
|
+
export * from './sync.ts'
|
|
13
|
+
export * from './upgrade.ts'
|
|
14
|
+
export * from './validate.ts'
|
|
15
|
+
export * from './validate-forms.ts'
|
|
16
|
+
export * from './value-migrations.ts'
|
|
17
|
+
export * from './version.ts'
|
|
18
|
+
export * from './write.ts'
|
|
19
|
+
export * from './write-forms.ts'
|
package/src/load.ts
ADDED
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reads a `schema/` directory into a SchemaSet, and hashes it.
|
|
3
|
+
*
|
|
4
|
+
* The hash is over canonical serialisations, sorted — so it does not change
|
|
5
|
+
* with file order, whitespace or comments, only with meaning.
|
|
6
|
+
*/
|
|
7
|
+
import { createHash } from 'node:crypto'
|
|
8
|
+
import { existsSync, readdirSync, readFileSync } from 'node:fs'
|
|
9
|
+
import { join } from 'node:path'
|
|
10
|
+
import type { SchemaForm } from './forms.ts'
|
|
11
|
+
import type {
|
|
12
|
+
SchemaContentType,
|
|
13
|
+
SchemaDataType,
|
|
14
|
+
SchemaLanguage,
|
|
15
|
+
SchemaProblem,
|
|
16
|
+
SchemaSet,
|
|
17
|
+
} from './model.ts'
|
|
18
|
+
import { TYPE_KIND_FILES, typeFileKey } from './model.ts'
|
|
19
|
+
import {
|
|
20
|
+
parseContentType,
|
|
21
|
+
parseDataType,
|
|
22
|
+
parseLanguages,
|
|
23
|
+
parseMediaType,
|
|
24
|
+
parseMemberType,
|
|
25
|
+
parseSchemaVersion,
|
|
26
|
+
} from './parse.ts'
|
|
27
|
+
import { parseForm } from './parse-forms.ts'
|
|
28
|
+
import {
|
|
29
|
+
writeContentType,
|
|
30
|
+
writeDataType,
|
|
31
|
+
writeLanguages,
|
|
32
|
+
writeMediaType,
|
|
33
|
+
writeMemberType,
|
|
34
|
+
} from './write.ts'
|
|
35
|
+
import { writeForm } from './write-forms.ts'
|
|
36
|
+
|
|
37
|
+
export interface LoadedSchema {
|
|
38
|
+
set: SchemaSet
|
|
39
|
+
problems: SchemaProblem[]
|
|
40
|
+
/** Which file each type or data type came from, by alias. */
|
|
41
|
+
files: Map<string, string>
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function tomlFiles(dir: string): string[] {
|
|
45
|
+
if (!existsSync(dir)) return []
|
|
46
|
+
return readdirSync(dir)
|
|
47
|
+
.filter((name) => name.endsWith('.toml'))
|
|
48
|
+
.sort()
|
|
49
|
+
.map((name) => join(dir, name))
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function loadSchemaDirectory(schemaDir: string): LoadedSchema {
|
|
53
|
+
const problems: SchemaProblem[] = []
|
|
54
|
+
const files = new Map<string, string>()
|
|
55
|
+
const contentTypes: SchemaContentType[] = []
|
|
56
|
+
const dataTypes: SchemaDataType[] = []
|
|
57
|
+
let languages: SchemaLanguage[] = []
|
|
58
|
+
let version = '0'
|
|
59
|
+
|
|
60
|
+
const rel = (file: string) =>
|
|
61
|
+
file.startsWith(schemaDir) ? `schema${file.slice(schemaDir.length)}` : file
|
|
62
|
+
|
|
63
|
+
const versionFile = join(schemaDir, 'schema.toml')
|
|
64
|
+
if (existsSync(versionFile)) {
|
|
65
|
+
const parsed = parseSchemaVersion(rel(versionFile), readFileSync(versionFile, 'utf8'))
|
|
66
|
+
problems.push(...parsed.problems)
|
|
67
|
+
if (parsed.value) version = parsed.value
|
|
68
|
+
}
|
|
69
|
+
for (const file of tomlFiles(join(schemaDir, TYPE_KIND_FILES.document.dir))) {
|
|
70
|
+
const parsed = parseContentType(rel(file), readFileSync(file, 'utf8'))
|
|
71
|
+
problems.push(...parsed.problems)
|
|
72
|
+
if (parsed.value) {
|
|
73
|
+
contentTypes.push(parsed.value)
|
|
74
|
+
files.set(parsed.value.alias, file)
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
const mediaTypes: SchemaContentType[] = []
|
|
78
|
+
for (const file of tomlFiles(join(schemaDir, 'media-types'))) {
|
|
79
|
+
const parsed = parseMediaType(rel(file), readFileSync(file, 'utf8'))
|
|
80
|
+
problems.push(...parsed.problems)
|
|
81
|
+
if (parsed.value) {
|
|
82
|
+
mediaTypes.push(parsed.value)
|
|
83
|
+
files.set(typeFileKey('media', parsed.value.alias), file)
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
const memberTypes: SchemaContentType[] = []
|
|
87
|
+
for (const file of tomlFiles(join(schemaDir, 'member-types'))) {
|
|
88
|
+
const parsed = parseMemberType(rel(file), readFileSync(file, 'utf8'))
|
|
89
|
+
problems.push(...parsed.problems)
|
|
90
|
+
if (parsed.value) {
|
|
91
|
+
memberTypes.push(parsed.value)
|
|
92
|
+
files.set(typeFileKey('member', parsed.value.alias), file)
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
for (const file of tomlFiles(join(schemaDir, 'data-types'))) {
|
|
96
|
+
const parsed = parseDataType(rel(file), readFileSync(file, 'utf8'))
|
|
97
|
+
problems.push(...parsed.problems)
|
|
98
|
+
if (parsed.value) {
|
|
99
|
+
dataTypes.push(parsed.value)
|
|
100
|
+
files.set(`data-type:${parsed.value.alias}`, file)
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
const forms: SchemaForm[] = []
|
|
104
|
+
for (const file of tomlFiles(join(schemaDir, 'forms'))) {
|
|
105
|
+
const parsed = parseForm(rel(file), readFileSync(file, 'utf8'))
|
|
106
|
+
problems.push(...parsed.problems)
|
|
107
|
+
if (parsed.value) {
|
|
108
|
+
forms.push(parsed.value)
|
|
109
|
+
files.set(`form:${parsed.value.alias}`, file)
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
const languagesFile = join(schemaDir, 'languages.toml')
|
|
113
|
+
if (existsSync(languagesFile)) {
|
|
114
|
+
const parsed = parseLanguages(rel(languagesFile), readFileSync(languagesFile, 'utf8'))
|
|
115
|
+
problems.push(...parsed.problems)
|
|
116
|
+
if (parsed.value) languages = parsed.value
|
|
117
|
+
}
|
|
118
|
+
return {
|
|
119
|
+
set: { version, contentTypes, mediaTypes, memberTypes, dataTypes, languages, forms },
|
|
120
|
+
problems,
|
|
121
|
+
files,
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** Order-independent content hash of a schema set. */
|
|
126
|
+
export function hashSchemaSet(set: SchemaSet): string {
|
|
127
|
+
const parts = [
|
|
128
|
+
`version:${set.version}`,
|
|
129
|
+
...set.contentTypes.map((t) => writeContentType(t)).sort(),
|
|
130
|
+
...(set.mediaTypes ?? []).map((t) => writeMediaType(t)).sort(),
|
|
131
|
+
...(set.memberTypes ?? []).map((t) => writeMemberType(t)).sort(),
|
|
132
|
+
...set.dataTypes.map((d) => writeDataType(d)).sort(),
|
|
133
|
+
writeLanguages([...set.languages].sort((a, b) => a.iso.localeCompare(b.iso))),
|
|
134
|
+
...(set.forms ?? []).map((f) => writeForm(f)).sort(),
|
|
135
|
+
]
|
|
136
|
+
return createHash('sha256').update(parts.join('\n---\n')).digest('hex')
|
|
137
|
+
}
|