@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@crombolo/schema",
3
- "version": "0.11.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.11.0",
32
- "@crombolo/data": "0.11.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 inFiles = new Set(loaded.set.contentTypes.map((t) => t.alias))
218
- const dbTypes = await db.query<{ unique_id: string; alias: string }>(
219
- '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',
220
- )
221
- for (const row of dbTypes) {
222
- if (inFiles.has(String(row.alias))) continue
223
- const count = await types.contentCount(String(row.unique_id).toLowerCase())
224
- if (count === 0) continue
225
- findings.push({
226
- kind: 'blocking',
227
- code: 'type-removed-with-content',
228
- subjectType: 'document-type',
229
- subjectKey: String(row.unique_id).toLowerCase(),
230
- subjectName: String(row.alias),
231
- propertyAlias: null,
232
- culture: null,
233
- message: `content type "${row.alias}" was removed from schema/ but ${count} document(s) of it exist`,
234
- link: null,
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, 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/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
- 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
- }
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
@@ -15,5 +15,6 @@ export * from './validate.ts'
15
15
  export * from './validate-forms.ts'
16
16
  export * from './value-migrations.ts'
17
17
  export * from './version.ts'
18
+ export * from './workflows.ts'
18
19
  export * from './write.ts'
19
20
  export * from './write-forms.ts'
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
- parseSchemaVersion,
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 function loadSchemaDirectory(schemaDir: string): LoadedSchema {
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 = parseSchemaVersion(rel(versionFile), readFileSync(versionFile, 'utf8'))
78
+ const parsed = parseSchemaSettings(rel(versionFile), readFileSync(versionFile, 'utf8'))
66
79
  problems.push(...parsed.problems)
67
- if (parsed.value) version = 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: { version, contentTypes, mediaTypes, memberTypes, dataTypes, languages, forms },
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. */