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