@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.
@@ -6,13 +6,16 @@
6
6
  * type and the two share nothing but the reader.
7
7
  */
8
8
  import {
9
+ BUILT_IN_FORM_TYPES,
10
+ type ChoiceSource,
11
+ type ChoiceSourceKind,
9
12
  FORM_CONDITION_OPERATORS,
10
- FORM_FIELD_TYPE_NAMES,
11
- FORM_WORKFLOW_TYPES,
12
13
  type FormCondition,
13
14
  type FormConditionOperator,
14
15
  type FormConditionRule,
15
16
  type FormFieldType,
17
+ type FormSettingValue,
18
+ type FormTypes,
16
19
  type FormWorkflowTrigger,
17
20
  type FormWorkflowType,
18
21
  formFieldType,
@@ -31,6 +34,7 @@ const FORM_KEYS = [
31
34
  'notes',
32
35
  'store-entries',
33
36
  'requires-approval',
37
+ 'step-pages',
34
38
  'submit-label',
35
39
  'next-label',
36
40
  'previous-label',
@@ -59,6 +63,7 @@ const FIELD_KEYS = [
59
63
  'css-class',
60
64
  'autocomplete',
61
65
  'values',
66
+ 'source',
62
67
  'placeholder',
63
68
  'default-value',
64
69
  'content',
@@ -70,10 +75,12 @@ const FIELD_KEYS = [
70
75
  'accept',
71
76
  'max-size-kb',
72
77
  'multiple',
78
+ 'settings',
73
79
  'condition',
74
80
  ] as const
75
81
  const CONDITION_KEYS = ['action', 'match', 'rule'] as const
76
82
  const RULE_KEYS = ['field', 'operator', 'value'] as const
83
+ const SOURCE_KEYS = ['kind', 'parent'] as const
77
84
  const WORKFLOW_KEYS = ['key', 'type', 'name', 'on', 'settings'] as const
78
85
 
79
86
  function condition(r: Reader, value: unknown, path: string): FormCondition | undefined {
@@ -112,14 +119,30 @@ function condition(r: Reader, value: unknown, path: string): FormCondition | und
112
119
  }
113
120
  }
114
121
 
115
- function field(r: Reader, t: Toml, path: string): SchemaFormField {
122
+ /** Kept as written: the validator names an unknown kind, as it does an unknown type. */
123
+ function source(r: Reader, value: unknown, path: string): ChoiceSource | undefined {
124
+ if (value === undefined) return undefined
125
+ const t = r.table(value, path)
126
+ if (!t) return undefined
127
+ r.unknown(t, SOURCE_KEYS, path)
128
+ return {
129
+ kind: (r.str(t, 'kind', path, true) ?? '') as ChoiceSourceKind,
130
+ parent: r.str(t, 'parent', path, true) ?? '',
131
+ }
132
+ }
133
+
134
+ /** Says a type is unknown by its name, and what is known: the built-ins, then each bundle's. */
135
+ function unknownType(kind: 'field' | 'workflow', type: string, known: Iterable<string>): string {
136
+ return `the ${kind} type "${type}" is not built in, and no wired bundle provides it; expected one of ${[...known].join(', ')}`
137
+ }
138
+
139
+ function field(r: Reader, t: Toml, path: string, types: FormTypes): SchemaFormField {
116
140
  r.unknown(t, FIELD_KEYS, path)
117
141
  const type = r.str(t, 'type', path, true) ?? ''
118
- if (type && !formFieldType(type))
119
- r.problem(
120
- `${path}.type`,
121
- `unknown field type; expected one of ${FORM_FIELD_TYPE_NAMES.join(', ')}`,
122
- )
142
+ if (type && !formFieldType(type, types))
143
+ r.problem(`${path}.type`, unknownType('field', type, types.fields.keys()))
144
+ const settings =
145
+ t.settings === undefined ? undefined : (r.table(t.settings, `${path}.settings`) ?? {})
123
146
  // Consent is mandatory unless the form says otherwise: a consent box that can
124
147
  // be skipped records agreement nobody gave.
125
148
  const mandatoryByDefault = type === 'dataConsent'
@@ -137,6 +160,7 @@ function field(r: Reader, t: Toml, path: string): SchemaFormField {
137
160
  cssClass: r.str(t, 'css-class', path),
138
161
  autocomplete: r.str(t, 'autocomplete', path),
139
162
  values: r.strs(t, 'values', path),
163
+ ...(t.source !== undefined ? { source: source(r, t.source, `${path}.source`) } : {}),
140
164
  placeholder: r.str(t, 'placeholder', path),
141
165
  defaultValue: r.str(t, 'default-value', path),
142
166
  content: r.str(t, 'content', path),
@@ -148,37 +172,39 @@ function field(r: Reader, t: Toml, path: string): SchemaFormField {
148
172
  accept: r.strs(t, 'accept', path),
149
173
  maxSizeKb: r.num(t, 'max-size-kb', path),
150
174
  ...(t.multiple !== undefined ? { multiple: r.bool(t, 'multiple', path, false) } : {}),
175
+ ...(settings ? { settings: settings as Record<string, FormSettingValue> } : {}),
151
176
  condition: condition(r, t.condition, `${path}.condition`),
152
177
  }
153
178
  }
154
179
 
155
- function group(r: Reader, t: Toml, path: string): SchemaFormGroup {
180
+ function group(r: Reader, t: Toml, path: string, types: FormTypes): SchemaFormGroup {
156
181
  r.unknown(t, GROUP_KEYS, path)
157
182
  return {
158
183
  caption: r.str(t, 'caption', path),
159
184
  columns: r.num(t, 'columns', path) ?? 1,
160
- fields: r.tables(t.field, `${path}.field`).map((f, i) => field(r, f, `${path}.field[${i}]`)),
185
+ fields: r
186
+ .tables(t.field, `${path}.field`)
187
+ .map((f, i) => field(r, f, `${path}.field[${i}]`, types)),
161
188
  condition: condition(r, t.condition, `${path}.condition`),
162
189
  }
163
190
  }
164
191
 
165
- function page(r: Reader, t: Toml, path: string): SchemaFormPage {
192
+ function page(r: Reader, t: Toml, path: string, types: FormTypes): SchemaFormPage {
166
193
  r.unknown(t, PAGE_KEYS, path)
167
194
  return {
168
195
  caption: r.str(t, 'caption', path),
169
- groups: r.tables(t.group, `${path}.group`).map((g, i) => group(r, g, `${path}.group[${i}]`)),
196
+ groups: r
197
+ .tables(t.group, `${path}.group`)
198
+ .map((g, i) => group(r, g, `${path}.group[${i}]`, types)),
170
199
  condition: condition(r, t.condition, `${path}.condition`),
171
200
  }
172
201
  }
173
202
 
174
- function workflow(r: Reader, t: Toml, path: string): SchemaFormWorkflow {
203
+ function workflow(r: Reader, t: Toml, path: string, types: FormTypes): SchemaFormWorkflow {
175
204
  r.unknown(t, WORKFLOW_KEYS, path)
176
205
  const type = r.str(t, 'type', path, true) ?? ''
177
- if (type && !FORM_WORKFLOW_TYPES.includes(type as FormWorkflowType))
178
- r.problem(
179
- `${path}.type`,
180
- `unknown workflow type; expected one of ${FORM_WORKFLOW_TYPES.join(', ')}`,
181
- )
206
+ if (type && !types.workflows.has(type))
207
+ r.problem(`${path}.type`, unknownType('workflow', type, types.workflows.keys()))
182
208
  const on = r.str(t, 'on', path) ?? 'submit'
183
209
  if (on !== 'submit' && on !== 'approve') r.problem(`${path}.on`, "expected 'submit' or 'approve'")
184
210
  const settings = t.settings === undefined ? {} : r.table(t.settings, `${path}.settings`)
@@ -191,7 +217,11 @@ function workflow(r: Reader, t: Toml, path: string): SchemaFormWorkflow {
191
217
  }
192
218
  }
193
219
 
194
- export function parseForm(file: string, source: string): ParseResult<SchemaForm> {
220
+ export function parseForm(
221
+ file: string,
222
+ source: string,
223
+ types: FormTypes = BUILT_IN_FORM_TYPES,
224
+ ): ParseResult<SchemaForm> {
195
225
  const r = new Reader(file)
196
226
  const root = toml(source, r)
197
227
  if (!root) return { value: undefined, problems: r.problems }
@@ -212,6 +242,7 @@ export function parseForm(file: string, source: string): ParseResult<SchemaForm>
212
242
  // what people typed would be the worst possible default.
213
243
  storeEntries: r.bool(t, 'store-entries', path, true),
214
244
  requiresApproval: r.bool(t, 'requires-approval', path, false),
245
+ ...(t['step-pages'] !== undefined ? { stepPages: r.bool(t, 'step-pages', path, false) } : {}),
215
246
  submitLabel: r.str(t, 'submit-label', path),
216
247
  nextLabel: r.str(t, 'next-label', path),
217
248
  previousLabel: r.str(t, 'previous-label', path),
@@ -222,8 +253,10 @@ export function parseForm(file: string, source: string): ParseResult<SchemaForm>
222
253
  minimumSubmitSeconds: r.num(t, 'minimum-submit-seconds', path),
223
254
  maxEntries: r.num(t, 'max-entries', path),
224
255
  since: r.str(t, 'since', path),
225
- pages: r.tables(root.page, 'page').map((p, i) => page(r, p, `page[${i}]`)),
226
- workflows: r.tables(root.workflow, 'workflow').map((w, i) => workflow(r, w, `workflow[${i}]`)),
256
+ pages: r.tables(root.page, 'page').map((p, i) => page(r, p, `page[${i}]`, types)),
257
+ workflows: r
258
+ .tables(root.workflow, 'workflow')
259
+ .map((w, i) => workflow(r, w, `workflow[${i}]`, types)),
227
260
  }
228
261
  return { value: r.problems.length === 0 ? value : undefined, problems: r.problems }
229
262
  }
package/src/parse.ts CHANGED
@@ -11,6 +11,7 @@ import {
11
11
  type SchemaProperty,
12
12
  type SchemaTab,
13
13
  type SchemaTypeKind,
14
+ type SchemaUrls,
14
15
  TYPE_KIND_FILES,
15
16
  } from './model.ts'
16
17
  import { type ParseResult, Reader, type Toml, toml } from './reader.ts'
@@ -251,12 +252,35 @@ export function parseLanguages(file: string, source: string): ParseResult<Schema
251
252
  }
252
253
 
253
254
  export function parseSchemaVersion(file: string, source: string): ParseResult<string> {
255
+ const parsed = parseSchemaSettings(file, source)
256
+ return { value: parsed.value?.version, problems: parsed.problems }
257
+ }
258
+
259
+ /** `schema/schema.toml`: the schema version, and how the site's URLs are built. */
260
+ export function parseSchemaSettings(
261
+ file: string,
262
+ source: string,
263
+ ): ParseResult<{ version: string; urls?: SchemaUrls }> {
254
264
  const r = new Reader(file)
255
265
  const root = toml(source, r)
256
266
  if (!root) return { value: undefined, problems: r.problems }
257
- r.unknown(root, ['schema'], '')
267
+ r.unknown(root, ['schema', 'urls'], '')
258
268
  const t = r.table(root.schema, 'schema')
259
269
  const version = t ? r.str(t, 'version', 'schema', true) : undefined
260
270
  if (t) r.unknown(t, ['version'], 'schema')
261
- return { value: r.problems.length === 0 ? version : undefined, problems: r.problems }
271
+ let urls: SchemaUrls | undefined
272
+ if (root.urls !== undefined) {
273
+ const u = r.table(root.urls, 'urls')
274
+ if (u) {
275
+ r.unknown(u, ['hide-top-level'], 'urls')
276
+ urls = { hideTopLevel: r.bool(u, 'hide-top-level', 'urls', true) }
277
+ }
278
+ }
279
+ return {
280
+ value:
281
+ r.problems.length === 0 && version !== undefined
282
+ ? { version, ...(urls ? { urls } : {}) }
283
+ : undefined,
284
+ problems: r.problems,
285
+ }
262
286
  }
package/src/sync.ts CHANGED
@@ -3,6 +3,7 @@
3
3
  * retirement, and a type with content refuses to disappear. The algorithm and
4
4
  * the multi-node rules are in docs/schema-as-code.md, "Synchronisation".
5
5
  */
6
+ import type { FormTypes } from '@crombolo/core'
6
7
  import { type ContentTypeAggregate, ObjectTypes, type PropertyTypeModel } from '@crombolo/core'
7
8
  import {
8
9
  appendCacheInstruction,
@@ -15,6 +16,7 @@ import {
15
16
  DataTypeRepository,
16
17
  type Db,
17
18
  DbDate,
19
+ DEFAULT_URL_OPTIONS,
18
20
  FolderRepository,
19
21
  LanguageRepository,
20
22
  Locks,
@@ -25,7 +27,12 @@ import {
25
27
  SYSTEM_CONTENT_TYPES,
26
28
  SYSTEM_MEDIA_TYPES,
27
29
  SYSTEM_MEMBER_TYPE,
30
+ sameUrlOptions,
31
+ syncWorkflows,
32
+ type UrlOptions,
28
33
  updateSchemaStateHash,
34
+ type WorkflowSyncReport,
35
+ writeUrlOptions,
29
36
  } from '@crombolo/data'
30
37
  import { BUILTIN_DATA_TYPES, storageTypeFor, systemPropertyKeys } from './builtin.ts'
31
38
  import { hashSchemaSet, type LoadedSchema } from './load.ts'
@@ -38,6 +45,7 @@ import {
38
45
  typeFileKey,
39
46
  } from './model.ts'
40
47
  import { validateSchemaSet } from './validate.ts'
48
+ import { workflowVersion } from './workflows.ts'
41
49
 
42
50
  export interface SyncNode extends NodeSchemaState {
43
51
  nodeId: string
@@ -56,6 +64,8 @@ export interface SyncOptions {
56
64
  * is pending everywhere until `upgrade` makes the state current. Default current.
57
65
  */
58
66
  status?: 'prepared' | 'current'
67
+ /** The form field and workflow types the site's wired bundles add. */
68
+ formTypes?: FormTypes
59
69
  }
60
70
 
61
71
  export type SyncAction = 'applied' | 'would-apply' | 'skipped-older' | 'skipped-same' | 'refused'
@@ -88,6 +98,10 @@ export interface SyncReport {
88
98
  revived: string[]
89
99
  assignedKeys: AssignedKey[]
90
100
  deferred: string[]
101
+ /** Set when the sync changed how URLs are built, so the old ones can be redirected. */
102
+ urls?: { before: UrlOptions; after: UrlOptions }
103
+ /** Set when a workflow changed, with what that did to requests in flight. */
104
+ workflows?: WorkflowSyncReport
91
105
  }
92
106
 
93
107
  export const emptySyncReport = (
@@ -132,7 +146,10 @@ export async function syncSchema(
132
146
  ): Promise<SyncReport> {
133
147
  const problems = [
134
148
  ...loaded.problems,
135
- ...validateSchemaSet(loaded.set, { componentAliases: options.componentAliases }),
149
+ ...validateSchemaSet(loaded.set, {
150
+ componentAliases: options.componentAliases,
151
+ types: options.formTypes,
152
+ }),
136
153
  ]
137
154
  if (problems.length > 0)
138
155
  return emptySyncReport('refused', { reason: 'the schema has problems', problems })
@@ -268,6 +285,24 @@ export async function syncSchema(
268
285
  report.updated.languages += result.updated
269
286
  }
270
287
 
288
+ // ---- how URLs are built: the files are the source, absent is the default.
289
+ // Only a current sync moves it; a prepared one serves no URLs yet.
290
+ if (status === 'current') {
291
+ const urls = { hideTopLevel: set.urls?.hideTopLevel ?? DEFAULT_URL_OPTIONS.hideTopLevel }
292
+ const before = await writeUrlOptions(tx, urls)
293
+ if (!sameUrlOptions(before, urls)) report.urls = { before, after: urls }
294
+ // Approval workflows likewise: only a current sync changes the rules
295
+ // that pages are published under.
296
+ const workflows = await syncWorkflows(
297
+ tx,
298
+ (set.workflows ?? []).map((workflow) => ({
299
+ workflow,
300
+ version: workflowVersion(workflow),
301
+ })),
302
+ )
303
+ if (workflows.changed.length > 0) report.workflows = workflows
304
+ }
305
+
271
306
  // ---- templates referenced by types must have a row
272
307
  const componentKeyByAlias = new Map<string, string>()
273
308
  for (const alias of new Set(set.contentTypes.flatMap((t) => t.components))) {
@@ -8,14 +8,20 @@
8
8
  */
9
9
  import {
10
10
  allFormFields,
11
+ BUILT_IN_FORM_TYPES,
12
+ CHOICE_SOURCE_KINDS,
11
13
  type FormCondition,
14
+ type FormTypes,
12
15
  formFieldType,
13
16
  type SchemaForm,
14
17
  type SchemaFormField,
18
+ settingsProblems,
15
19
  VALUELESS_OPERATORS,
16
20
  } from '@crombolo/core'
17
21
  import type { SchemaProblem } from './model.ts'
18
22
 
23
+ const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
24
+
19
25
  const VERSION = /^\d+(\.\d+)*$/
20
26
  const ALIAS = /^[a-zA-Z][a-zA-Z0-9]*$/
21
27
 
@@ -45,6 +51,8 @@ export interface ValidateFormsOptions {
45
51
  formThemes?: ReadonlySet<string>
46
52
  /** Content type aliases, so `saveAsContent` can be held to a real one. */
47
53
  contentTypeAliases?: ReadonlySet<string>
54
+ /** The field and workflow types in play: the built-ins, and the wired bundles'. */
55
+ types?: FormTypes
48
56
  }
49
57
 
50
58
  export function validateForms(
@@ -98,7 +106,7 @@ export function validateForms(
98
106
  'requires store-entries, since there is no entry to approve',
99
107
  )
100
108
 
101
- validateFields(form, file, add)
109
+ validateFields(form, file, options.types ?? BUILT_IN_FORM_TYPES, add)
102
110
  validateWorkflows(form, file, options, add)
103
111
  }
104
112
  return problems
@@ -106,7 +114,7 @@ export function validateForms(
106
114
 
107
115
  type Add = (file: string, path: string, message: string) => void
108
116
 
109
- function validateFields(form: SchemaForm, file: string, add: Add): void {
117
+ function validateFields(form: SchemaForm, file: string, types: FormTypes, add: Add): void {
110
118
  const fields = allFormFields(form)
111
119
  const seen = new Set<string>()
112
120
  /** Aliases usable by a condition: a field may only look at earlier ones. */
@@ -121,7 +129,7 @@ function validateFields(form: SchemaForm, file: string, add: Add): void {
121
129
  add(file, 'page.group.columns', `${group.columns} is not a column count between 1 and 4`)
122
130
  for (const field of group.fields) {
123
131
  const path = `page.group.field[${index}]`
124
- const info = formFieldType(field.type)
132
+ const info = formFieldType(field.type, types)
125
133
 
126
134
  if (!ALIAS.test(field.alias))
127
135
  add(
@@ -148,13 +156,40 @@ function validateFields(form: SchemaForm, file: string, add: Add): void {
148
156
  `a ${field.type} field collects nothing, so it cannot be sensitive`,
149
157
  )
150
158
  }
151
- if (info.choices && field.values.length === 0)
159
+ const { source } = field
160
+ if (source && field.values.length > 0)
161
+ add(
162
+ file,
163
+ `${path}.source`,
164
+ 'a field takes its choices from values or from a source, not both',
165
+ )
166
+ if (source && !info.choices)
167
+ add(file, `${path}.source`, `a ${field.type} field has nothing to choose from`)
168
+ if (source && info.choices) {
169
+ if (!CHOICE_SOURCE_KINDS.includes(source.kind))
170
+ add(
171
+ file,
172
+ `${path}.source.kind`,
173
+ `"${source.kind}" is not a kind of source; expected one of ${CHOICE_SOURCE_KINDS.join(', ')}`,
174
+ )
175
+ if (source.kind === 'content' && !UUID.test(source.parent))
176
+ add(file, `${path}.source.parent`, 'a content source names its parent page by key')
177
+ if (source.kind === 'dictionary' && source.parent.trim() === '')
178
+ add(
179
+ file,
180
+ `${path}.source.parent`,
181
+ 'a dictionary source names its parent dictionary item',
182
+ )
183
+ }
184
+ if (info.choices && !source && field.values.length === 0)
152
185
  add(file, `${path}.values`, `a ${field.type} field needs values to choose from`)
153
186
  if (!info.choices && field.values.length > 0)
154
187
  add(file, `${path}.values`, `a ${field.type} field has nothing to choose from`)
155
- // A default has to be one of the choices, or the form opens invalid.
188
+ // A default has to be one of the choices, or the form opens invalid. A
189
+ // source's choices are only known once it is resolved, so not checked here.
156
190
  if (
157
191
  info.choices &&
192
+ !source &&
158
193
  field.defaultValue !== undefined &&
159
194
  !field.values.includes(field.defaultValue)
160
195
  )
@@ -165,6 +200,21 @@ function validateFields(form: SchemaForm, file: string, add: Add): void {
165
200
  if (!info.settings.includes(setting as never))
166
201
  add(file, `${path}.${setting}`, `does not apply to a ${field.type} field`)
167
202
  }
203
+ // A built-in type's settings are keys of the field; only a bundle's
204
+ // type has a settings table, held to what it declares.
205
+ if (!info.bundle && field.settings !== undefined)
206
+ add(
207
+ file,
208
+ `${path}.settings`,
209
+ `a ${field.type} field is built in and takes no settings table; its settings are keys of the field`,
210
+ )
211
+ if (info.bundle)
212
+ for (const message of settingsProblems(
213
+ info.declared,
214
+ field.settings ?? {},
215
+ `a ${field.type} field`,
216
+ ))
217
+ add(file, `${path}.settings`, message)
168
218
  }
169
219
 
170
220
  if (field.pattern !== undefined) {
@@ -184,19 +234,27 @@ function validateFields(form: SchemaForm, file: string, add: Add): void {
184
234
  add(file, `${path}.max-size-kb`, 'must be at least 1')
185
235
 
186
236
  if (field.condition)
187
- validateCondition(field.condition, fields, earlier, file, `${path}.condition`, add)
237
+ validateCondition(field.condition, fields, earlier, file, `${path}.condition`, types, add)
188
238
  earlier.add(field.alias.toLowerCase())
189
239
  index++
190
240
  }
191
241
  if (group.condition)
192
- validateCondition(group.condition, fields, earlier, file, 'page.group.condition', add)
242
+ validateCondition(
243
+ group.condition,
244
+ fields,
245
+ earlier,
246
+ file,
247
+ 'page.group.condition',
248
+ types,
249
+ add,
250
+ )
193
251
  }
194
252
  if (page.condition)
195
- validateCondition(page.condition, fields, earlier, file, 'page.condition', add)
253
+ validateCondition(page.condition, fields, earlier, file, 'page.condition', types, add)
196
254
  }
197
255
 
198
256
  if (fields.length === 0) add(file, 'page.group.field', 'a form needs at least one field')
199
- else if (fields.every((field) => formFieldType(field.type)?.presentational))
257
+ else if (fields.every((field) => formFieldType(field.type, types)?.presentational))
200
258
  add(file, 'page.group.field', 'every field is presentational, so the form collects nothing')
201
259
  }
202
260
 
@@ -206,6 +264,7 @@ function validateCondition(
206
264
  earlier: ReadonlySet<string>,
207
265
  file: string,
208
266
  path: string,
267
+ types: FormTypes,
209
268
  add: Add,
210
269
  ): void {
211
270
  for (const [index, rule] of condition.rules.entries()) {
@@ -223,7 +282,7 @@ function validateCondition(
223
282
  `${rulePath}.field`,
224
283
  `"${rule.field}" comes later in the form, so it has no value to compare yet`,
225
284
  )
226
- if (formFieldType(target.type)?.presentational)
285
+ if (formFieldType(target.type, types)?.presentational)
227
286
  add(file, `${rulePath}.field`, `"${rule.field}" collects nothing, so it cannot be compared`)
228
287
  const valueless = VALUELESS_OPERATORS.includes(rule.operator)
229
288
  if (valueless && rule.value !== undefined)
@@ -275,6 +334,15 @@ function validateWorkflows(
275
334
  'runs on approval, but the form does not require approval so nothing ever approves it',
276
335
  )
277
336
 
337
+ const bundled = (options.types ?? BUILT_IN_FORM_TYPES).workflows.get(workflow.type)
338
+ if (bundled?.bundle) {
339
+ const where = `a ${workflow.type} workflow`
340
+ const declared = settingsProblems(bundled.declared, workflow.settings, where)
341
+ for (const message of declared) add(file, `${path}.settings`, message)
342
+ const own = declared.length === 0 ? bundled.validate?.(workflow.settings) : undefined
343
+ if (own) add(file, `${path}.settings`, own)
344
+ continue
345
+ }
278
346
  const rules = WORKFLOW_SETTINGS[workflow.type]
279
347
  if (!rules) continue
280
348
  const allowed = new Set([...rules.required, ...rules.optional])
package/src/validate.ts CHANGED
@@ -2,6 +2,7 @@
2
2
  * Whole-set validation. Every problem is reported, not just the first, because
3
3
  * a person fixing a schema wants the full list.
4
4
  */
5
+
5
6
  import { SYSTEM_MEDIA_TYPES, SYSTEM_MEMBER_TYPE } from '@crombolo/data'
6
7
  import {
7
8
  BUILTIN_DATA_TYPE_ALIASES,
@@ -18,6 +19,7 @@ import {
18
19
  TYPE_KIND_FILES,
19
20
  } from './model.ts'
20
21
  import { type ValidateFormsOptions, validateForms } from './validate-forms.ts'
22
+ import { validateWorkflows } from './workflows.ts'
21
23
 
22
24
  export interface ValidateOptions extends ValidateFormsOptions {
23
25
  /** Template aliases that exist as view files; omit to skip template checks. */
@@ -220,6 +222,12 @@ export function validateSchemaSet(set: SchemaSet, options: ValidateOptions = {})
220
222
  options.contentTypeAliases ?? new Set(set.contentTypes.map((t) => t.alias)),
221
223
  }),
222
224
  )
225
+ problems.push(
226
+ ...validateWorkflows(
227
+ set.workflows ?? [],
228
+ options.contentTypeAliases ?? new Set(set.contentTypes.map((t) => t.alias)),
229
+ ),
230
+ )
223
231
  return problems
224
232
  }
225
233