@crombolo/schema 0.10.0 → 0.12.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.10.0",
3
+ "version": "0.12.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.10.0",
32
- "@crombolo/data": "0.10.0"
31
+ "@crombolo/core": "0.12.1",
32
+ "@crombolo/data": "0.12.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'
@@ -67,6 +68,7 @@ function toSchemaProperty(p: PropertyTypeModel, ctx: ExportContext): SchemaPrope
67
68
  if (p.memberCanView) property.memberCanView = true
68
69
  if (p.memberCanEdit) property.memberCanEdit = true
69
70
  if (p.isSensitive) property.sensitive = true
71
+ if (p.searchable === false) property.searchable = false
70
72
  return property
71
73
  }
72
74
 
@@ -187,6 +189,7 @@ function isUnchangedBuiltin(dataType: SchemaDataType): boolean {
187
189
 
188
190
  /** Every live definition in the database as a schema set; keys are always present. */
189
191
  export async function exportSchemaSet(db: Db, version: string): Promise<SchemaSet> {
192
+ const urls = await readUrlOptions(db)
190
193
  const types = new ContentTypeRepository(db)
191
194
  const dataTypes = new DataTypeRepository(db)
192
195
  const templates = new ComponentRepository(db)
@@ -329,6 +332,7 @@ export async function exportSchemaSet(db: Db, version: string): Promise<SchemaSe
329
332
  memberTypes,
330
333
  dataTypes: exportedDataTypes,
331
334
  languages,
335
+ ...(urls.hideTopLevel ? {} : { urls }),
332
336
  }
333
337
  }
334
338
 
@@ -341,7 +345,7 @@ export function writeSchemaFiles(schemaDir: string, set: SchemaSet): string[] {
341
345
  writeFileSync(file, content)
342
346
  written.push(file)
343
347
  }
344
- put('schema.toml', writeSchemaVersion(set.version))
348
+ put('schema.toml', writeSchemaVersion(set.version, set.urls))
345
349
  for (const t of set.contentTypes)
346
350
  put(join(TYPE_KIND_FILES.document.dir, fileNameFor(t.alias)), writeContentType(t))
347
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/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 } 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,7 +25,7 @@ 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'
28
31
  import {
@@ -49,22 +52,34 @@ function tomlFiles(dir: string): string[] {
49
52
  .map((name) => join(dir, name))
50
53
  }
51
54
 
52
- export function loadSchemaDirectory(schemaDir: string): LoadedSchema {
55
+ export interface LoadSchemaOptions {
56
+ /** The field and workflow types forms may use: the built-ins, and the wired bundles'. */
57
+ formTypes?: FormTypes
58
+ }
59
+
60
+ export function loadSchemaDirectory(
61
+ schemaDir: string,
62
+ options: LoadSchemaOptions = {},
63
+ ): LoadedSchema {
53
64
  const problems: SchemaProblem[] = []
54
65
  const files = new Map<string, string>()
55
66
  const contentTypes: SchemaContentType[] = []
56
67
  const dataTypes: SchemaDataType[] = []
57
68
  let languages: SchemaLanguage[] = []
58
69
  let version = '0'
70
+ let urls: SchemaUrls | undefined
59
71
 
60
72
  const rel = (file: string) =>
61
73
  file.startsWith(schemaDir) ? `schema${file.slice(schemaDir.length)}` : file
62
74
 
63
75
  const versionFile = join(schemaDir, 'schema.toml')
64
76
  if (existsSync(versionFile)) {
65
- const parsed = parseSchemaVersion(rel(versionFile), readFileSync(versionFile, 'utf8'))
77
+ const parsed = parseSchemaSettings(rel(versionFile), readFileSync(versionFile, 'utf8'))
66
78
  problems.push(...parsed.problems)
67
- if (parsed.value) version = parsed.value
79
+ if (parsed.value) {
80
+ version = parsed.value.version
81
+ urls = parsed.value.urls
82
+ }
68
83
  }
69
84
  for (const file of tomlFiles(join(schemaDir, TYPE_KIND_FILES.document.dir))) {
70
85
  const parsed = parseContentType(rel(file), readFileSync(file, 'utf8'))
@@ -102,7 +117,7 @@ export function loadSchemaDirectory(schemaDir: string): LoadedSchema {
102
117
  }
103
118
  const forms: SchemaForm[] = []
104
119
  for (const file of tomlFiles(join(schemaDir, 'forms'))) {
105
- const parsed = parseForm(rel(file), readFileSync(file, 'utf8'))
120
+ const parsed = parseForm(rel(file), readFileSync(file, 'utf8'), options.formTypes)
106
121
  problems.push(...parsed.problems)
107
122
  if (parsed.value) {
108
123
  forms.push(parsed.value)
@@ -116,7 +131,16 @@ export function loadSchemaDirectory(schemaDir: string): LoadedSchema {
116
131
  if (parsed.value) languages = parsed.value
117
132
  }
118
133
  return {
119
- set: { version, contentTypes, mediaTypes, memberTypes, dataTypes, languages, forms },
134
+ set: {
135
+ version,
136
+ contentTypes,
137
+ mediaTypes,
138
+ memberTypes,
139
+ dataTypes,
140
+ languages,
141
+ forms,
142
+ ...(urls ? { urls } : {}),
143
+ },
120
144
  problems,
121
145
  files,
122
146
  }
@@ -126,6 +150,8 @@ export function loadSchemaDirectory(schemaDir: string): LoadedSchema {
126
150
  export function hashSchemaSet(set: SchemaSet): string {
127
151
  const parts = [
128
152
  `version:${set.version}`,
153
+ // Only when not the default, so every site hashed before this kept its hash.
154
+ ...(set.urls?.hideTopLevel === false ? ['urls:top-level-shown'] : []),
129
155
  ...set.contentTypes.map((t) => writeContentType(t)).sort(),
130
156
  ...(set.mediaTypes ?? []).map((t) => writeMediaType(t)).sort(),
131
157
  ...(set.memberTypes ?? []).map((t) => writeMemberType(t)).sort(),
package/src/model.ts CHANGED
@@ -26,6 +26,8 @@ export interface SchemaProperty {
26
26
  memberCanEdit?: boolean
27
27
  /** Member types only: hidden from users without sensitive-data access. */
28
28
  sensitive?: boolean
29
+ /** False keeps the property's values out of backoffice search; searchable when absent. */
30
+ searchable?: boolean
29
31
  /** Fills existing content when a required property is added to a type with pages. */
30
32
  default?: unknown
31
33
  since?: string
@@ -110,6 +112,14 @@ export interface SchemaSet {
110
112
  languages: SchemaLanguage[]
111
113
  /** `schema/forms/*.toml` — form definitions; see `docs/forms.md`. */
112
114
  forms?: SchemaForm[]
115
+ /** `[urls]` in schema/schema.toml; Umbraco's defaults when absent. */
116
+ urls?: SchemaUrls
117
+ }
118
+
119
+ /** How URLs are built, as Umbraco's `Umbraco:CMS:Global` settings set it. */
120
+ export interface SchemaUrls {
121
+ /** `HideTopLevelNodeFromPath`: true, the default, leaves the top-level page out of paths. */
122
+ hideTopLevel: boolean
113
123
  }
114
124
 
115
125
  /** A problem found while parsing or validating, always locatable. */
@@ -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'
@@ -21,6 +22,7 @@ const PROPERTY_KEYS = [
21
22
  'member-can-view',
22
23
  'member-can-edit',
23
24
  'sensitive',
25
+ 'searchable',
24
26
  'key',
25
27
  'alias',
26
28
  'name',
@@ -101,6 +103,7 @@ function property(r: Reader, t: Toml, path: string): SchemaProperty {
101
103
  ? { memberCanEdit: r.bool(t, 'member-can-edit', path, false) }
102
104
  : {}),
103
105
  ...(t.sensitive !== undefined ? { sensitive: r.bool(t, 'sensitive', path, false) } : {}),
106
+ ...(t.searchable !== undefined ? { searchable: r.bool(t, 'searchable', path, true) } : {}),
104
107
  default: t.default,
105
108
  since: r.str(t, 'since', path),
106
109
  }
@@ -249,12 +252,35 @@ export function parseLanguages(file: string, source: string): ParseResult<Schema
249
252
  }
250
253
 
251
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 }> {
252
264
  const r = new Reader(file)
253
265
  const root = toml(source, r)
254
266
  if (!root) return { value: undefined, problems: r.problems }
255
- r.unknown(root, ['schema'], '')
267
+ r.unknown(root, ['schema', 'urls'], '')
256
268
  const t = r.table(root.schema, 'schema')
257
269
  const version = t ? r.str(t, 'version', 'schema', true) : undefined
258
270
  if (t) r.unknown(t, ['version'], 'schema')
259
- 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
+ }
260
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,10 @@ import {
25
27
  SYSTEM_CONTENT_TYPES,
26
28
  SYSTEM_MEDIA_TYPES,
27
29
  SYSTEM_MEMBER_TYPE,
30
+ sameUrlOptions,
31
+ type UrlOptions,
28
32
  updateSchemaStateHash,
33
+ writeUrlOptions,
29
34
  } from '@crombolo/data'
30
35
  import { BUILTIN_DATA_TYPES, storageTypeFor, systemPropertyKeys } from './builtin.ts'
31
36
  import { hashSchemaSet, type LoadedSchema } from './load.ts'
@@ -56,6 +61,8 @@ export interface SyncOptions {
56
61
  * is pending everywhere until `upgrade` makes the state current. Default current.
57
62
  */
58
63
  status?: 'prepared' | 'current'
64
+ /** The form field and workflow types the site's wired bundles add. */
65
+ formTypes?: FormTypes
59
66
  }
60
67
 
61
68
  export type SyncAction = 'applied' | 'would-apply' | 'skipped-older' | 'skipped-same' | 'refused'
@@ -88,6 +95,8 @@ export interface SyncReport {
88
95
  revived: string[]
89
96
  assignedKeys: AssignedKey[]
90
97
  deferred: string[]
98
+ /** Set when the sync changed how URLs are built, so the old ones can be redirected. */
99
+ urls?: { before: UrlOptions; after: UrlOptions }
91
100
  }
92
101
 
93
102
  export const emptySyncReport = (
@@ -132,7 +141,10 @@ export async function syncSchema(
132
141
  ): Promise<SyncReport> {
133
142
  const problems = [
134
143
  ...loaded.problems,
135
- ...validateSchemaSet(loaded.set, { componentAliases: options.componentAliases }),
144
+ ...validateSchemaSet(loaded.set, {
145
+ componentAliases: options.componentAliases,
146
+ types: options.formTypes,
147
+ }),
136
148
  ]
137
149
  if (problems.length > 0)
138
150
  return emptySyncReport('refused', { reason: 'the schema has problems', problems })
@@ -268,6 +280,14 @@ export async function syncSchema(
268
280
  report.updated.languages += result.updated
269
281
  }
270
282
 
283
+ // ---- how URLs are built: the files are the source, absent is the default.
284
+ // Only a current sync moves it; a prepared one serves no URLs yet.
285
+ if (status === 'current') {
286
+ const urls = { hideTopLevel: set.urls?.hideTopLevel ?? DEFAULT_URL_OPTIONS.hideTopLevel }
287
+ const before = await writeUrlOptions(tx, urls)
288
+ if (!sameUrlOptions(before, urls)) report.urls = { before, after: urls }
289
+ }
290
+
271
291
  // ---- templates referenced by types must have a row
272
292
  const componentKeyByAlias = new Map<string, string>()
273
293
  for (const alias of new Set(set.contentTypes.flatMap((t) => t.components))) {
@@ -418,6 +438,7 @@ export async function syncSchema(
418
438
  memberCanView: p.memberCanView === true,
419
439
  memberCanEdit: p.memberCanEdit === true,
420
440
  isSensitive: p.sensitive === true,
441
+ searchable: p.searchable !== false,
421
442
  }
422
443
  }
423
444
  // Groups have no key in the file; they match an existing group of the same name.
@@ -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])
@@ -73,6 +73,8 @@ function writeField(out: string[], header: string, field: SchemaFormField): void
73
73
  line(out, 'css-class', field.cssClass)
74
74
  line(out, 'autocomplete', field.autocomplete)
75
75
  line(out, 'values', field.values, field.values.length > 0)
76
+ if (field.source)
77
+ out.push(`source = { kind = ${str(field.source.kind)}, parent = ${str(field.source.parent)} }`)
76
78
  line(out, 'placeholder', field.placeholder)
77
79
  line(out, 'default-value', field.defaultValue)
78
80
  line(out, 'content', field.content)
@@ -84,6 +86,12 @@ function writeField(out: string[], header: string, field: SchemaFormField): void
84
86
  line(out, 'accept', field.accept, field.accept.length > 0)
85
87
  line(out, 'max-size-kb', field.maxSizeKb)
86
88
  line(out, 'multiple', field.multiple)
89
+ const settings = Object.keys(field.settings ?? {}).sort()
90
+ // Inline, so a bundle type's settings stay inside the one field they belong to.
91
+ if (field.settings)
92
+ out.push(
93
+ `settings = { ${settings.map((key) => `${keyOf(key)} = ${scalar(field.settings?.[key])}`).join(', ')} }`,
94
+ )
87
95
  if (field.condition) writeCondition(out, `${header}.condition`, field.condition)
88
96
  }
89
97
 
@@ -123,6 +131,7 @@ export function writeForm(form: SchemaForm): string {
123
131
  line(out, 'notes', form.notes)
124
132
  line(out, 'store-entries', false, !form.storeEntries)
125
133
  line(out, 'requires-approval', true, form.requiresApproval)
134
+ line(out, 'step-pages', true, form.stepPages === true)
126
135
  line(out, 'submit-label', form.submitLabel)
127
136
  line(out, 'next-label', form.nextLabel)
128
137
  line(out, 'previous-label', form.previousLabel)
package/src/write.ts CHANGED
@@ -12,6 +12,7 @@ import {
12
12
  type SchemaLanguage,
13
13
  type SchemaProperty,
14
14
  type SchemaTypeKind,
15
+ type SchemaUrls,
15
16
  TYPE_KIND_FILES,
16
17
  } from './model.ts'
17
18
 
@@ -65,6 +66,7 @@ function writeProperty(out: string[], header: string, p: SchemaProperty): void {
65
66
  line(out, 'member-can-view', true, p.memberCanView === true)
66
67
  line(out, 'member-can-edit', true, p.memberCanEdit === true)
67
68
  line(out, 'sensitive', true, p.sensitive === true)
69
+ line(out, 'searchable', false, p.searchable === false)
68
70
  line(out, 'default', p.default)
69
71
  line(out, 'since', p.since)
70
72
  }
@@ -161,8 +163,10 @@ export function writeLanguages(languages: readonly SchemaLanguage[]): string {
161
163
  return `${out.join('\n')}\n`
162
164
  }
163
165
 
164
- export function writeSchemaVersion(version: string): string {
165
- return `[schema]\nversion = ${str(version)}\n`
166
+ /** `schema/schema.toml`. `[urls]` is written only when it differs from Umbraco's defaults. */
167
+ export function writeSchemaVersion(version: string, urls?: SchemaUrls): string {
168
+ const out = `[schema]\nversion = ${str(version)}\n`
169
+ return urls && !urls.hideTopLevel ? `${out}\n[urls]\nhide-top-level = false\n` : out
166
170
  }
167
171
 
168
172
  /** The canonical filename for an alias: kebab-case. */